001package gudusoft.gsqlparser.resolver2; 002 003import gudusoft.gsqlparser.TBaseType; 004import gudusoft.gsqlparser.TCustomSqlStatement; 005import gudusoft.gsqlparser.IRelation; 006import gudusoft.gsqlparser.TLog; 007import gudusoft.gsqlparser.TSourceToken; 008import gudusoft.gsqlparser.TStatementList; 009import gudusoft.gsqlparser.ETableSource; 010import gudusoft.gsqlparser.EDbVendor; 011import gudusoft.gsqlparser.EDbObjectType; 012import gudusoft.gsqlparser.EErrorType; 013import gudusoft.gsqlparser.ESqlClause; 014import gudusoft.gsqlparser.ESqlStatementType; 015import gudusoft.gsqlparser.TSyntaxError; 016import gudusoft.gsqlparser.stmt.dax.TDaxStmt; 017import gudusoft.gsqlparser.stmt.TAlterTableStatement; 018import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement; 019import gudusoft.gsqlparser.stmt.TInsertSqlStatement; 020import gudusoft.gsqlparser.stmt.TUpdateSqlStatement; 021import gudusoft.gsqlparser.stmt.TUseDatabase; 022import gudusoft.gsqlparser.stmt.TDeleteSqlStatement; 023import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 024import gudusoft.gsqlparser.compiler.TContext; 025import gudusoft.gsqlparser.nodes.TObjectName; 026import gudusoft.gsqlparser.nodes.TObjectNameList; 027import gudusoft.gsqlparser.nodes.TTable; 028import gudusoft.gsqlparser.nodes.TTableList; 029import gudusoft.gsqlparser.nodes.TJoinExpr; 030import gudusoft.gsqlparser.nodes.TParseTreeNode; 031import gudusoft.gsqlparser.nodes.TParseTreeVisitor; 032import gudusoft.gsqlparser.nodes.TResultColumn; 033import gudusoft.gsqlparser.nodes.TResultColumnList; 034import gudusoft.gsqlparser.nodes.TQualifyClause; 035import gudusoft.gsqlparser.nodes.TExpression; 036import gudusoft.gsqlparser.EExpressionType; 037import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource; 038import gudusoft.gsqlparser.resolver2.model.ColumnSource; 039import gudusoft.gsqlparser.resolver2.model.FromScopeIndex; 040import gudusoft.gsqlparser.resolver2.model.ResolutionContext; 041import gudusoft.gsqlparser.resolver2.model.ResolutionResult; 042import gudusoft.gsqlparser.resolver2.model.ResolutionStatistics; 043import gudusoft.gsqlparser.resolver2.ResolutionStatus; 044import gudusoft.gsqlparser.resolver2.result.IResolutionResult; 045import gudusoft.gsqlparser.resolver2.result.ResolutionResultImpl; 046import gudusoft.gsqlparser.resolver2.scope.FromScope; 047import gudusoft.gsqlparser.resolver2.scope.GlobalScope; 048import gudusoft.gsqlparser.resolver2.scope.IScope; 049import gudusoft.gsqlparser.resolver2.scope.SelectScope; 050import gudusoft.gsqlparser.resolver2.scope.CTEScope; 051import gudusoft.gsqlparser.resolver2.scope.GroupByScope; 052import gudusoft.gsqlparser.resolver2.scope.HavingScope; 053import gudusoft.gsqlparser.resolver2.scope.OrderByScope; 054import gudusoft.gsqlparser.resolver2.scope.UpdateScope; 055import gudusoft.gsqlparser.resolver2.scope.DeleteScope; 056import gudusoft.gsqlparser.resolver2.namespace.INamespace; 057import gudusoft.gsqlparser.resolver2.namespace.TableNamespace; 058import gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace; 059import gudusoft.gsqlparser.resolver2.namespace.CTENamespace; 060import gudusoft.gsqlparser.nodes.TCTE; 061import gudusoft.gsqlparser.nodes.TCTEList; 062import gudusoft.gsqlparser.nodes.TUnnestClause; 063import gudusoft.gsqlparser.stmt.TSelectSqlStatement; 064import gudusoft.gsqlparser.resolver2.iterative.ConvergenceDetector; 065import gudusoft.gsqlparser.resolver2.iterative.ResolutionPass; 066import gudusoft.gsqlparser.resolver2.enhancement.NamespaceEnhancer; 067import gudusoft.gsqlparser.resolver2.enhancement.EnhancementResult; 068import gudusoft.gsqlparser.resolver2.enhancement.CollectedColumnRef; 069import gudusoft.gsqlparser.resolver2.metadata.BatchMetadataCollector; 070import gudusoft.gsqlparser.resolver2.context.DatabaseContextTracker; 071import gudusoft.gsqlparser.resolver2.namespace.CTENamespace; 072import gudusoft.gsqlparser.sqlenv.CanonKey; 073import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 074import gudusoft.gsqlparser.sqlenv.TSQLEnv; 075import gudusoft.gsqlparser.util.SQLUtil; 076import gudusoft.gsqlparser.TAttributeNode; 077import gudusoft.gsqlparser.resolver2.binding.BindingDiagnostic; 078import gudusoft.gsqlparser.resolver2.binding.BindingDiagnosticPostPass; 079import gudusoft.gsqlparser.resolver2.binding.BindingMetadataAuthority; 080import gudusoft.gsqlparser.resolver2.binding.BindingResult; 081import gudusoft.gsqlparser.resolver2.binding.ColumnAuthority; 082 083import java.util.ArrayDeque; 084import java.util.ArrayList; 085import java.util.Deque; 086import java.util.HashMap; 087import java.util.HashSet; 088import java.util.IdentityHashMap; 089import java.util.List; 090import java.util.Map; 091import java.util.Set; 092 093// ScopeBuilder for visitor-based scope construction 094import gudusoft.gsqlparser.resolver2.ScopeBuilder; 095import gudusoft.gsqlparser.resolver2.ScopeBuildResult; 096 097/** 098 * New SQL Resolver - Phase 2 Enhanced Framework 099 * 100 * This is the main entry point for the new resolution architecture. 101 * Provides improved column-to-table resolution with: 102 * - Clear scope-based name resolution 103 * - Full candidate collection for ambiguous cases 104 * - Confidence-scored inference 105 * - Better tracing and debugging 106 * 107 * Usage: 108 * <pre> 109 * TSQLResolver2 resolver = new TSQLResolver2(context, statements); 110 * boolean success = resolver.resolve(); 111 * ResolutionStatistics stats = resolver.getStatistics(); 112 * </pre> 113 * 114 * Phase 1 capabilities: 115 * - Basic SELECT statement resolution 116 * - Table and subquery namespaces 117 * - Qualified and unqualified column references 118 * - FROM clause scope management 119 * 120 * Phase 2 capabilities: 121 * - JOIN scope handling with nullable semantics 122 * - CTE (WITH clause) resolution 123 * - Iterative resolution framework (auto-converges after first pass if no iteration needed) 124 * 125 * Future phases will add: 126 * - Evidence-based inference 127 * - Star column expansion 128 */ 129public class TSQLResolver2 { 130 131 /** 132 * Immutable observation of one {@link #resolve()} invocation. 133 * 134 * <p>This is deliberately diagnostic-only. The parser continues to ignore 135 * the boolean returned by {@code resolve()} exactly as before; callers that 136 * need to distinguish "resolver object exists" from "resolver completed" 137 * can inspect this value after parsing.</p> 138 */ 139 public static final class RunOutcome { 140 141 private static final RunOutcome NOT_ATTEMPTED = 142 new RunOutcome(false, false, null, null); 143 private static final RunOutcome ATTEMPTED = 144 new RunOutcome(true, false, null, null); 145 146 private final boolean attempted; 147 private final boolean successful; 148 private final String failureClassName; 149 private final String failureMessage; 150 151 private RunOutcome(boolean attempted, boolean successful, 152 String failureClassName, String failureMessage) { 153 this.attempted = attempted; 154 this.successful = successful; 155 this.failureClassName = failureClassName; 156 this.failureMessage = failureMessage; 157 } 158 159 private static RunOutcome notAttempted() { 160 return NOT_ATTEMPTED; 161 } 162 163 private static RunOutcome attempted() { 164 return ATTEMPTED; 165 } 166 167 private static RunOutcome completed(boolean successful) { 168 return new RunOutcome(true, successful, null, null); 169 } 170 171 private static RunOutcome failed(Exception failure) { 172 return new RunOutcome(true, false, 173 failure.getClass().getName(), failure.getMessage()); 174 } 175 176 public boolean isAttempted() { 177 return attempted; 178 } 179 180 public boolean isSuccessful() { 181 return successful; 182 } 183 184 public String getFailureClassName() { 185 return failureClassName; 186 } 187 188 public String getFailureMessage() { 189 return failureMessage; 190 } 191 } 192 193 private final TContext globalContext; 194 private final TStatementList sqlStatements; 195 private final TSQLResolverConfig config; 196 private final ResolutionContext resolutionContext; 197 private final NameResolver nameResolver; 198 199 /** 200 * Binding traces for the dynamic-SQL publication proof; {@code null} 201 * unless {@link TSQLResolverConfig#isCaptureBindingTrace()}. 202 */ 203 private final gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry bindingTraceRegistry; 204 205 /** @see #bindingTraceRegistry */ 206 public gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry getBindingTraceRegistry() { 207 return bindingTraceRegistry; 208 } 209 210 /** Global scope (root of scope tree) */ 211 private GlobalScope globalScope; 212 213 /** Convergence detector for iterative resolution */ 214 private ConvergenceDetector convergenceDetector; 215 216 /** History of all resolution passes */ 217 private final List<ResolutionPass> passHistory; 218 219 /** 220 * Scope cache for iterative resolution. 221 * Maps statements to their scope trees to avoid rebuilding scopes on each pass. 222 * Key: TCustomSqlStatement, Value: SelectScope (or other scope type) 223 */ 224 private final java.util.Map<Object, IScope> statementScopeCache; 225 226 /** 227 * Column-to-Scope mapping for iterative resolution (Principle 1: Scope完全复用). 228 * Built once in Pass 1, reused in Pass 2+ to avoid rebuilding scopes. 229 * Maps each TObjectName (column reference) to the IScope where it should be resolved. 230 */ 231 private final java.util.Map<TObjectName, IScope> columnToScopeMap; 232 233 /** 234 * FromScope index cache for O(1) table/namespace lookups (Performance Optimization B). 235 * Maps FromScope instances to their pre-built indexes. 236 * Built lazily on first access, cleared at the start of each resolve() call. 237 * Uses IdentityHashMap because we need object identity, not equals(). 238 */ 239 private final Map<IScope, FromScopeIndex> fromScopeIndexCache; 240 241 /** 242 * Cache for Teradata NAMED alias lookup. 243 * Maps SELECT statements to their alias index (alias name -> TResultColumn). 244 * Uses IdentityHashMap because we need object identity, not equals(). 245 * Optimization C: Reduces O(cols * select_items) to O(cols) for Teradata. 246 */ 247 private final Map<TSelectSqlStatement, Map<String, TResultColumn>> teradataNamedAliasCache; 248 249 /** Resolver-invocation-local identity index for legacy linked-column lists. */ 250 private final Map<TObjectNameList, Set<TObjectName>> linkedColumnIdentityCache; 251 252 /** 253 * Mantis 4651 — the synthetic clones produced by {@link #createTracedColumnClones()}, 254 * held by identity. 255 * 256 * <p>A column referenced through a derived table that selects a star 257 * (e.g. {@code SELECT c FROM (SELECT * FROM t)}) can reach the physical table's 258 * {@code linkedColumns} through two independent paths in 259 * {@link #syncColumnToLegacy(TObjectName)}: the traced clone (whose 260 * {@code sourceTable} is the physical table) is appended by the generic add, 261 * and the original reference (whose {@code sourceTable} is the derived table) is 262 * appended by the subquery fallback. Both used to fire, so 263 * {@code TTable.getLinkedColumns()} reported the same column twice. This set and 264 * {@link #tracedStarCloneKeys} let each path see what the other contributed. 265 */ 266 private final Set<TObjectName> tracedStarClones; 267 268 /** 269 * Mantis 4651 — the physical columns covered by {@link #tracedStarClones}, 270 * indexed by table identity, so the subquery fallback in 271 * {@link #syncColumnToLegacy(TObjectName)} can tell that an equivalent clone 272 * already carries the link. 273 * 274 * <p>Structural on both axes on purpose: the table is the map's identity key 275 * rather than its {@code identityHashCode} (two tables can share a 32-bit hash, 276 * and a collision here would suppress the second table's only physical link), 277 * and the column is a {@link CanonKey} rather than a folded string, so equality 278 * is exactly {@link SQLUtil#sameName} — including on the collation-based vendors 279 * whose {@code CanonKey} text is diagnostic only.</p> 280 */ 281 private final Map<TTable, Set<CanonKey>> tracedStarCloneKeys; 282 283 /** 284 * All column references collected during Pass 1 (Principle 1: Scope完全复用). 285 * Used in Pass 2+ to re-resolve names without rebuilding the scope tree. 286 */ 287 private final List<TObjectName> allColumnReferences; 288 289 /** 290 * ScopeBuilder for visitor-based scope construction. 291 * Replaces manual scope building with proper nested scope handling. 292 */ 293 private final ScopeBuilder scopeBuilder; 294 295 /** 296 * Result from ScopeBuilder containing the complete scope tree. 297 * This is populated in Pass 1 and reused in Pass 2+. 298 */ 299 private ScopeBuildResult scopeBuildResult; 300 301 /** 302 * Slice S5 — populated by {@link BindingDiagnosticPostPass} after the 303 * iterative resolver loop converges, when at least one binding flag is 304 * on. Stays at {@link BindingResult#empty()} when binding is disabled 305 * or {@code resolve()} has not yet been called (plan §5.6, §12). 306 */ 307 private BindingResult bindingResult = BindingResult.empty(); 308 309 /** Observation state for the most recent {@link #resolve()} call. */ 310 private RunOutcome runOutcome = RunOutcome.notAttempted(); 311 312 /** 313 * NamespaceEnhancer for explicit column collection and enhancement. 314 * Handles the explicit namespace enhancement phase between resolution passes. 315 * Columns are collected during resolution and added to namespaces explicitly. 316 */ 317 private NamespaceEnhancer namespaceEnhancer; 318 319 /** 320 * Create resolver with default configuration 321 */ 322 public TSQLResolver2(TContext context, TStatementList statements) { 323 this(context, statements, TSQLResolverConfig.createDefault()); 324 } 325 326 /** 327 * Create resolver with custom configuration 328 */ 329 public TSQLResolver2(TContext context, TStatementList statements, TSQLResolverConfig config) { 330 this.globalContext = context; 331 this.sqlStatements = statements; 332 this.config = config; 333 this.resolutionContext = new ResolutionContext(); 334 this.nameResolver = new NameResolver(config, resolutionContext); 335 this.passHistory = new ArrayList<>(); 336 this.statementScopeCache = new java.util.HashMap<>(); 337 this.columnToScopeMap = new java.util.HashMap<>(); 338 this.fromScopeIndexCache = new IdentityHashMap<>(); 339 this.teradataNamedAliasCache = new IdentityHashMap<>(); 340 this.linkedColumnIdentityCache = new IdentityHashMap<>(); 341 this.tracedStarClones = java.util.Collections.newSetFromMap(new IdentityHashMap<TObjectName, Boolean>()); 342 this.tracedStarCloneKeys = new IdentityHashMap<>(); 343 this.allColumnReferences = new ArrayList<>(); 344 345 // Initialize ScopeBuilder for visitor-based scope construction 346 this.scopeBuilder = new ScopeBuilder(context, config.getNameMatcher()); 347 // Pass guessColumnStrategy from config for namespace isolation (prevents test side effects) 348 if (config.hasCustomGuessColumnStrategy()) { 349 this.scopeBuilder.setGuessColumnStrategy(config.getGuessColumnStrategy()); 350 } 351 352 // Binding-trace capture (dynamic-SQL publication proof, R4 step 3). 353 // Registry exists only when opted in; the capture hooks in 354 // NameResolver/ScopeBuilder are a null check otherwise. 355 if (config.isCaptureBindingTrace()) { 356 this.bindingTraceRegistry = 357 new gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry(); 358 this.nameResolver.setBindingTraceRegistry(bindingTraceRegistry); 359 this.scopeBuilder.setBindingTraceRegistry(bindingTraceRegistry); 360 } else { 361 this.bindingTraceRegistry = null; 362 } 363 364 // If context is null, try to get TSQLEnv from statements 365 // This allows TSQLEnv to flow from parser.setSqlEnv() through statements 366 if (statements != null && statements.size() > 0) { 367 try { 368 TCustomSqlStatement firstStmt = statements.get(0); 369 if (firstStmt != null && firstStmt.getGlobalScope() != null && 370 firstStmt.getGlobalScope().getSqlEnv() != null) { 371 this.scopeBuilder.setSqlEnv(firstStmt.getGlobalScope().getSqlEnv()); 372 } 373 } catch (Exception e) { 374 // Silently ignore - SQLEnv is optional enhancement 375 } 376 } 377 378 // Initialize convergence detector for iterative resolution 379 this.convergenceDetector = new ConvergenceDetector( 380 config.getMaxIterations(), 381 config.getStablePassesForConvergence(), 382 config.getMinProgressRate() 383 ); 384 385 // Initialize namespace enhancer for explicit column collection 386 // Debug mode follows the global resolver log setting 387 this.namespaceEnhancer = new NamespaceEnhancer(TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE); 388 } 389 390 /** 391 * Set the TSQLEnv to use for table metadata lookup. 392 * This allows external callers to provide TSQLEnv if automatic detection fails. 393 * 394 * @param sqlEnv the SQL environment containing table metadata 395 */ 396 public void setSqlEnv(gudusoft.gsqlparser.sqlenv.TSQLEnv sqlEnv) { 397 if (scopeBuilder != null) { 398 scopeBuilder.setSqlEnv(sqlEnv); 399 } 400 } 401 402 /** 403 * Get the TSQLEnv used for table metadata lookup. 404 * 405 * @return the SQL environment, or null if not set 406 */ 407 public gudusoft.gsqlparser.sqlenv.TSQLEnv getSqlEnv() { 408 return scopeBuilder != null ? scopeBuilder.getSqlEnv() : null; 409 } 410 411 /** 412 * Get the set of virtual trigger tables (deleted/inserted in SQL Server triggers). 413 * These tables should be excluded from table output since their columns are 414 * resolved to the trigger's target table. 415 * 416 * @return Set of TTable objects that are virtual trigger tables 417 */ 418 public java.util.Set<gudusoft.gsqlparser.nodes.TTable> getVirtualTriggerTables() { 419 return scopeBuilder != null ? scopeBuilder.getVirtualTriggerTables() : java.util.Collections.emptySet(); 420 } 421 422 /** 423 * Get the SQL statements being resolved. 424 * 425 * @return the list of SQL statements 426 */ 427 public TStatementList getStatements() { 428 return sqlStatements; 429 } 430 431 // Performance timing fields (instance-level for single resolve() call) 432 private long timeScopeBuilder = 0; 433 private long timeNameResolution = 0; 434 private long timeEnhancement = 0; 435 private long timeLegacySync = 0; 436 private long timeOther = 0; 437 438 // Global accumulators for profiling across all resolve() calls 439 private static long globalTimeScopeBuilder = 0; 440 private static long globalTimeNameResolution = 0; 441 private static long globalTimeEnhancement = 0; 442 private static long globalTimeLegacySync = 0; 443 private static long globalTimeOther = 0; 444 private static int globalResolveCount = 0; 445 446 /** 447 * Reset global timing accumulators. 448 */ 449 public static void resetGlobalTimings() { 450 globalTimeScopeBuilder = 0; 451 globalTimeNameResolution = 0; 452 globalTimeEnhancement = 0; 453 globalTimeLegacySync = 0; 454 globalTimeOther = 0; 455 globalResolveCount = 0; 456 // Reset detailed legacy sync timings 457 globalTimeClearLinked = 0; 458 globalTimeFillAttributes = 0; 459 globalTimeSyncColumns = 0; 460 globalTimePopulateOrphans = 0; 461 globalTimeClearHints = 0; 462 } 463 464 /** 465 * Get global performance timing breakdown for profiling across all resolve() calls. 466 * @return formatted timing information 467 */ 468 public static String getGlobalPerformanceTimings() { 469 long total = globalTimeScopeBuilder + globalTimeNameResolution + globalTimeEnhancement + globalTimeLegacySync + globalTimeOther; 470 return String.format( 471 "TSQLResolver2 Global Timings (across %d resolve() calls):\n" + 472 " ScopeBuilder: %d ms (%.1f%%)\n" + 473 " NameResolution: %d ms (%.1f%%)\n" + 474 " Enhancement: %d ms (%.1f%%)\n" + 475 " LegacySync: %d ms (%.1f%%)\n" + 476 " Other: %d ms (%.1f%%)\n" + 477 " Total: %d ms", 478 globalResolveCount, 479 globalTimeScopeBuilder, total > 0 ? 100.0 * globalTimeScopeBuilder / total : 0, 480 globalTimeNameResolution, total > 0 ? 100.0 * globalTimeNameResolution / total : 0, 481 globalTimeEnhancement, total > 0 ? 100.0 * globalTimeEnhancement / total : 0, 482 globalTimeLegacySync, total > 0 ? 100.0 * globalTimeLegacySync / total : 0, 483 globalTimeOther, total > 0 ? 100.0 * globalTimeOther / total : 0, 484 total); 485 } 486 487 /** 488 * Get performance timing breakdown for profiling. 489 * @return formatted timing information 490 */ 491 public String getPerformanceTimings() { 492 long total = timeScopeBuilder + timeNameResolution + timeEnhancement + timeLegacySync + timeOther; 493 return String.format( 494 "TSQLResolver2 Timings:\n" + 495 " ScopeBuilder: %d ms (%.1f%%)\n" + 496 " NameResolution: %d ms (%.1f%%)\n" + 497 " Enhancement: %d ms (%.1f%%)\n" + 498 " LegacySync: %d ms (%.1f%%)\n" + 499 " Other: %d ms (%.1f%%)\n" + 500 " Total: %d ms", 501 timeScopeBuilder, total > 0 ? 100.0 * timeScopeBuilder / total : 0, 502 timeNameResolution, total > 0 ? 100.0 * timeNameResolution / total : 0, 503 timeEnhancement, total > 0 ? 100.0 * timeEnhancement / total : 0, 504 timeLegacySync, total > 0 ? 100.0 * timeLegacySync / total : 0, 505 timeOther, total > 0 ? 100.0 * timeOther / total : 0, 506 total); 507 } 508 509 /** 510 * Perform resolution on all SQL statements 511 */ 512 public boolean resolve() { 513 runOutcome = RunOutcome.attempted(); 514 515 // Traces describe exactly one run; a repeated resolve() must not mix 516 // stale entries from the previous run. 517 if (bindingTraceRegistry != null) { 518 bindingTraceRegistry.clear(); 519 } 520 // Reset timing counters 521 timeScopeBuilder = 0; 522 timeNameResolution = 0; 523 timeEnhancement = 0; 524 timeLegacySync = 0; 525 timeOther = 0; 526 527 // S5: clear any prior binding result so flag-off resolves stay 528 // pinned to BindingResult.empty(). 529 bindingResult = BindingResult.empty(); 530 531 // Setup logging 532 TLog.clearLogs(); 533 if (!TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 534 TLog.disableLog(); 535 } else { 536 TLog.enableAllLevelLog(); 537 } 538 539 try { 540 logInfo("Starting TSQLResolver2.resolve()"); 541 542 long startTime = System.currentTimeMillis(); 543 544 CircularCteDetector.Finding circularCte = 545 CircularCteDetector.find(sqlStatements, config.getVendor()); 546 if (circularCte != null) { 547 reportCircularCte(circularCte); 548 throw new IllegalArgumentException(circularCte.getMessage()); 549 } 550 551 // S3: enable per-TObjectName binding-trace recording when at least 552 // one binding flag is on. Allocation stays lazy — flag-off parses 553 // never instantiate the trace maps (plan §10.1 perf gate). 554 boolean bindingEnabled = config != null 555 && (config.isEmitBindingDiagnostics() 556 || config.isBindingIncludeSuccessfulReferences()); 557 if (bindingEnabled) { 558 resolutionContext.enableBindingTrace(); 559 } 560 561 // Delta 1: Collect metadata from DDL statements if no SQLEnv provided 562 if (getSqlEnv() == null) { 563 collectBatchMetadata(); 564 } 565 566 // Delta 4: Track database context from USE/SET statements 567 trackDatabaseContext(); 568 569 // Phase 1: Build global scope (once for all passes) 570 buildGlobalScope(); 571 572 timeOther += System.currentTimeMillis() - startTime; 573 574 // Phase 2: Perform iterative resolution 575 // (automatically completes after first pass if no second pass is needed) 576 boolean ok = performIterativeResolution(); 577 578 // S5: run the binding-diagnostic post-pass exactly once after the 579 // iterative loop converges, when at least one binding flag is on. 580 // Plan §5.6.1: this is a diagnostic interpretation pass — it 581 // reads the final resolver state but never re-binds names. 582 if (ok && bindingEnabled) { 583 BindingDiagnosticPostPass postPass = 584 new BindingDiagnosticPostPass(this, config); 585 bindingResult = postPass.run(); 586 } 587 588 runOutcome = RunOutcome.completed(ok); 589 return ok; 590 591 } catch (Exception e) { 592 runOutcome = RunOutcome.failed(e); 593 logError("Exception in TSQLResolver2.resolve(): " + e.getMessage()); 594 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 595 e.printStackTrace(); 596 } 597 return false; 598 } finally { 599 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 600 TBaseType.dumpLogs(false); 601 } 602 } 603 } 604 605 private void reportCircularCte(CircularCteDetector.Finding finding) { 606 TCTE cte = finding.getErrorNode(); 607 if (cte == null || cte.getSubquery() == null || cte.getTableName() == null 608 || cte.getTableName().getStartToken() == null) { 609 return; 610 } 611 612 TCustomSqlStatement statement = cte.getSubquery(); 613 statement.parseerrormessagehandle(new TSyntaxError( 614 cte.getTableName().getStartToken(), 615 finding.getMessage(), 616 EErrorType.sperror, 617 TBaseType.MSG_ERROR_SYNTAX_ERROR, 618 statement)); 619 } 620 621 /** 622 * Perform iterative resolution. 623 * Automatically converges after first pass if no additional passes are needed. 624 * 625 * Architecture: 626 * - Pass 1: Build scope tree + initial name resolution 627 * - Pass 2-N: Reuse scope tree, collect evidence, infer columns, re-resolve names 628 * 629 * This separation allows: 630 * 1. Scopes to accumulate inferred columns across iterations 631 * 2. Later scopes to reference earlier scopes' inferred columns 632 * 3. Forward references to be resolved in subsequent passes 633 */ 634 private boolean performIterativeResolution() { 635 logInfo("Performing iterative resolution (max iterations: " + config.getMaxIterations() + ")"); 636 637 int passNumber = 1; 638 ResolutionStatistics previousStats = null; 639 boolean continueIterating = true; 640 boolean scopesBuilt = false; 641 642 while (continueIterating) { 643 logInfo("=== Pass " + passNumber + " ==="); 644 645 // Create a resolution pass 646 ResolutionPass pass = new ResolutionPass(passNumber, previousStats); 647 648 if (passNumber == 1) { 649 // ========== PASS 1: Build scope tree + initial resolution ========== 650 logInfo("Pass 1: Building scope tree using ScopeBuilder and performing initial resolution"); 651 652 // Clear all state for fresh start 653 resolutionContext.clear(); 654 columnToScopeMap.clear(); 655 fromScopeIndexCache.clear(); 656 dmlIndexCache.clear(); 657 teradataNamedAliasCache.clear(); 658 allColumnReferences.clear(); 659 660 // Use ScopeBuilder to build complete scope tree (handles all nesting correctly) 661 long scopeBuilderStart = System.currentTimeMillis(); 662 scopeBuildResult = scopeBuilder.build(sqlStatements); 663 664 // Get global scope from builder 665 globalScope = scopeBuildResult.getGlobalScope(); 666 667 // Copy column references and scope mappings from ScopeBuildResult 668 columnToScopeMap.putAll(scopeBuildResult.getColumnToScopeMap()); 669 allColumnReferences.addAll(scopeBuildResult.getAllColumnReferences()); 670 timeScopeBuilder += System.currentTimeMillis() - scopeBuilderStart; 671 672 logInfo("ScopeBuilder complete: " + scopeBuildResult.getStatistics()); 673 logInfo("Built " + scopeBuildResult.getStatementScopeMap().size() + " SelectScopes"); 674 675 // Initialize NamespaceEnhancer with scope tree (caches star namespaces) 676 namespaceEnhancer.initialize(scopeBuildResult); 677 namespaceEnhancer.startPass(passNumber); 678 679 // Get SET clause target columns that should not be re-resolved 680 Set<TObjectName> setClauseTargetColumns = scopeBuilder.getSetClauseTargetColumns(); 681 682 // Get INSERT ALL target columns that should not be re-resolved 683 Set<TObjectName> insertAllTargetColumns = scopeBuilder.getInsertAllTargetColumns(); 684 685 // Get MERGE INSERT VALUES columns that need sourceTable restoration after resolution 686 Map<TObjectName, TTable> mergeInsertValuesColumns = scopeBuilder.getMergeInsertValuesColumns(); 687 688 // Perform initial name resolution for all collected columns 689 logInfo("Performing initial name resolution for " + allColumnReferences.size() + " column references"); 690 long nameResStart = System.currentTimeMillis(); 691 for (TObjectName objName : allColumnReferences) { 692 // Skip SET clause target columns - they already have sourceTable correctly set 693 // to the UPDATE target table and should NOT be resolved through star columns 694 if (setClauseTargetColumns.contains(objName)) { 695 continue; 696 } 697 698 // Skip INSERT ALL target columns - they already have sourceTable correctly set 699 // to the INSERT target table and should NOT be resolved against the subquery scope 700 if (insertAllTargetColumns.contains(objName)) { 701 continue; 702 } 703 704 // Skip ClickHouse expression-CTE aliases (WITH <expr> AS ident) - 705 // phase 1 resolved them to scalar symbols; they are not columns 706 // of any relation and must not enter namespace matching or the 707 // enhancement pipeline. 708 if (objName.getExpressionCteRef() != null && objName.getSourceTable() == null) { 709 continue; 710 } 711 712 IScope scope = columnToScopeMap.get(objName); 713 if (scope != null) { 714 nameResolver.resolve(objName, scope); 715 716 // Handle USING column priority for JOIN...USING syntax 717 handleUsingColumnResolution(objName); 718 719 // Handle Teradata NAMED alias resolution 720 handleTeradataNamedAliasResolution(objName); 721 handleQualifyClauseAliasResolution(objName); 722 handleSelectListAliasResolution(objName); 723 724 // Handle subquery aliased/calculated column resolution 725 // Ensures aliased columns don't incorrectly trace to base tables 726 handleSubqueryAliasedColumnResolution(objName); 727 728 // The correction handlers above may change or clear 729 // sourceTable AFTER the binding trace was captured; a 730 // trace whose table definition no longer matches is 731 // stale and must not survive as complete (fail-closed). 732 if (bindingTraceRegistry != null) { 733 bindingTraceRegistry.invalidateIfInconsistent(objName); 734 } 735 736 // Collect unresolved references for enhancement 737 collectForEnhancementIfNeeded(objName, scope); 738 } 739 } 740 741 // Restore sourceTable for MERGE INSERT VALUES columns after name resolution. 742 // Name resolution may have set an AMBIGUOUS resolution (e.g., column 'product' 743 // appears in both target and source tables through the ON clause). In MERGE 744 // semantics, WHEN NOT MATCHED VALUES columns always reference the USING (source) 745 // table. 746 // 747 // For AMBIGUOUS resolution: clear it so getSourceTable() returns the actual field 748 // value (the USING table). AMBIGUOUS means the column was found in both target and 749 // source namespaces, but semantically it must reference the source. 750 // 751 // For EXACT_MATCH resolution: keep it because it contains star column push-down 752 // tracing info (e.g., when USING is a subquery with SELECT *, the resolution 753 // traces the VALUES column to the physical table inside the subquery). 754 for (Map.Entry<TObjectName, TTable> entry : mergeInsertValuesColumns.entrySet()) { 755 TObjectName col = entry.getKey(); 756 TTable usingTable = entry.getValue(); 757 ResolutionResult res = col.getResolution(); 758 if (res != null && res.isAmbiguous()) { 759 col.setResolution(null); 760 } 761 col.setSourceTable(usingTable); 762 // This restore runs AFTER the per-reference consistency 763 // check; a scope trace observed before it is now stale. 764 if (bindingTraceRegistry != null) { 765 bindingTraceRegistry.invalidateIfInconsistent(col); 766 } 767 } 768 769 timeNameResolution += System.currentTimeMillis() - nameResStart; 770 771 // Explicit Enhancement Phase: Add collected columns to namespaces 772 long enhanceStart = System.currentTimeMillis(); 773 EnhancementResult enhanceResult = namespaceEnhancer.enhance(); 774 timeEnhancement += System.currentTimeMillis() - enhanceStart; 775 logInfo("Pass 1 enhancement: " + enhanceResult.getTotalAdded() + " columns added to namespaces"); 776 777 scopesBuilt = true; 778 logInfo("Pass 1 complete. Resolved " + allColumnReferences.size() + " column references."); 779 780 781 } else { 782 // ========== PASS 2+: Explicit Enhancement + Re-resolve ========== 783 logInfo("Pass " + passNumber + ": Explicit namespace enhancement and re-resolution"); 784 785 // ======== Phase A: Start New Pass ======== 786 namespaceEnhancer.startPass(passNumber); 787 788 // ======== Phase B: Clear Resolution Results (keep scopes!) ======== 789 logInfo("Phase B: Clearing resolution results (scopes preserved)"); 790 resolutionContext.clear(); 791 792 // ======== Phase C: Re-resolve with Enhanced Namespaces ======== 793 logInfo("Phase C: Re-resolving with enhanced namespaces"); 794 795 // Get SET clause target columns that should not be re-resolved 796 Set<TObjectName> setClauseTargetColumns = scopeBuilder.getSetClauseTargetColumns(); 797 798 // Get INSERT ALL target columns that should not be re-resolved 799 Set<TObjectName> insertAllTargetColumns = scopeBuilder.getInsertAllTargetColumns(); 800 801 // Get MERGE INSERT VALUES columns that need sourceTable restoration after resolution 802 Map<TObjectName, TTable> mergeInsertValuesColumns = scopeBuilder.getMergeInsertValuesColumns(); 803 804 // Re-resolve all column references using their original scopes 805 // Scopes are reused from Pass 1, but namespaces may have been enhanced 806 for (TObjectName objName : allColumnReferences) { 807 // Skip SET clause target columns - they already have sourceTable correctly set 808 // to the UPDATE target table and should NOT be resolved through star columns 809 if (setClauseTargetColumns.contains(objName)) { 810 continue; 811 } 812 813 // Skip INSERT ALL target columns - they already have sourceTable correctly set 814 // to the INSERT target table and should NOT be resolved against the subquery scope 815 if (insertAllTargetColumns.contains(objName)) { 816 continue; 817 } 818 819 // Skip ClickHouse expression-CTE aliases (WITH <expr> AS ident) - 820 // phase 1 resolved them to scalar symbols; they are not columns 821 // of any relation and must not enter namespace matching or the 822 // enhancement pipeline. 823 if (objName.getExpressionCteRef() != null && objName.getSourceTable() == null) { 824 continue; 825 } 826 827 IScope scope = columnToScopeMap.get(objName); 828 if (scope != null) { 829 nameResolver.resolve(objName, scope); 830 831 // Handle USING column priority for JOIN...USING syntax 832 handleUsingColumnResolution(objName); 833 834 // Handle Teradata NAMED alias resolution 835 handleTeradataNamedAliasResolution(objName); 836 handleQualifyClauseAliasResolution(objName); 837 handleSelectListAliasResolution(objName); 838 839 // Handle subquery aliased/calculated column resolution 840 // Ensures aliased columns don't incorrectly trace to base tables 841 handleSubqueryAliasedColumnResolution(objName); 842 843 // The correction handlers above may change or clear 844 // sourceTable AFTER the binding trace was captured; a 845 // trace whose table definition no longer matches is 846 // stale and must not survive as complete (fail-closed). 847 if (bindingTraceRegistry != null) { 848 bindingTraceRegistry.invalidateIfInconsistent(objName); 849 } 850 851 // Collect for next enhancement pass if still targets star namespace 852 collectForEnhancementIfNeeded(objName, scope); 853 } 854 } 855 856 // Restore sourceTable for MERGE INSERT VALUES columns after re-resolution 857 for (Map.Entry<TObjectName, TTable> entry : mergeInsertValuesColumns.entrySet()) { 858 ResolutionResult res = entry.getKey().getResolution(); 859 if (res != null && res.isAmbiguous()) { 860 entry.getKey().setResolution(null); 861 } 862 entry.getKey().setSourceTable(entry.getValue()); 863 // Same staleness rule as the pass-1 restore above. 864 if (bindingTraceRegistry != null) { 865 bindingTraceRegistry.invalidateIfInconsistent(entry.getKey()); 866 } 867 } 868 869 // ======== Phase D: Explicit Namespace Enhancement ======== 870 logInfo("Phase D: Explicit namespace enhancement"); 871 EnhancementResult enhanceResult = namespaceEnhancer.enhance(); 872 logInfo("Pass " + passNumber + " enhancement: " + 873 enhanceResult.getTotalAdded() + " columns added, " + 874 enhanceResult.getTotalSkipped() + " skipped (existing)"); 875 876 // Legacy support: also run old evidence collection (if needed) 877 if (config.isEvidenceCollectionEnabled()) { 878 runLegacyEvidenceCollection(); 879 } 880 } 881 882 // Get statistics after this pass 883 ResolutionStatistics currentStats = getStatistics(); 884 pass.complete(currentStats); 885 886 // Record this pass 887 convergenceDetector.recordPass(pass); 888 passHistory.add(pass); 889 890 logInfo(pass.getSummary()); 891 892 // Check convergence 893 ConvergenceDetector.ConvergenceResult convergence = convergenceDetector.checkConvergence(); 894 if (convergence.hasConverged()) { 895 logInfo("Convergence detected: " + convergence.getReason()); 896 pass.setStopReason(convergence.getReason()); 897 continueIterating = false; 898 } else { 899 // Prepare for next pass 900 previousStats = currentStats; 901 passNumber++; 902 } 903 } 904 905 // Create cloned columns for star column tracing 906 // This is a CORE part of TSQLResolver2 - when a column traces through a CTE/subquery 907 // with SELECT * to a physical table, we create a cloned TObjectName with sourceTable 908 // pointing to the traced physical table. This ensures complete lineage tracking. 909 createTracedColumnClones(); 910 911 // Sync to legacy structures if enabled 912 if (config.isLegacyCompatibilityEnabled()) { 913 long syncStart = System.currentTimeMillis(); 914 syncToLegacyStructures(); 915 timeLegacySync += System.currentTimeMillis() - syncStart; 916 } 917 918 // Print final statistics 919 logInfo("Iterative resolution complete after " + passHistory.size() + " passes"); 920 ResolutionStatistics finalStats = getStatistics(); 921 logInfo("Final statistics: " + finalStats); 922 923 // Print namespace enhancement summary if in debug mode 924 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 925 logInfo("=== Namespace Enhancement Summary ==="); 926 logInfo("Total columns added: " + namespaceEnhancer.getTotalColumnsAdded()); 927 } 928 929 // Print performance timing breakdown 930 logInfo(getPerformanceTimings()); 931 932 // Accumulate to global timings for profiling 933 globalTimeScopeBuilder += timeScopeBuilder; 934 globalTimeNameResolution += timeNameResolution; 935 globalTimeEnhancement += timeEnhancement; 936 globalTimeLegacySync += timeLegacySync; 937 globalTimeOther += timeOther; 938 globalResolveCount++; 939 940 return true; 941 } 942 943 /** 944 * Run legacy evidence collection (deprecated). 945 * Kept for backward compatibility. 946 */ 947 @SuppressWarnings("deprecation") 948 private void runLegacyEvidenceCollection() { 949 logInfo("Running legacy evidence collection (deprecated)"); 950 951 gudusoft.gsqlparser.resolver2.inference.EvidenceCollector evidenceCollector = 952 new gudusoft.gsqlparser.resolver2.inference.EvidenceCollector(); 953 954 int evidenceCount = 0; 955 for (int i = 0; i < sqlStatements.size(); i++) { 956 Object stmt = sqlStatements.get(i); 957 if (stmt instanceof TSelectSqlStatement) { 958 List<gudusoft.gsqlparser.resolver2.inference.InferenceEvidence> stmtEvidence = 959 evidenceCollector.collectFromSelect((TSelectSqlStatement) stmt); 960 evidenceCount += stmtEvidence.size(); 961 } 962 } 963 964 logInfo("Legacy evidence collection: " + evidenceCount + " items"); 965 } 966 967 /** 968 * Get the namespace enhancer for external access to enhancement history. 969 * 970 * @return the namespace enhancer 971 */ 972 public NamespaceEnhancer getNamespaceEnhancer() { 973 return namespaceEnhancer; 974 } 975 976 /** 977 * Get a detailed enhancement report. 978 * 979 * @return detailed report string 980 */ 981 public String getEnhancementReport() { 982 return namespaceEnhancer.generateReport(); 983 } 984 985 /** 986 * Re-process a statement for name resolution only (without rebuilding scopes). 987 * This is used in Pass 2+ to re-resolve names using enhanced scopes. 988 * 989 * CRITICAL (Principle 1: Scope完全复用): 990 * - Scope tree is built ONCE in Pass 1 and completely reused in Pass 2+ 991 * - This method MUST NOT call processStatement() which rebuilds scopes 992 * - Instead, it iterates through allColumnReferences and re-resolves each 993 * column using its original scope from columnToScopeMap 994 * 995 * This allows: 996 * - Namespaces to be enhanced across iterations (Principle 2) 997 * - Star columns to benefit from reverse inference (Principle 3) 998 * - All previous inference results to be preserved 999 */ 1000 private void reprocessStatementNamesOnly(Object statement) { 1001 logDebug("Re-resolving column references without rebuilding scopes"); 1002 1003 // Re-resolve all column references using their original scopes 1004 // The scopes are reused from Pass 1, but their namespaces may have been enhanced 1005 for (TObjectName objName : allColumnReferences) { 1006 IScope scope = columnToScopeMap.get(objName); 1007 if (scope != null) { 1008 // Re-resolve this column using the (potentially enhanced) scope 1009 nameResolver.resolve(objName, scope); 1010 1011 // Handle USING column priority for JOIN...USING syntax 1012 handleUsingColumnResolution(objName); 1013 1014 // Handle Teradata NAMED alias resolution 1015 handleTeradataNamedAliasResolution(objName); 1016 handleQualifyClauseAliasResolution(objName); 1017 handleSelectListAliasResolution(objName); 1018 1019 // Collect for next enhancement pass if still unresolved 1020 collectForEnhancementIfNeeded(objName, scope); 1021 } else { 1022 logError("No scope found for column: " + objName); 1023 } 1024 } 1025 } 1026 1027 /** 1028 * Handle special resolution for USING columns in JOIN...USING syntax. 1029 * In "a JOIN table2 USING (id)", the USING column exists in BOTH tables. 1030 * - The synthetic column (clone) resolves to the right-side table (table2) 1031 * - The original USING column resolves to the left-side table (a) 1032 * 1033 * @param objName The column reference 1034 */ 1035 private void handleUsingColumnResolution(TObjectName objName) { 1036 if (objName == null || scopeBuildResult == null) return; 1037 1038 // Check if this is a synthetic USING column (should resolve to right table) 1039 TTable rightTable = scopeBuildResult.getUsingColumnRightTable(objName); 1040 if (rightTable != null) { 1041 // This is the synthetic USING column - set its sourceTable to the right-side table 1042 objName.setSourceTable(rightTable); 1043 1044 // Create a proper resolution with the right-side table 1045 gudusoft.gsqlparser.resolver2.model.ColumnSource source = 1046 new gudusoft.gsqlparser.resolver2.model.ColumnSource( 1047 null, // no namespace for USING columns 1048 objName.getColumnNameOnly(), 1049 null, // no definition node 1050 1.0, // high confidence 1051 "using_column_right", 1052 rightTable // override table - the right-side table of the JOIN 1053 ); 1054 gudusoft.gsqlparser.resolver2.model.ResolutionResult result = 1055 gudusoft.gsqlparser.resolver2.model.ResolutionResult.exactMatch(source); 1056 1057 // Update the TObjectName's resolution so formatter uses correct finalTable 1058 objName.setResolution(result); 1059 1060 // Also register in ResolutionContext so getReferencesTo(table) can find it 1061 resolutionContext.registerResolution(objName, result); 1062 1063 logDebug("USING column " + objName.getColumnNameOnly() + 1064 " -> right-side table " + rightTable.getName()); 1065 return; 1066 } 1067 1068 // Check if this is the original USING column (should resolve to left table) 1069 TTable leftTable = scopeBuildResult.getUsingColumnLeftTable(objName); 1070 if (leftTable != null) { 1071 // This is the original USING column - set its sourceTable to the left-side table 1072 objName.setSourceTable(leftTable); 1073 1074 // Create a proper resolution with the left-side table 1075 gudusoft.gsqlparser.resolver2.model.ColumnSource source = 1076 new gudusoft.gsqlparser.resolver2.model.ColumnSource( 1077 null, // no namespace for USING columns 1078 objName.getColumnNameOnly(), 1079 null, // no definition node 1080 1.0, // high confidence 1081 "using_column_left", 1082 leftTable // override table - the left-side table of the JOIN 1083 ); 1084 gudusoft.gsqlparser.resolver2.model.ResolutionResult result = 1085 gudusoft.gsqlparser.resolver2.model.ResolutionResult.exactMatch(source); 1086 1087 // Update the TObjectName's resolution so formatter uses correct finalTable 1088 objName.setResolution(result); 1089 1090 // Also register in ResolutionContext so getReferencesTo(table) can find it 1091 resolutionContext.registerResolution(objName, result); 1092 1093 logDebug("USING column " + objName.getColumnNameOnly() + 1094 " -> left-side table " + leftTable.getName()); 1095 } 1096 } 1097 1098 /** 1099 * Handle Teradata NAMED alias resolution. 1100 * 1101 * <p>In Teradata, NAMED aliases defined in the SELECT list (using the {@code (NAMED alias)} syntax) 1102 * can be referenced in the WHERE and QUALIFY clauses of the same SELECT statement. This is different 1103 * from standard SQL where column aliases are only visible in ORDER BY.</p> 1104 * 1105 * <p>This method checks if a resolved column matches a NAMED alias from the enclosing SELECT list. 1106 * If it does, the resolution is updated to indicate this is a calculated column (alias), not a 1107 * physical column from the table.</p> 1108 * 1109 * <p>Example:</p> 1110 * <pre> 1111 * SELECT USI_ID, SUBS_ID, 1112 * (CAST(:param AS TIMESTAMP(0)))(NAMED REPORT_DTTM) 1113 * FROM PRD2_ODW.SUBS_USI_HISTORY 1114 * WHERE stime <= REPORT_DTTM AND etime > REPORT_DTTM 1115 * </pre> 1116 * <p>Here, REPORT_DTTM references in WHERE should NOT be linked to PRD2_ODW.SUBS_USI_HISTORY 1117 * because REPORT_DTTM is a NAMED alias, not a physical column.</p> 1118 * 1119 * @param objName The column reference to check 1120 */ 1121 private void handleTeradataNamedAliasResolution(TObjectName objName) { 1122 if (objName == null || sqlStatements == null || sqlStatements.size() == 0) return; 1123 1124 // Only applies to Teradata 1125 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 1126 if (dbVendor != EDbVendor.dbvteradata) return; 1127 1128 String columnName = objName.getColumnNameOnly(); 1129 if (columnName == null || columnName.isEmpty()) return; 1130 1131 // Only apply to UNQUALIFIED column references (no table prefix) 1132 // If a column has a table qualifier like "CP.CALC_PLATFORM_ID", it's clearly 1133 // referencing a specific table's column, not a NAMED alias 1134 if (objName.getTableToken() != null) return; 1135 1136 // Get the scope for this column reference 1137 IScope scope = columnToScopeMap.get(objName); 1138 if (scope == null) return; 1139 1140 // Find the enclosing SELECT statement from the scope 1141 TSelectSqlStatement enclosingSelect = findEnclosingSelectFromScope(scope); 1142 if (enclosingSelect == null) return; 1143 1144 // Optimization C: Use cached index for O(1) lookup instead of O(N) iteration 1145 Map<String, TResultColumn> aliasIndex = getTeradataNamedAliasIndex(enclosingSelect); 1146 if (aliasIndex == null || aliasIndex.isEmpty()) return; 1147 1148 // S2: look up via the vendor-aware key. Teradata is case-insensitive 1149 // for unquoted column aliases but case-sensitive for quoted ones, so 1150 // raw {@code toLowerCase()} drops quoted-alias correctness. 1151 // 1152 // CRITICAL — vendor source-of-truth invariant (codex round-2 review): 1153 // both this lookup and the storage path in 1154 // {@link #getTeradataNamedAliasIndex} read from the SAME source 1155 // {@code sqlStatements.get(0).dbvendor}: this method's local 1156 // {@code dbVendor} variable is initialized from it at line 916 1157 // above, and the storage path passes the same value. They cannot 1158 // diverge. 1159 String aliasKey = gudusoft.gsqlparser.sqlenv.IdentifierService.normalizeStatic( 1160 dbVendor, 1161 gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn, 1162 columnName); 1163 TResultColumn resultCol = aliasIndex.get(aliasKey); 1164 if (resultCol == null) { 1165 // Fallback: quoted-vs-unquoted mix in the same SELECT (rare). 1166 // Walk the cached map with a vendor-aware compare so the 1167 // normalized-key fast probe miss does not become a false negative. 1168 for (Map.Entry<String, TResultColumn> entry : aliasIndex.entrySet()) { 1169 if (gudusoft.gsqlparser.sqlenv.IdentifierService.areEqualStatic( 1170 dbVendor, 1171 gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn, 1172 entry.getKey(), 1173 columnName)) { 1174 resultCol = entry.getValue(); 1175 break; 1176 } 1177 } 1178 } 1179 if (resultCol == null) return; 1180 1181 // Skip if objName is part of this result column's expression 1182 // This handles cases like "CAST(ID AS DECIMAL) AS ID" where the ID inside 1183 // CAST is the source column, not a reference to the ID alias 1184 if (isColumnWithinResultColumn(objName, resultCol)) { 1185 return; 1186 } 1187 1188 // Found a matching NAMED alias 1189 // Clear the source table since this is an alias, not a physical column 1190 objName.setSourceTable(null); 1191 1192 // Create a new ColumnSource with the TResultColumn as the definition node 1193 // This will make isCalculatedColumn() return true 1194 ColumnSource source = new ColumnSource( 1195 null, // namespace - not from a table 1196 columnName, 1197 resultCol, // definition node - the TResultColumn with the alias 1198 1.0, // high confidence 1199 "teradata_named_alias" 1200 ); 1201 ResolutionResult result = ResolutionResult.exactMatch(source); 1202 objName.setResolution(result); 1203 resolutionContext.registerResolution(objName, result); 1204 1205 logDebug("Teradata NAMED alias: " + columnName + " -> alias from SELECT list"); 1206 } 1207 1208 /** 1209 * Handle QUALIFY clause alias resolution for Snowflake, BigQuery, and Databricks. 1210 * 1211 * <p>In Snowflake, BigQuery, and Databricks, column aliases defined in the SELECT list 1212 * can be referenced in the QUALIFY clause. This is different from standard SQL where 1213 * column aliases are only visible in ORDER BY.</p> 1214 * 1215 * <p>This method checks if a column reference in the QUALIFY clause matches an alias 1216 * from the enclosing SELECT list. If it does, the resolution is updated to indicate 1217 * this is a calculated column (alias), not a physical column from the table.</p> 1218 * 1219 * <p>Example:</p> 1220 * <pre> 1221 * SELECT RoomNumber, RoomType, BlockFloor, 1222 * ROW_NUMBER() OVER (PARTITION BY RoomType ORDER BY BlockFloor) AS row_num 1223 * FROM Hospital.Room 1224 * QUALIFY row_num = 1 1225 * </pre> 1226 * <p>Here, row_num in QUALIFY should NOT be linked to Hospital.Room because 1227 * row_num is an alias for the window function, not a physical column.</p> 1228 * 1229 * @param objName The column reference to check 1230 */ 1231 private void handleQualifyClauseAliasResolution(TObjectName objName) { 1232 if (objName == null || sqlStatements == null || sqlStatements.size() == 0) return; 1233 1234 // Only applies to databases that support QUALIFY with alias visibility 1235 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 1236 if (dbVendor != EDbVendor.dbvsnowflake && 1237 dbVendor != EDbVendor.dbvbigquery && 1238 dbVendor != EDbVendor.dbvdatabricks) return; 1239 1240 String columnName = objName.getColumnNameOnly(); 1241 if (columnName == null || columnName.isEmpty()) return; 1242 1243 // Only apply to UNQUALIFIED column references (no table prefix) 1244 if (objName.getTableToken() != null) return; 1245 1246 // Check if this column is within a QUALIFY clause 1247 if (!isInQualifyClause(objName)) return; 1248 1249 // Get the scope for this column reference 1250 IScope scope = columnToScopeMap.get(objName); 1251 if (scope == null) return; 1252 1253 // Find the enclosing SELECT statement from the scope 1254 TSelectSqlStatement enclosingSelect = findEnclosingSelectFromScope(scope); 1255 if (enclosingSelect == null) return; 1256 1257 // Look for a matching alias in the SELECT list 1258 TResultColumnList resultColumns = enclosingSelect.getResultColumnList(); 1259 if (resultColumns == null || resultColumns.size() == 0) return; 1260 1261 TResultColumn matchingResultCol = null; 1262 for (int i = 0; i < resultColumns.size(); i++) { 1263 TResultColumn resultCol = resultColumns.getResultColumn(i); 1264 if (resultCol == null) continue; 1265 1266 // Check if this result column has an alias matching the column name 1267 if (resultCol.getAliasClause() != null && 1268 resultCol.getAliasClause().getAliasName() != null) { 1269 String aliasName = resultCol.getAliasClause().getAliasName().toString(); 1270 if (aliasName != null && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, aliasName, columnName)) { 1271 matchingResultCol = resultCol; 1272 break; 1273 } 1274 } 1275 } 1276 1277 if (matchingResultCol == null) return; 1278 1279 // Found a matching alias - clear the source table since this is an alias, not a physical column 1280 objName.setSourceTable(null); 1281 1282 // Create a new ColumnSource with the TResultColumn as the definition node 1283 // This will make isCalculatedColumn() return true 1284 ColumnSource source = new ColumnSource( 1285 null, // namespace - not from a table 1286 columnName, 1287 matchingResultCol, // definition node - the TResultColumn with the alias 1288 1.0, // high confidence 1289 "qualify_clause_alias" 1290 ); 1291 ResolutionResult result = ResolutionResult.exactMatch(source); 1292 objName.setResolution(result); 1293 resolutionContext.registerResolution(objName, result); 1294 1295 logDebug("QUALIFY clause alias: " + columnName + " -> alias from SELECT list"); 1296 } 1297 1298 /** 1299 * Handle a reference to a SELECT-list column alias from elsewhere in the same 1300 * SELECT block — the general "lateral column alias" case, of which the Teradata 1301 * NAMED and QUALIFY handlers above are two special cases. 1302 * 1303 * <p>Snowflake, Teradata, Vertica and other dialects whose 1304 * {@link TSQLEnv#isAliasReferenceForbidden} entry is {@code false} let a 1305 * SELECT-list alias be referenced in the same block's WHERE / HAVING:</p> 1306 * <pre> 1307 * WITH base AS (SELECT id, val FROM t1), 1308 * final AS (SELECT id, TO_CHAR(val,'mm/dd/yyyy')::TIMESTAMP_NTZ AS derived_col 1309 * FROM base 1310 * WHERE derived_col > CURRENT_DATE) 1311 * SELECT * FROM final 1312 * </pre> 1313 * 1314 * <p>{@code derived_col} in that WHERE is the alias, not a column of {@code base} 1315 * — {@code base} projects only {@code id} and {@code val}. Without this handler 1316 * resolver2 fell through to the namespace's non-authoritative MAYBE path and 1317 * stamped {@code sourceTable = base}, i.e. a confident link to a table that 1318 * provably has no such column, while {@code sourceColumn} already pointed at the 1319 * alias. Nothing anywhere flagged the contradiction (MantisBT 4659).</p> 1320 * 1321 * <p><b>What this deliberately does NOT do:</b> it never overrides a link the 1322 * namespace can PROVE. When the FROM-clause namespace authoritatively exposes a 1323 * column of that name — real catalog metadata, an explicit CTE column list, a 1324 * subquery projection — the alias and the column collide and the existing 1325 * table link is kept. Only a MAYBE/inferred link, which was never proven in the 1326 * first place, is replaced. See {@link BindingMetadataAuthority#lookup}.</p> 1327 * 1328 * <p>The alias-binding rule itself is NOT reimplemented here: it is 1329 * {@link TResultColumnList#findLateralAliasDefinition}, shared with Phase 1 1330 * {@code TCustomSqlStatement.linkColumnToTable()}, so the publish path and the 1331 * lookup path cannot drift apart.</p> 1332 * 1333 * @param objName The column reference to check 1334 */ 1335 private void handleSelectListAliasResolution(TObjectName objName) { 1336 if (objName == null || sqlStatements == null || sqlStatements.size() == 0) return; 1337 1338 // Only apply to UNQUALIFIED column references (no table prefix): a qualified 1339 // "t.derived_col" names that table's column, never a SELECT-list alias. 1340 if (objName.getTableToken() != null) return; 1341 1342 String columnName = objName.getColumnNameOnly(); 1343 if (columnName == null || columnName.isEmpty()) return; 1344 1345 // A more specific handler (Teradata NAMED alias, QUALIFY) may already have bound 1346 // this reference to its alias. Leave it alone rather than restamping it with a 1347 // weaker evidence string. 1348 ResolutionResult existing = objName.getResolution(); 1349 if (existing != null && existing.getColumnSource() != null 1350 && existing.getColumnSource().getSourceNamespace() == null 1351 && existing.getColumnSource().isColumnAlias()) { 1352 return; 1353 } 1354 1355 IScope scope = columnToScopeMap.get(objName); 1356 if (scope == null) return; 1357 1358 TSelectSqlStatement enclosingSelect = findEnclosingSelectFromScope(scope); 1359 if (enclosingSelect == null) return; 1360 1361 TResultColumnList resultColumns = enclosingSelect.getResultColumnList(); 1362 if (resultColumns == null || resultColumns.size() == 0) return; 1363 1364 // Read the vendor from the enclosing statement rather than sqlStatements.get(0): 1365 // a statement list can mix vendors only in theory, but the enclosing SELECT is 1366 // always the authority for its own alias visibility rules. 1367 EDbVendor dbVendor = enclosingSelect.dbvendor; 1368 if (dbVendor == null) dbVendor = sqlStatements.get(0).dbvendor; 1369 1370 // Only where the alias is actually visible, per VENDOR and per CLAUSE. 1371 // Textual position alone is NOT enough: a JOIN ... ON predicate is written 1372 // after the SELECT list but belongs to the FROM clause, which is evaluated 1373 // before any alias exists, so a name there is a table column even when it 1374 // happens to match an alias. Getting this wrong rebinds real join columns 1375 // (caught by dataflowTest.testICYIYY / snowflake/1027.sql). 1376 if (!isClauseExposingSelectListAlias(dbVendor, objName.getLocation())) return; 1377 1378 TResultColumn matchingResultCol = 1379 resultColumns.findLateralAliasDefinition(dbVendor, objName); 1380 if (matchingResultCol == null) return; 1381 1382 // Defensive: never re-point a column that lives inside the alias's own 1383 // defining expression. findLateralAliasDefinition()'s ordering rule already 1384 // excludes it; this keeps the guarantee local and explicit. 1385 if (isColumnWithinResultColumn(objName, matchingResultCol)) return; 1386 1387 // Do not discard a PROVABLE column link. Only an unproven (MAYBE/inferred) 1388 // match may be replaced by the alias binding. 1389 if (isCurrentResolutionAuthoritative(objName, columnName)) return; 1390 1391 // Nor discard a link the SQL itself evidences. In 1392 // SELECT CAST(id AS INT) AS id FROM t1 WHERE id > 1 1393 // the inner `id` cannot be referring to the alias being defined, so it can 1394 // only be a real column of t1 — which means t1 HAS an `id`, and Snowflake's 1395 // collision rule gives that real column precedence over the alias. No 1396 // catalog is needed to know this; the query says it. 1397 if (aliasDefinitionReferencesSameColumn(matchingResultCol, columnName, dbVendor, 1398 objName)) return; 1399 1400 // Clear the source table: an alias is not a physical column of any table. 1401 objName.setSourceTable(null); 1402 // ...and the candidates that came with it, or a consumer reading 1403 // getCandidateTables() would still publish the tables we just ruled out. 1404 objName.clearCandidateTables(); 1405 1406 ColumnSource source = new ColumnSource( 1407 null, // namespace - not from a table 1408 columnName, 1409 matchingResultCol, // definition node - the TResultColumn with the alias 1410 1.0, // high confidence 1411 "select_list_alias" 1412 ); 1413 ResolutionResult result = ResolutionResult.exactMatch(source); 1414 objName.setResolution(result); 1415 // replace, not register: this CORRECTS an earlier resolution for the same 1416 // reference, and registerResolution() would leave the superseded table 1417 // binding in the reverse indexes and double-count the reference. 1418 resolutionContext.replaceResolution(objName, result); 1419 1420 logDebug("SELECT list alias: " + columnName + " -> alias from SELECT list"); 1421 } 1422 1423 /** 1424 * Is a SELECT-list alias of the same block visible in {@code clause}, for 1425 * {@code vendor}? 1426 * 1427 * <p>Deliberately a per-vendor AND per-clause decision, and deliberately NOT 1428 * derived from {@link TSQLEnv#isAliasReferenceForbidden}. That map is a single 1429 * whole-dialect flag answering "does this dialect ever allow an alias reference 1430 * outside ORDER BY", which is a coarser question: Firebird and SAP HANA are 1431 * {@code false} there, yet neither documents a SELECT-list alias as visible in 1432 * WHERE. Treating that flag as a per-clause permission would rebind real table 1433 * columns in those dialects, so per CLAUDE.md's "never apply one vendor's rule to 1434 * all" this allow-lists only dialects whose documentation states the alias is 1435 * referenceable:</p> 1436 * <ul> 1437 * <li><b>Snowflake</b> — aliases are referenceable in WHERE / GROUP BY / 1438 * HAVING / QUALIFY (a real column of the same name still wins, which the 1439 * authority check in {@link #isCurrentResolutionAuthoritative} enforces);</li> 1440 * <li><b>Vertica</b> — an alias "can be referenced elsewhere in the SELECT 1441 * statement, for example in the query predicate or ORDER BY clause";</li> 1442 * <li><b>Teradata</b> — already relied upon by 1443 * {@link #handleTeradataNamedAliasResolution}, whose whole purpose is a 1444 * WHERE-clause alias reference.</li> 1445 * </ul> 1446 * 1447 * <p>Every other dialect returns false and keeps its previous behavior. Adding 1448 * one means citing its documentation and pinning it with a test in BOTH 1449 * directions, as {@code testMantis4659} does.</p> 1450 * 1451 * <p>{@code joinCondition}/{@code join} are excluded for ALL vendors: they are 1452 * part of the FROM clause, evaluated before any alias exists.</p> 1453 */ 1454 private static boolean isClauseExposingSelectListAlias(EDbVendor vendor, ESqlClause clause) { 1455 if (vendor == null || clause == null) return false; 1456 switch (vendor) { 1457 case dbvsnowflake: 1458 case dbvvertica: 1459 case dbvteradata: 1460 break; 1461 default: 1462 return false; 1463 } 1464 switch (clause) { 1465 case where: 1466 case having: 1467 case groupby: 1468 case orderby: 1469 case qualify: 1470 return true; 1471 default: 1472 return false; 1473 } 1474 } 1475 1476 /** 1477 * Does the namespace this column currently resolves to actually PROVE that it 1478 * exposes a column of this name? 1479 * 1480 * <p>Used by {@link #handleSelectListAliasResolution} to decide whether an 1481 * existing table link is worth keeping. Returns false whenever the namespace 1482 * cannot decide — a bare table with no catalog metadata, or a CTE answering 1483 * MAYBE through its base-table fallback — because such a link was inferred, 1484 * never proven.</p> 1485 * 1486 * <p>An {@code AMBIGUOUS} result is checked candidate by candidate. Its 1487 * {@code getColumnSource()} is null, so looking only there would read 1488 * "not authoritative" and let the alias overwrite a genuine ambiguity between 1489 * two PROVEN real columns.</p> 1490 */ 1491 private boolean isCurrentResolutionAuthoritative(TObjectName objName, String columnName) { 1492 ResolutionResult current = objName.getResolution(); 1493 if (current == null) return false; 1494 1495 if (current.getStatus() == ResolutionStatus.AMBIGUOUS) { 1496 AmbiguousColumnSource ambiguous = current.getAmbiguousSource(); 1497 if (ambiguous == null || ambiguous.getCandidates() == null) return false; 1498 for (ColumnSource candidate : ambiguous.getCandidates()) { 1499 if (isProvenColumnSource(candidate, columnName)) return true; 1500 } 1501 return false; 1502 } 1503 1504 return isProvenColumnSource(current.getColumnSource(), columnName); 1505 } 1506 1507 /** 1508 * Does the alias's own defining expression reference a real column of the same 1509 * name? 1510 * 1511 * <p>{@code SELECT CAST(id AS INT) AS id FROM t1 WHERE id > 1} — the {@code id} 1512 * inside the CAST is positioned before the alias token, so it cannot be a 1513 * reference to the alias being defined; the resolver has bound it to {@code t1}. 1514 * That is proof, taken from the query text alone, that {@code t1} really has an 1515 * {@code id} column, so the WHERE reference must stay on the table under the 1516 * "real column outranks a same-named alias" rule — no catalog required.</p> 1517 * 1518 * <p>Containment is decided by token span, the same way 1519 * {@link #isColumnWithinResultColumn} does it, so no expression tree is walked 1520 * recursively.</p> 1521 * 1522 * <p>The inner reference must be able to read one of the SAME tables the WHERE 1523 * reference could be reading. Span containment alone is not enough: 1524 * in {@code SELECT (SELECT max(id) FROM t2) AS id FROM t1 WHERE id > 1} the inner 1525 * {@code id} sits inside the alias expression but belongs to {@code t2}, and says 1526 * nothing about whether {@code t1} has an {@code id}. Requiring the same table 1527 * keeps a nested subquery, CASE arm or OVER clause over a DIFFERENT table from 1528 * being read as evidence about this one.</p> 1529 */ 1530 private boolean aliasDefinitionReferencesSameColumn(TResultColumn resultCol, 1531 String columnName, 1532 EDbVendor vendor, 1533 TObjectName reference) { 1534 if (resultCol == null || columnName == null || reference == null) return false; 1535 TExpression expr = resultCol.getExpr(); 1536 if (expr == null || expr.getStartToken() == null || expr.getEndToken() == null) return false; 1537 1538 List<TTable> referenceTables = possibleSourceTables(reference); 1539 if (referenceTables.isEmpty()) return false; 1540 1541 long exprStart = expr.getStartToken().posinlist; 1542 long exprEnd = expr.getEndToken().posinlist; 1543 1544 for (TObjectName candidate : columnToScopeMap.keySet()) { 1545 if (candidate == null || candidate.getStartToken() == null) continue; 1546 long pos = candidate.getStartToken().posinlist; 1547 if (pos < exprStart || pos > exprEnd) continue; 1548 if (!SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn, 1549 candidate.getColumnNameOnly(), columnName)) { 1550 continue; 1551 } 1552 if (sharesAnyTable(referenceTables, possibleSourceTables(candidate))) return true; 1553 } 1554 return false; 1555 } 1556 1557 /** 1558 * The tables a reference could be reading, as an identity list: the resolved one 1559 * if it has it, otherwise its candidates. 1560 * 1561 * <p>An unqualified name over several metadata-free tables resolves AMBIGUOUS with 1562 * {@code sourceTable == null} and the tables in {@code candidateTables}. Reading 1563 * only {@code sourceTable} would treat that as "no information" and let the alias 1564 * overwrite a genuine ambiguity between real columns.</p> 1565 */ 1566 private static List<TTable> possibleSourceTables(TObjectName reference) { 1567 List<TTable> tables = new ArrayList<TTable>(); 1568 if (reference == null) return tables; 1569 if (reference.getSourceTable() != null) { 1570 tables.add(reference.getSourceTable()); 1571 return tables; 1572 } 1573 TTableList candidates = reference.getCandidateTables(); 1574 for (int i = 0; candidates != null && i < candidates.size(); i++) { 1575 TTable candidate = candidates.getTable(i); 1576 if (candidate != null) tables.add(candidate); 1577 } 1578 return tables; 1579 } 1580 1581 /** Identity intersection — {@link TTable} does not override equals. */ 1582 private static boolean sharesAnyTable(List<TTable> left, List<TTable> right) { 1583 for (TTable l : left) { 1584 for (TTable r : right) { 1585 if (l == r) return true; 1586 } 1587 } 1588 return false; 1589 } 1590 1591 /** 1592 * Can this {@link ColumnSource} be taken as PROOF that a real column of that 1593 * name exists? 1594 * 1595 * <p>Two independent ways to qualify, because neither alone covers the field:</p> 1596 * <ul> 1597 * <li>the namespace vouches for its output schema 1598 * ({@link BindingMetadataAuthority#lookupOutputSchema}) — catalog metadata, 1599 * an explicit CTE column list, a subquery projection;</li> 1600 * <li>or the source itself was recorded at full confidence. Namespaces with a 1601 * FIXED output schema — {@code ValuesNamespace} ({@code v(c)}), 1602 * {@code UnnestNamespace}, {@code PivotNamespace} — know their columns 1603 * exactly but never override {@code getMetadataState()}, so the first test 1604 * reports METADATA_UNAVAILABLE for them and would strip a real 1605 * {@code values_alias_column} binding. Every definite source in the 1606 * resolver is built at confidence 1.0 and every inferred one below it 1607 * ({@code inferred_from_usage} 0.8, {@code inferred_from_cte_base_table} 1608 * 0.6), so full confidence is the codebase's own marker for "proven".</li> 1609 * </ul> 1610 */ 1611 private boolean isProvenColumnSource(ColumnSource source, String columnName) { 1612 if (source == null) return false; 1613 INamespace namespace = source.getSourceNamespace(); 1614 if (namespace == null) return false; // already alias-shaped, nothing to protect 1615 if (BindingMetadataAuthority.lookupOutputSchema(namespace, columnName) 1616 == ColumnAuthority.AUTHORITATIVE_PRESENT) { 1617 return true; 1618 } 1619 return source.getConfidence() >= 1.0; 1620 } 1621 1622 /** 1623 * Check if a column reference is within a QUALIFY clause. 1624 * 1625 * @param objName The column reference to check 1626 * @return true if the column is within a QUALIFY clause 1627 */ 1628 private boolean isInQualifyClause(TObjectName objName) { 1629 if (objName == null) return false; 1630 1631 // Get the column's scope to find the enclosing SELECT statement 1632 IScope scope = columnToScopeMap.get(objName); 1633 if (scope == null) return false; 1634 1635 TSelectSqlStatement enclosingSelect = findEnclosingSelectFromScope(scope); 1636 if (enclosingSelect == null) return false; 1637 1638 // Check if this SELECT has a QUALIFY clause 1639 TQualifyClause qualifyClause = enclosingSelect.getQualifyClause(); 1640 if (qualifyClause == null) return false; 1641 1642 // Check if the column's token position is within the QUALIFY clause's range 1643 if (objName.getStartToken() != null && qualifyClause.getStartToken() != null && 1644 qualifyClause.getEndToken() != null) { 1645 long objPos = objName.getStartToken().posinlist; 1646 long qualifyStart = qualifyClause.getStartToken().posinlist; 1647 long qualifyEnd = qualifyClause.getEndToken().posinlist; 1648 1649 return objPos >= qualifyStart && objPos <= qualifyEnd; 1650 } 1651 1652 return false; 1653 } 1654 1655 /** 1656 * Gets or builds the Teradata NAMED alias index for a SELECT statement. 1657 * Optimization C: Caches the alias map for O(1) lookup instead of O(N) iteration. 1658 * 1659 * @param selectStmt The SELECT statement to get/build the index for 1660 * @return Map from lowercase alias name to TResultColumn, or null if no aliases 1661 */ 1662 private Map<String, TResultColumn> getTeradataNamedAliasIndex(TSelectSqlStatement selectStmt) { 1663 if (selectStmt == null) return null; 1664 1665 // Check cache first 1666 Map<String, TResultColumn> index = teradataNamedAliasCache.get(selectStmt); 1667 if (index != null) { 1668 return index; 1669 } 1670 1671 // Build index for this SELECT statement 1672 TResultColumnList resultColumns = selectStmt.getResultColumnList(); 1673 if (resultColumns == null || resultColumns.size() == 0) { 1674 // Cache empty map to avoid rebuilding 1675 index = java.util.Collections.emptyMap(); 1676 teradataNamedAliasCache.put(selectStmt, index); 1677 return index; 1678 } 1679 1680 index = new java.util.LinkedHashMap<>(); 1681 // S2: store keys via the vendor-aware identifier normalizer so the 1682 // index honors quoted-vs-unquoted distinctions on Teradata. 1683 // 1684 // CRITICAL — vendor source-of-truth invariant (codex round-1+2): 1685 // we read {@code sqlStatements.get(0).dbvendor} here, which is the 1686 // SAME source the lookup at 1687 // {@link #handleTeradataNamedAliasResolution} reads (line 916, 1688 // also gated on {@code dbVendor == EDbVendor.dbvteradata}). Storage 1689 // and lookup share this single source so the index keys built here 1690 // and the lookup keys produced there are guaranteed to use 1691 // identical vendor rules. The fall-through to 1692 // {@code selectStmt.dbvendor} is purely defensive and only fires if 1693 // the resolver's statement list is unexpectedly empty. 1694 EDbVendor indexVendor = (sqlStatements != null && sqlStatements.size() > 0 1695 && sqlStatements.get(0).dbvendor != null) 1696 ? sqlStatements.get(0).dbvendor 1697 : (selectStmt.dbvendor != null ? selectStmt.dbvendor : EDbVendor.dbvgeneric); 1698 for (int i = 0; i < resultColumns.size(); i++) { 1699 TResultColumn resultCol = resultColumns.getResultColumn(i); 1700 if (resultCol == null) continue; 1701 1702 // Check if this result column has a NAMED alias 1703 if (resultCol.getAliasClause() != null && 1704 resultCol.getAliasClause().getAliasName() != null) { 1705 String aliasName = resultCol.getAliasClause().getAliasName().toString(); 1706 if (aliasName != null && !aliasName.isEmpty()) { 1707 String key = gudusoft.gsqlparser.sqlenv.IdentifierService.normalizeStatic( 1708 indexVendor, 1709 gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn, 1710 aliasName); 1711 index.put(key, resultCol); 1712 } 1713 } 1714 } 1715 1716 // Cache the index (even if empty, to avoid rebuilding) 1717 teradataNamedAliasCache.put(selectStmt, index); 1718 1719 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE && !index.isEmpty()) { 1720 logDebug("Built Teradata NAMED alias index for SELECT with " + index.size() + " aliases"); 1721 } 1722 1723 return index; 1724 } 1725 1726 /** 1727 * Check if a column reference (TObjectName) is within a result column's expression. 1728 * This is used to prevent treating source columns in expressions like "CAST(ID AS DECIMAL) AS ID" 1729 * as references to the alias. 1730 * 1731 * @param objName The column reference to check 1732 * @param resultCol The result column to check against 1733 * @return true if objName is within resultCol's expression tree 1734 */ 1735 private boolean isColumnWithinResultColumn(TObjectName objName, TResultColumn resultCol) { 1736 if (objName == null || resultCol == null) return false; 1737 1738 // Get the expression of the result column 1739 TExpression expr = resultCol.getExpr(); 1740 if (expr == null) return false; 1741 1742 // Check by comparing start/end positions 1743 // If objName's position is within resultCol's expression, it's part of it 1744 long objStart = objName.getStartToken() != null ? objName.getStartToken().posinlist : -1; 1745 long objEnd = objName.getEndToken() != null ? objName.getEndToken().posinlist : -1; 1746 long exprStart = expr.getStartToken() != null ? expr.getStartToken().posinlist : -1; 1747 long exprEnd = expr.getEndToken() != null ? expr.getEndToken().posinlist : -1; 1748 1749 if (objStart >= 0 && exprStart >= 0 && objEnd >= 0 && exprEnd >= 0) { 1750 return objStart >= exprStart && objEnd <= exprEnd; 1751 } 1752 1753 return false; 1754 } 1755 1756 /** 1757 * Handle subquery aliased/calculated column resolution. 1758 * 1759 * <p>When a column reference resolves through a subquery (or CTE containing subqueries), 1760 * and the underlying column is an alias or calculated expression, we should NOT trace 1761 * it to the base table. This method ensures that such columns have their sourceTable 1762 * cleared to prevent incorrect attribution.</p> 1763 * 1764 * <p>This is essential for queries like:</p> 1765 * <pre> 1766 * WITH DataCTE AS ( 1767 * SELECT t.col, COUNT(*) AS cnt FROM table1 t ... 1768 * ) 1769 * SELECT * FROM DataCTE 1770 * </pre> 1771 * <p>The 'cnt' column should NOT be traced to 'table1' because it's a calculated column.</p> 1772 * 1773 * @param objName The column reference to check 1774 */ 1775 private void handleSubqueryAliasedColumnResolution(TObjectName objName) { 1776 if (objName == null) return; 1777 1778 // Check if column has a table qualifier pointing to a subquery/CTE 1779 // If so, we should KEEP the sourceTable link for lineage tracing 1780 // The qualifier explicitly tells us which subquery the column belongs to 1781 String tableQualifier = objName.getTableString(); 1782 if (tableQualifier != null && !tableQualifier.isEmpty()) { 1783 IScope scope = columnToScopeMap.get(objName); 1784 if (scope != null) { 1785 TTable qualifiedTable = findTableByQualifier(scope, tableQualifier); 1786 if (qualifiedTable != null && 1787 (qualifiedTable.getSubquery() != null || qualifiedTable.getCTE() != null)) { 1788 // Column has qualifier pointing to a subquery/CTE 1789 // Keep the sourceTable link for lineage tracing (e.g., a.num_emp -> subquery a) 1790 // Don't clear sourceTable - this link is correct and needed 1791 logDebug("Subquery/CTE qualified column: " + objName.toString() + 1792 " - keeping sourceTable link to " + tableQualifier); 1793 return; 1794 } 1795 } 1796 } 1797 1798 // For unqualified columns (or columns qualified with base tables), 1799 // check if this is a calculated column or alias that should not trace to base tables 1800 ColumnSource source = objName.getColumnSource(); 1801 if (source != null) { 1802 if (source.isCalculatedColumn() || source.isColumnAlias()) { 1803 TTable currentSource = objName.getSourceTable(); 1804 if (currentSource != null) { 1805 // Only clear if sourceTable is a base table (not subquery/CTE) 1806 // For subquery/CTE references, keep the link for lineage tracing 1807 if (currentSource.getSubquery() == null && currentSource.getCTE() == null) { 1808 objName.setSourceTable(null); 1809 logDebug("Calculated/alias column: " + objName.getColumnNameOnly() + 1810 " cleared sourceTable (was " + currentSource.getName() + ") - not linked to base table"); 1811 } 1812 } 1813 } 1814 } 1815 } 1816 1817 /** 1818 * Gets or builds the FromScopeIndex for a scope (Performance Optimization B). 1819 * 1820 * <p>This method implements lazy initialization: the index is built on first access 1821 * and cached for subsequent lookups within the same resolution pass.</p> 1822 * 1823 * @param scope The scope to get the index for (SelectScope, UpdateScope, or FromScope) 1824 * @return The cached or newly built FromScopeIndex, or null if scope has no FROM clause 1825 */ 1826 private FromScopeIndex getFromScopeIndex(IScope scope) { 1827 if (scope == null) { 1828 return null; 1829 } 1830 1831 // Get the actual FromScope to use as cache key 1832 IScope fromScope = null; 1833 if (scope instanceof SelectScope) { 1834 fromScope = ((SelectScope) scope).getFromScope(); 1835 } else if (scope instanceof gudusoft.gsqlparser.resolver2.scope.UpdateScope) { 1836 fromScope = ((gudusoft.gsqlparser.resolver2.scope.UpdateScope) scope).getFromScope(); 1837 } else if (scope instanceof FromScope) { 1838 fromScope = scope; 1839 } 1840 1841 if (fromScope == null) { 1842 return null; 1843 } 1844 1845 // Check cache first (lazy initialization) 1846 FromScopeIndex index = fromScopeIndexCache.get(fromScope); 1847 if (index == null) { 1848 // S2: thread the GlobalScope's matcher into the index so per-vendor 1849 // identifier rules govern alias / table-name lookups (BigQuery 1850 // tables sensitive, Oracle / Postgres quoted sensitive, etc.). 1851 gudusoft.gsqlparser.resolver2.matcher.INameMatcher matcher = 1852 globalScope != null ? globalScope.getNameMatcher() : null; 1853 // Build index and cache it 1854 index = new FromScopeIndex(fromScope.getChildren(), matcher); 1855 fromScopeIndexCache.put(fromScope, index); 1856 1857 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1858 logDebug("Built FromScopeIndex for scope: " + index); 1859 } 1860 } 1861 1862 return index; 1863 } 1864 1865 /** 1866 * Find a table by its qualifier (alias or name) in the scope. 1867 * Uses FromScopeIndex for O(1) lookup instead of O(N) linear scan. 1868 */ 1869 private TTable findTableByQualifier(IScope scope, String qualifier) { 1870 if (scope == null || qualifier == null) return null; 1871 1872 // Use indexed lookup (Performance Optimization B) 1873 FromScopeIndex index = getFromScopeIndex(scope); 1874 if (index != null) { 1875 return index.findTableByQualifier(qualifier); 1876 } 1877 1878 return null; 1879 } 1880 1881 /** 1882 * Check if a column name is an alias (not a passthrough column) in the subquery. 1883 */ 1884 private boolean isColumnAnAliasInSubquery(TSelectSqlStatement subquery, String columnName) { 1885 if (subquery == null || columnName == null) return false; 1886 1887 TResultColumnList resultCols = subquery.getResultColumnList(); 1888 if (resultCols == null) return false; 1889 1890 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 1891 1892 for (int i = 0; i < resultCols.size(); i++) { 1893 TResultColumn rc = resultCols.getResultColumn(i); 1894 if (rc == null) continue; 1895 1896 // Check if this result column has an alias matching the column name 1897 if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) { 1898 String alias = rc.getAliasClause().getAliasName().toString(); 1899 if (alias != null && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, alias, columnName)) { 1900 // Found matching alias - check if it's a calculated column 1901 TExpression expr = rc.getExpr(); 1902 if (expr != null) { 1903 // Not a simple column reference = calculated 1904 if (expr.getExpressionType() != EExpressionType.simple_object_name_t) { 1905 return true; 1906 } 1907 } 1908 } 1909 } 1910 1911 // Also check for SQL Server proprietary alias syntax: alias = expr 1912 // In this case, the alias is the column name itself 1913 String colName = getResultColumnName(rc); 1914 if (colName != null && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, colName, columnName)) { 1915 TExpression expr = rc.getExpr(); 1916 if (expr != null && expr.getExpressionType() != EExpressionType.simple_object_name_t) { 1917 return true; 1918 } 1919 } 1920 } 1921 return false; 1922 } 1923 1924 /** 1925 * Get the column name from a result column (handles aliases and SQL Server proprietary syntax). 1926 */ 1927 private String getResultColumnName(TResultColumn rc) { 1928 if (rc == null) return null; 1929 1930 // Check for explicit alias 1931 if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) { 1932 return rc.getAliasClause().getAliasName().toString(); 1933 } 1934 1935 // Check for SQL Server proprietary alias: alias = expr 1936 // In this case, the expression itself contains the alias 1937 TExpression expr = rc.getExpr(); 1938 if (expr != null && expr.getExpressionType() == EExpressionType.assignment_t) { 1939 // The left side is the alias 1940 if (expr.getLeftOperand() != null && expr.getLeftOperand().getObjectOperand() != null) { 1941 return expr.getLeftOperand().getObjectOperand().toString(); 1942 } 1943 } 1944 1945 return null; 1946 } 1947 1948 /** 1949 * Find the enclosing SELECT statement from a scope. 1950 * Traverses up the scope hierarchy to find a SelectScope and gets its node. 1951 * 1952 * @param scope The scope to start from 1953 * @return The enclosing SELECT statement, or null if not found 1954 */ 1955 private TSelectSqlStatement findEnclosingSelectFromScope(IScope scope) { 1956 if (scope == null) return null; 1957 1958 IScope currentScope = scope; 1959 int maxIterations = 100; // Prevent infinite loops 1960 int iterations = 0; 1961 1962 while (currentScope != null && iterations < maxIterations) { 1963 iterations++; 1964 1965 // Check if current scope is a SelectScope 1966 if (currentScope instanceof SelectScope) { 1967 TParseTreeNode node = currentScope.getNode(); 1968 if (node instanceof TSelectSqlStatement) { 1969 return (TSelectSqlStatement) node; 1970 } 1971 } 1972 1973 // Move up to parent scope 1974 currentScope = currentScope.getParent(); 1975 } 1976 return null; 1977 } 1978 1979 /** 1980 * Collect a column reference for namespace enhancement if it targets a star namespace. 1981 * This is called during resolution to gather columns that need to be added to namespaces. 1982 * 1983 * @param objName The column reference 1984 * @param scope The scope where the column should be resolved 1985 */ 1986 private void collectForEnhancementIfNeeded(TObjectName objName, IScope scope) { 1987 if (objName == null || scope == null) return; 1988 1989 String columnName = objName.getColumnNameOnly(); 1990 if (columnName == null || columnName.isEmpty()) return; 1991 1992 // Get the resolution result to check status 1993 gudusoft.gsqlparser.resolver2.model.ResolutionResult result = objName.getResolution(); 1994 1995 // Find candidate namespace from scope's FROM clause 1996 INamespace candidateNamespace = findCandidateNamespace(objName, scope); 1997 1998 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 1999 logInfo("[TSQLResolver2] collectForEnhancement: column=" + columnName + 2000 ", candidateNs=" + (candidateNamespace != null ? candidateNamespace.getDisplayName() : "null") + 2001 ", hasStar=" + (candidateNamespace != null ? candidateNamespace.hasStarColumn() : "N/A")); 2002 } 2003 2004 if (candidateNamespace != null) { 2005 // Determine confidence based on context 2006 double confidence = 0.7; // Default for unqualified reference 2007 String evidence = "outer_reference"; 2008 2009 // Higher confidence for qualified references (e.g., "a.column") 2010 if (objName.getTableToken() != null) { 2011 confidence = 0.9; 2012 evidence = "qualified_reference"; 2013 } 2014 2015 // Collect for enhancement 2016 namespaceEnhancer.collectColumnRef( 2017 columnName, 2018 candidateNamespace, 2019 objName, 2020 confidence, 2021 evidence 2022 ); 2023 } 2024 } 2025 2026 /** 2027 * Find the candidate namespace for a column reference. 2028 * Looks at the scope's FROM clause to find namespaces with star columns. 2029 * Uses FromScopeIndex for O(1) lookup instead of O(N) linear scan. 2030 */ 2031 private INamespace findCandidateNamespace(TObjectName objName, IScope scope) { 2032 // Use indexed lookup (Performance Optimization B) 2033 FromScopeIndex index = getFromScopeIndex(scope); 2034 if (index == null) { 2035 return null; 2036 } 2037 2038 String tablePrefix = objName.getTableToken() != null ? 2039 objName.getTableToken().toString() : null; 2040 2041 return index.findCandidateNamespace(tablePrefix); 2042 } 2043 2044 /** 2045 * Delta 1: Collect metadata from DDL statements in the batch. 2046 * 2047 * If no SQLEnv is provided, this method extracts table/column metadata 2048 * from CREATE TABLE and CREATE VIEW statements in the SQL batch and 2049 * creates a TSQLEnv for use during resolution. 2050 * 2051 * This enables standalone resolution of SQL batches that contain both 2052 * DDL and DML without requiring external metadata. 2053 */ 2054 private void collectBatchMetadata() { 2055 if (sqlStatements == null || sqlStatements.size() == 0) { 2056 return; 2057 } 2058 2059 EDbVendor vendor = config != null ? config.getVendor() : EDbVendor.dbvmssql; 2060 BatchMetadataCollector collector = new BatchMetadataCollector(sqlStatements, vendor); 2061 TSQLEnv batchEnv = collector.collect(); 2062 2063 if (batchEnv != null) { 2064 setSqlEnv(batchEnv); 2065 logDebug("Collected batch-local DDL metadata into TSQLEnv"); 2066 } 2067 } 2068 2069 /** 2070 * Delta 4: Track database context from USE/SET statements. 2071 * 2072 * Scans the statement list for USE DATABASE, USE SCHEMA, SET SCHEMA, 2073 * and similar statements, and applies the context to TSQLEnv for 2074 * proper resolution of unqualified table names. 2075 */ 2076 private void trackDatabaseContext() { 2077 if (sqlStatements == null || sqlStatements.size() == 0) { 2078 return; 2079 } 2080 2081 DatabaseContextTracker tracker = new DatabaseContextTracker(); 2082 tracker.processStatements(sqlStatements); 2083 2084 // Apply context to TSQLEnv if any context was found 2085 if (tracker.hasContext()) { 2086 TSQLEnv env = getSqlEnv(); 2087 if (env != null) { 2088 tracker.applyDefaults(env); 2089 logDebug("Applied database context: " + tracker); 2090 } else { 2091 // Create a minimal TSQLEnv if none exists 2092 EDbVendor vendor = config != null ? config.getVendor() : EDbVendor.dbvmssql; 2093 try { 2094 env = new TSQLEnv(vendor) { 2095 @Override 2096 public void initSQLEnv() { 2097 // Minimal initialization 2098 } 2099 }; 2100 tracker.applyDefaults(env); 2101 setSqlEnv(env); 2102 logDebug("Created minimal TSQLEnv with database context: " + tracker); 2103 } catch (Exception e) { 2104 // TSQLEnv creation failed - context will not be applied 2105 logDebug("Failed to create TSQLEnv for database context: " + e.getMessage()); 2106 } 2107 } 2108 } 2109 } 2110 2111 /** 2112 * Build the global scope 2113 */ 2114 private void buildGlobalScope() { 2115 logDebug("Building global scope"); 2116 2117 // Get SQLEnv and vendor for qualified name resolution 2118 TSQLEnv sqlEnv = globalContext != null ? globalContext.getSqlEnv() : null; 2119 EDbVendor vendor = EDbVendor.dbvoracle; // Default 2120 2121 // Try to get vendor from statements 2122 if (sqlStatements != null && sqlStatements.size() > 0) { 2123 vendor = sqlStatements.get(0).dbvendor; 2124 } 2125 2126 // Create global scope with sqlEnv and vendor for proper qualified name resolution 2127 globalScope = new GlobalScope(globalContext, config.getNameMatcher(), sqlEnv, vendor); 2128 2129 logDebug("GlobalScope created with defaults: catalog=" + 2130 globalScope.getDefaultCatalog() + ", schema=" + globalScope.getDefaultSchema()); 2131 } 2132 2133 /** 2134 * Process a single statement 2135 */ 2136 private void processStatement(Object statement) { 2137 if (statement instanceof TSelectSqlStatement) { 2138 processSelectStatement((TSelectSqlStatement) statement); 2139 } 2140 // TODO: Add support for INSERT, UPDATE, DELETE, etc. 2141 } 2142 2143 /** 2144 * Process a SELECT statement 2145 */ 2146 private void processSelectStatement(TSelectSqlStatement select) { 2147 processSelectStatement(select, globalScope); 2148 } 2149 2150 /** 2151 * Process a SELECT statement with a specific parent scope. 2152 * This is used for recursive processing of CTE subqueries. 2153 */ 2154 private void processSelectStatement(TSelectSqlStatement select, IScope givenParentScope) { 2155 logDebug("Processing SELECT statement"); 2156 2157 // Create SELECT scope (will be child of CTE scope if CTEs exist, otherwise child of given parent scope) 2158 IScope parentScope = givenParentScope; 2159 2160 // Process CTEs (WITH clause) if present 2161 CTEScope cteScope = null; 2162 if (select.getCteList() != null && select.getCteList().size() > 0) { 2163 cteScope = processCTEs(select.getCteList(), givenParentScope); 2164 parentScope = cteScope; // CTEs become parent of SELECT 2165 } 2166 2167 SelectScope selectScope = new SelectScope(parentScope, select); 2168 2169 // Process FROM clause 2170 if (select.tables != null && select.tables.size() > 0) { 2171 FromScope fromScope = processFromClause(select, selectScope); 2172 selectScope.setFromScope(fromScope); 2173 } 2174 2175 // Process column references in SELECT list 2176 if (select.getResultColumnList() != null) { 2177 List<TObjectName> selectListColumns = collectObjectNamesFromResultColumns(select.getResultColumnList()); 2178 processColumnReferences(selectListColumns, selectScope); 2179 } 2180 2181 // Process WHERE clause 2182 if (select.getWhereClause() != null && 2183 select.getWhereClause().getCondition() != null) { 2184 List<TObjectName> whereColumns = select.getWhereClause().getCondition().getColumnsInsideExpression(); 2185 processColumnReferences(whereColumns, selectScope); 2186 } 2187 2188 // Process GROUP BY clause 2189 GroupByScope groupByScope = null; 2190 if (select.getGroupByClause() != null) { 2191 groupByScope = processGroupBy(select, selectScope); 2192 } 2193 2194 // Process HAVING clause 2195 if (select.getGroupByClause() != null && 2196 select.getGroupByClause().getHavingClause() != null) { 2197 processHaving(select, selectScope, groupByScope); 2198 } 2199 2200 // Process ORDER BY clause 2201 if (select.getOrderbyClause() != null) { 2202 processOrderBy(select, selectScope); 2203 } 2204 } 2205 2206 /** 2207 * Process FROM clause and build FROM scope 2208 */ 2209 private FromScope processFromClause(TSelectSqlStatement select, IScope parentScope) { 2210 FromScope fromScope = new FromScope(parentScope, select.tables); 2211 2212 // Process each relation (table or join) 2213 ArrayList<TTable> relations = select.getRelations(); 2214 if (relations != null) { 2215 for (TTable table : relations) { 2216 processTableOrJoin(table, fromScope); 2217 } 2218 } 2219 2220 return fromScope; 2221 } 2222 2223 /** 2224 * Recursively process a table or join expression and add to FROM scope 2225 */ 2226 private void processTableOrJoin(TTable table, FromScope fromScope) { 2227 if (table.getTableType() == ETableSource.join) { 2228 // This is a JOIN - recursively process left and right tables 2229 TJoinExpr joinExpr = table.getJoinExpr(); 2230 if (joinExpr != null) { 2231 logDebug("Processing JOIN: " + joinExpr.getJointype()); 2232 2233 // Recursively process left table 2234 TTable leftTable = joinExpr.getLeftTable(); 2235 if (leftTable != null) { 2236 processTableOrJoin(leftTable, fromScope); 2237 } 2238 2239 // Recursively process right table 2240 TTable rightTable = joinExpr.getRightTable(); 2241 if (rightTable != null) { 2242 processTableOrJoin(rightTable, fromScope); 2243 } 2244 2245 // TODO: Create JoinScope to handle nullable semantics 2246 // For now, we just add the base tables to FROM scope 2247 } 2248 } else { 2249 // This is a base table (objectname, subquery, etc.) 2250 INamespace namespace = createNamespaceForTable(table); 2251 2252 // Validate namespace (load metadata) 2253 namespace.validate(); 2254 2255 // Determine alias 2256 String alias = table.getAliasName() != null 2257 ? table.getAliasName() 2258 : table.getName(); 2259 2260 // Add to FROM scope 2261 fromScope.addChild(namespace, alias, false); 2262 2263 logDebug("Added table to FROM scope: " + alias); 2264 } 2265 } 2266 2267 /** 2268 * Process CTEs (WITH clause) and build CTE scope 2269 */ 2270 private CTEScope processCTEs(TCTEList cteList, IScope parentScope) { 2271 CTEScope cteScope = new CTEScope(parentScope, cteList); 2272 logDebug("Processing WITH clause with " + cteList.size() + " CTE(s)"); 2273 2274 // Process each CTE in order (later CTEs can reference earlier ones) 2275 for (int i = 0; i < cteList.size(); i++) { 2276 TCTE cte = cteList.getCTE(i); 2277 2278 // Get CTE name 2279 String cteName = cte.getTableName() != null ? cte.getTableName().toString() : null; 2280 if (cteName == null) { 2281 logDebug("Skipping CTE with null name"); 2282 continue; 2283 } 2284 2285 // Get CTE subquery 2286 TSelectSqlStatement cteSubquery = cte.getSubquery(); 2287 if (cteSubquery == null) { 2288 logDebug("Skipping CTE '" + cteName + "' with null subquery"); 2289 continue; 2290 } 2291 2292 // Create CTENamespace 2293 CTENamespace cteNamespace = new CTENamespace( 2294 cte, 2295 cteName, 2296 cteSubquery, 2297 config.getNameMatcher() 2298 ); 2299 2300 // Validate namespace (load column metadata from subquery) 2301 cteNamespace.validate(); 2302 2303 // Add to CTE scope (makes it visible to later CTEs and main query) 2304 cteScope.addCTE(cteName, cteNamespace); 2305 2306 logDebug("Added CTE to scope: " + cteName + 2307 " (columns=" + cteNamespace.getExplicitColumns().size() + 2308 ", recursive=" + cteNamespace.isRecursive() + ")"); 2309 2310 // Recursively process CTE subquery 2311 // This ensures that: 2312 // 1. Columns within the CTE are properly resolved 2313 // 2. Nested CTEs within this CTE are handled 2314 // 3. Later CTEs can reference this CTE's columns 2315 logDebug("Recursively processing CTE subquery: " + cteName); 2316 processSelectStatement(cteSubquery, cteScope); 2317 } 2318 2319 return cteScope; 2320 } 2321 2322 /** 2323 * Process GROUP BY clause and build GROUP BY scope 2324 */ 2325 private GroupByScope processGroupBy(TSelectSqlStatement select, SelectScope selectScope) { 2326 GroupByScope groupByScope = new GroupByScope(selectScope, select.getGroupByClause()); 2327 logDebug("Processing GROUP BY clause"); 2328 2329 // Set the FROM scope for column resolution 2330 if (selectScope.getFromScope() != null) { 2331 groupByScope.setFromScope(selectScope.getFromScope()); 2332 } 2333 2334 // Process column references in GROUP BY items 2335 if (select.getGroupByClause().getItems() != null) { 2336 for (int i = 0; i < select.getGroupByClause().getItems().size(); i++) { 2337 gudusoft.gsqlparser.nodes.TGroupByItem item = select.getGroupByClause().getItems().getGroupByItem(i); 2338 if (item.getExpr() != null) { 2339 List<TObjectName> groupByColumns = item.getExpr().getColumnsInsideExpression(); 2340 processColumnReferences(groupByColumns, groupByScope); 2341 } 2342 } 2343 } 2344 2345 return groupByScope; 2346 } 2347 2348 /** 2349 * Process HAVING clause and build HAVING scope 2350 */ 2351 private void processHaving(TSelectSqlStatement select, SelectScope selectScope, GroupByScope groupByScope) { 2352 logDebug("Processing HAVING clause"); 2353 2354 HavingScope havingScope = new HavingScope( 2355 selectScope, 2356 select.getGroupByClause().getHavingClause() 2357 ); 2358 2359 // Set GROUP BY scope for grouped column resolution 2360 if (groupByScope != null) { 2361 havingScope.setGroupByScope(groupByScope); 2362 } 2363 2364 // Set SELECT scope for alias resolution 2365 havingScope.setSelectScope(selectScope); 2366 2367 // Process column references in HAVING condition 2368 List<TObjectName> havingColumns = select.getGroupByClause().getHavingClause().getColumnsInsideExpression(); 2369 processColumnReferences(havingColumns, havingScope); 2370 } 2371 2372 /** 2373 * Process ORDER BY clause and build ORDER BY scope 2374 */ 2375 private void processOrderBy(TSelectSqlStatement select, SelectScope selectScope) { 2376 logDebug("Processing ORDER BY clause"); 2377 2378 OrderByScope orderByScope = new OrderByScope(selectScope, select.getOrderbyClause()); 2379 2380 // Set SELECT scope for alias resolution 2381 orderByScope.setSelectScope(selectScope); 2382 2383 // Set FROM scope for direct column resolution (database-dependent) 2384 if (selectScope.getFromScope() != null) { 2385 orderByScope.setFromScope(selectScope.getFromScope()); 2386 } 2387 2388 // Process column references in ORDER BY items 2389 if (select.getOrderbyClause().getItems() != null) { 2390 for (int i = 0; i < select.getOrderbyClause().getItems().size(); i++) { 2391 gudusoft.gsqlparser.nodes.TOrderByItem item = select.getOrderbyClause().getItems().getOrderByItem(i); 2392 if (item.getSortKey() != null) { 2393 List<TObjectName> orderByColumns = item.getSortKey().getColumnsInsideExpression(); 2394 processColumnReferences(orderByColumns, orderByScope); 2395 } 2396 } 2397 } 2398 } 2399 2400 /** 2401 * Create appropriate namespace for a table 2402 */ 2403 private INamespace createNamespaceForTable(TTable table) { 2404 // Check if it's a subquery 2405 if (table.getSubquery() != null) { 2406 return new SubqueryNamespace( 2407 table.getSubquery(), 2408 table.getAliasName(), 2409 config.getNameMatcher() 2410 ); 2411 } 2412 2413 // Regular table - pass sqlEnv and vendor for qualified name resolution 2414 TSQLEnv sqlEnv = globalContext != null ? globalContext.getSqlEnv() : null; 2415 EDbVendor vendor = table.dbvendor != null ? table.dbvendor : EDbVendor.dbvoracle; 2416 return new TableNamespace(table, config.getNameMatcher(), sqlEnv, vendor); 2417 } 2418 2419 /** 2420 * Collect all TObjectName from TResultColumnList 2421 */ 2422 private List<TObjectName> collectObjectNamesFromResultColumns( 2423 gudusoft.gsqlparser.nodes.TResultColumnList resultColumns) { 2424 List<TObjectName> objNames = new ArrayList<>(); 2425 2426 for (int i = 0; i < resultColumns.size(); i++) { 2427 gudusoft.gsqlparser.nodes.TResultColumn rc = resultColumns.getResultColumn(i); 2428 if (rc.getExpr() != null) { 2429 // Get all column references from the expression 2430 List<TObjectName> exprColumns = rc.getExpr().getColumnsInsideExpression(); 2431 if (exprColumns != null) { 2432 objNames.addAll(exprColumns); 2433 } 2434 } 2435 } 2436 2437 return objNames; 2438 } 2439 2440 /** 2441 * Process column references (TObjectName list) 2442 */ 2443 private void processColumnReferences(List<TObjectName> objectNames, IScope scope) { 2444 if (objectNames == null) return; 2445 2446 for (TObjectName objName : objectNames) { 2447 // Record column-to-scope mapping for iterative resolution (Principle 1) 2448 columnToScopeMap.put(objName, scope); 2449 allColumnReferences.add(objName); 2450 2451 // Resolve the column reference 2452 nameResolver.resolve(objName, scope); 2453 2454 // Handle USING column priority for JOIN...USING syntax 2455 handleUsingColumnResolution(objName); 2456 2457 // Handle Teradata NAMED alias resolution 2458 handleTeradataNamedAliasResolution(objName); 2459 handleQualifyClauseAliasResolution(objName); 2460 handleSelectListAliasResolution(objName); 2461 } 2462 } 2463 2464 // Detailed legacy sync timing (for profiling) 2465 private static long globalTimeClearLinked = 0; 2466 private static long globalTimeFillAttributes = 0; 2467 private static long globalTimeSyncColumns = 0; 2468 private static long globalTimePopulateOrphans = 0; 2469 private static long globalTimeClearHints = 0; 2470 2471 /** 2472 * Get detailed legacy sync timing breakdown. 2473 */ 2474 public static String getLegacySyncTimings() { 2475 long total = globalTimeClearLinked + globalTimeFillAttributes + globalTimeSyncColumns + globalTimePopulateOrphans + globalTimeClearHints; 2476 return String.format( 2477 "LegacySync Breakdown:\n" + 2478 " ClearLinkedColumns: %d ms (%.1f%%)\n" + 2479 " FillTableAttributes: %d ms (%.1f%%)\n" + 2480 " SyncColumnToLegacy: %d ms (%.1f%%)\n" + 2481 " PopulateOrphanColumns: %d ms (%.1f%%)\n" + 2482 " ClearSyntaxHints: %d ms (%.1f%%)\n" + 2483 " Total: %d ms", 2484 globalTimeClearLinked, total > 0 ? 100.0 * globalTimeClearLinked / total : 0, 2485 globalTimeFillAttributes, total > 0 ? 100.0 * globalTimeFillAttributes / total : 0, 2486 globalTimeSyncColumns, total > 0 ? 100.0 * globalTimeSyncColumns / total : 0, 2487 globalTimePopulateOrphans, total > 0 ? 100.0 * globalTimePopulateOrphans / total : 0, 2488 globalTimeClearHints, total > 0 ? 100.0 * globalTimeClearHints / total : 0, 2489 total); 2490 } 2491 2492 /** 2493 * Create cloned columns for star column tracing. 2494 * 2495 * <p>This is a CORE part of TSQLResolver2's name resolution. When a column traces 2496 * through a CTE or subquery with SELECT * to a physical table, we create a cloned 2497 * TObjectName with sourceTable pointing to the traced physical table. 2498 * 2499 * <p>Example: 2500 * <pre> 2501 * WITH cte AS (SELECT * FROM physical_table) 2502 * SELECT a FROM cte 2503 * </pre> 2504 * 2505 * <p>For column 'a' in the outer SELECT: 2506 * <ul> 2507 * <li>Original column: sourceTable = cte (immediate source)</li> 2508 * <li>Cloned column: sourceTable = physical_table (traced through star)</li> 2509 * </ul> 2510 * 2511 * <p>Both columns are added to allColumnReferences for complete lineage tracking. 2512 * This ensures the formatter can output both the immediate source and the traced 2513 * physical table when needed. 2514 */ 2515 private void createTracedColumnClones() { 2516 // Collect clones to add (avoid ConcurrentModificationException) 2517 java.util.List<TObjectName> clonesToAdd = new java.util.ArrayList<>(); 2518 2519 // Mantis 4651: this method owns the clone bookkeeping consulted by 2520 // syncColumnToLegacy(), so start from a clean slate on every invocation. 2521 tracedStarClones.clear(); 2522 tracedStarCloneKeys.clear(); 2523 2524 // Index of existing (sourceTable, column) pairs for O(1) dedup — table by 2525 // identity, column by canonical identity (Mantis 4651). 2526 Map<TTable, Set<CanonKey>> existingKeys = new IdentityHashMap<TTable, Set<CanonKey>>(); 2527 for (TObjectName existing : allColumnReferences) { 2528 if (existing.getSourceTable() != null) { 2529 String existingColName = existing.getColumnNameOnly(); 2530 if (existingColName != null) { 2531 addColumnKey(existingKeys, existing.getSourceTable(), existingColName); 2532 } 2533 } 2534 } 2535 2536 for (TObjectName column : allColumnReferences) { 2537 // Skip star columns - they represent all columns from a table and shouldn't be cloned 2538 String colName = column.getColumnNameOnly(); 2539 if (colName != null && colName.equals("*")) { 2540 continue; 2541 } 2542 2543 // Skip columns without resolution 2544 gudusoft.gsqlparser.resolver2.model.ResolutionResult resolution = column.getResolution(); 2545 if (resolution == null || !resolution.isExactMatch()) { 2546 continue; 2547 } 2548 2549 gudusoft.gsqlparser.resolver2.model.ColumnSource source = resolution.getColumnSource(); 2550 if (source == null) { 2551 continue; 2552 } 2553 2554 TTable sourceTable = column.getSourceTable(); 2555 if (sourceTable == null) { 2556 continue; 2557 } 2558 2559 // Only process CTE or subquery columns 2560 if (!sourceTable.isCTEName() && sourceTable.getTableType() != ETableSource.subquery) { 2561 continue; 2562 } 2563 2564 // Get the traced physical table 2565 TTable finalTable = source.getFinalTable(); 2566 if (finalTable == null || finalTable == sourceTable) { 2567 continue; 2568 } 2569 2570 // Skip if finalTable is also a CTE or subquery 2571 if (finalTable.isCTEName() || finalTable.getTableType() == ETableSource.subquery) { 2572 continue; 2573 } 2574 2575 // Skip subquery columns when the column matches an explicit column in the subquery's 2576 // SELECT list. Cloning is only needed when tracing through star columns. 2577 // For example, in "SELECT al1.COL1, al1.COL3 FROM (SELECT t1.COL1, t2.* FROM t1, t2) al1": 2578 // - al1.COL1 matches explicit "t1.COL1" -> don't clone (stays at subquery level) 2579 // - al1.COL3 doesn't match explicit column, must come from t2.* -> clone to t2 2580 if (sourceTable.getTableType() == ETableSource.subquery) { 2581 TSelectSqlStatement subquery = sourceTable.getSubquery(); 2582 if (subquery != null && subqueryHasExplicitColumn(subquery, colName)) { 2583 continue; 2584 } 2585 } 2586 2587 // Skip UNION scenarios - syncToLegacyStructures already handles linking to all 2588 // UNION branch tables via getAllFinalTables(). Creating clones would cause duplicates. 2589 java.util.List<TTable> allFinalTables = source.getAllFinalTables(); 2590 if (allFinalTables != null && allFinalTables.size() > 1) { 2591 continue; 2592 } 2593 2594 // Skip UNQUALIFIED join condition columns - they should not be traced to the source 2595 // subquery's underlying table via star column expansion. 2596 // This is particularly important for MERGE ON clause columns which may 2597 // belong to the target table rather than the source subquery. 2598 // QUALIFIED columns (like S.id) should still be traced as they explicitly reference 2599 // the source subquery. 2600 // Note: We check location only because ownStmt may be null for unresolved columns. 2601 if (column.getLocation() == ESqlClause.joinCondition 2602 && (column.getTableString() == null || column.getTableString().isEmpty())) { 2603 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2604 logInfo("createTracedColumnClones: Skipping unqualified join condition column " + column.toString() + 2605 " - should not be traced to subquery's underlying table"); 2606 } 2607 continue; 2608 } 2609 2610 // O(1) dedup check using the index instead of an O(n) linear scan 2611 if (addColumnKey(existingKeys, finalTable, colName)) { 2612 // Clone the column and set sourceTable to the traced physical table 2613 TObjectName clonedColumn = column.clone(); 2614 clonedColumn.setSourceTable(finalTable); 2615 clonesToAdd.add(clonedColumn); 2616 // Mantis 4651: remember the clone so the subquery fallback in 2617 // syncColumnToLegacy() does not link the original reference to the 2618 // very same physical column a second time. 2619 tracedStarClones.add(clonedColumn); 2620 addColumnKey(tracedStarCloneKeys, finalTable, colName); 2621 2622 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2623 logInfo("createTracedColumnClones: Cloned column " + column.toString() + 2624 " with sourceTable traced from " + sourceTable.getTableName() + 2625 " to physical table " + finalTable.getTableName()); 2626 } 2627 } 2628 } 2629 2630 // Add all clones to allColumnReferences (local copy in TSQLResolver2) 2631 allColumnReferences.addAll(clonesToAdd); 2632 2633 // Also add to scopeBuildResult so consumers using scopeBuildResult.getAllColumnReferences() 2634 // (like TestGetTableColumn2 for star column expansion tests) can see the clones 2635 if (scopeBuildResult != null && !clonesToAdd.isEmpty()) { 2636 scopeBuildResult.addColumnReferences(clonesToAdd); 2637 } 2638 2639 // S3: tag every clone as SYNTHETIC_STAR_CLONE so the binding post-pass 2640 // (S5+) can skip them. Clones bypass NameResolver.resolve() entirely 2641 // (see comment block above) — without this tag the post-pass would see 2642 // a TObjectName in getAllColumnReferences() with no recorded 2643 // ResolutionResult and no skip reason, breaking the coverage invariant. 2644 if (resolutionContext.isBindingTraceEnabled() && !clonesToAdd.isEmpty()) { 2645 for (TObjectName clone : clonesToAdd) { 2646 resolutionContext.recordColumnSkipReason( 2647 clone, 2648 gudusoft.gsqlparser.resolver2.binding.BindingSkipReason.SYNTHETIC_STAR_CLONE); 2649 } 2650 } 2651 2652 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE && !clonesToAdd.isEmpty()) { 2653 logInfo("createTracedColumnClones: Created " + clonesToAdd.size() + " traced column clones"); 2654 } 2655 } 2656 2657 /** 2658 * Sync results to legacy structures for backward compatibility. 2659 * This populates: 2660 * - TTable.linkedColumns: columns resolved to this table 2661 * - TObjectName.sourceTable: already set in setResolution() 2662 * - TObjectName.linkedColumnDef: from ColumnSource.definitionNode 2663 * - TObjectName.sourceColumn: from ColumnSource.definitionNode (if TResultColumn) 2664 */ 2665 private void syncToLegacyStructures() { 2666 if (!config.isLegacyCompatibilityEnabled()) { 2667 logInfo("Legacy compatibility disabled, skipping sync"); 2668 return; 2669 } 2670 2671 for (int i = 0; i < sqlStatements.size(); i++) { 2672 } 2673 2674 logInfo("Syncing to legacy structures..."); 2675 2676 long phaseStart; 2677 2678 // Clear existing linkedColumns on all tables 2679 phaseStart = System.currentTimeMillis(); 2680 clearAllLinkedColumns(); 2681 2682 // Clear existing orphanColumns on all statements 2683 // These will be repopulated in Phase 4b based on TSQLResolver2 resolution 2684 for (int i = 0; i < sqlStatements.size(); i++) { 2685 clearOrphanColumnsRecursive(sqlStatements.get(i)); 2686 } 2687 linkedColumnIdentityCache.clear(); 2688 globalTimeClearLinked += System.currentTimeMillis() - phaseStart; 2689 2690 // Phase 1: Fill TTable.getAttributes() for all tables 2691 // This uses the namespace data already collected during resolution 2692 phaseStart = System.currentTimeMillis(); 2693 Set<TTable> processedTables = new HashSet<>(); 2694 for (int i = 0; i < sqlStatements.size(); i++) { 2695 fillTableAttributesRecursive(sqlStatements.get(i), processedTables); 2696 } 2697 globalTimeFillAttributes += System.currentTimeMillis() - phaseStart; 2698 logInfo("Filled attributes for " + processedTables.size() + " tables"); 2699 2700 // Phase 2: Iterate through all column references and sync to legacy structures 2701 phaseStart = System.currentTimeMillis(); 2702 int syncCount = 0; 2703 for (TObjectName column : allColumnReferences) { 2704 if (syncColumnToLegacy(column)) { 2705 syncCount++; 2706 } 2707 } 2708 globalTimeSyncColumns += System.currentTimeMillis() - phaseStart; 2709 2710 // Phase 3: Link CTAS target table columns 2711 // For CREATE TABLE AS SELECT, the SELECT list columns should be linked to the target table 2712 for (int i = 0; i < sqlStatements.size(); i++) { 2713 linkCTASTargetTableColumns(sqlStatements.get(i)); 2714 } 2715 2716 // Phase 4: Sync implicit database/schema from USE DATABASE/USE SCHEMA to AST 2717 // This enables TObjectName.getAnsiSchemaName() and getAnsiCatalogName() to work correctly 2718 syncImplicitDbSchemaToAST(); 2719 2720 // Phase 4b: Populate orphan columns 2721 // Columns with sourceTable=null (unresolved or ambiguous) should be added to 2722 // their containing statement's orphanColumns list. This enables TGetTableColumn 2723 // to report them as orphan columns (with linkOrphanColumnToFirstTable option). 2724 phaseStart = System.currentTimeMillis(); 2725 populateOrphanColumns(); 2726 globalTimePopulateOrphans += System.currentTimeMillis() - phaseStart; 2727 2728 // Phase 4c: Expand star columns using push-down inferred columns 2729 // For SELECT * and SELECT table.*, expand to individual columns based on: 2730 // 1. Inferred columns from the namespace (via push-down algorithm) 2731 // 2. This enables star column expansion without TSQLEnv metadata 2732 phaseStart = System.currentTimeMillis(); 2733 expandStarColumnsUsingPushDown(); 2734 long expandTime = System.currentTimeMillis() - phaseStart; 2735 logInfo("Star column expansion took " + expandTime + "ms"); 2736 2737 // Phase 5: Clear orphan column syntax hints for resolved columns 2738 // The old resolver adds "sphint" (syntax hint) warnings for columns that can't be resolved. 2739 // TSQLResolver2 resolves these columns but doesn't clear the syntax hints. 2740 // This phase cleans up those hints to maintain compatibility with tests expecting no hints. 2741 phaseStart = System.currentTimeMillis(); 2742 clearOrphanColumnSyntaxHints(); 2743 globalTimeClearHints += System.currentTimeMillis() - phaseStart; 2744 2745 logInfo("Legacy sync complete: " + syncCount + "/" + allColumnReferences.size() + " columns synced"); 2746 } 2747 2748 /** 2749 * Link SELECT list columns to CTAS target table. 2750 * For CREATE TABLE AS SELECT statements, the output column names (aliases) 2751 * should be linked to the target table. The source column references 2752 * remain linked to their source tables. 2753 * 2754 * NOTE: For CTAS, the parser (TCreateTableSqlStatement.doParseStatement) already 2755 * correctly creates and links alias columns to the target table. The source columns 2756 * that were incorrectly added are filtered out in clearLinkedColumnsRecursive(). 2757 * This method now only handles cases where the parser didn't create alias columns. 2758 */ 2759 private void linkCTASTargetTableColumns(TCustomSqlStatement stmt) { 2760 if (stmt == null) return; 2761 2762 // CTAS columns are already handled by the parser (TCreateTableSqlStatement.doParseStatement) 2763 // and incorrectly added source columns are filtered in clearLinkedColumnsRecursive(). 2764 // No additional processing needed here for CTAS. 2765 2766 // Process nested statements (for other statement types that might need CTAS handling) 2767 for (int i = 0; i < stmt.getStatements().size(); i++) { 2768 linkCTASTargetTableColumns(stmt.getStatements().get(i)); 2769 } 2770 } 2771 2772 /** 2773 * Populate orphanColumns for unresolved columns. 2774 * Columns with sourceTable=null should be added to their containing statement's orphanColumns. 2775 * This enables TGetTableColumn to report these as "missed" columns. 2776 */ 2777 private void populateOrphanColumns() { 2778 int addedCount = 0; 2779 for (TObjectName column : allColumnReferences) { 2780 if (column == null) continue; 2781 2782 // Skip non-column types that should not be in orphan columns 2783 EDbObjectType dbObjectType = column.getDbObjectType(); 2784 if (dbObjectType == EDbObjectType.column_alias // alias clause column definitions (e.g., AS x (numbers, animals)) 2785 || dbObjectType == EDbObjectType.variable // stored procedure variables 2786 || dbObjectType == EDbObjectType.parameter // stored procedure parameters 2787 || dbObjectType == EDbObjectType.cursor // cursors 2788 || dbObjectType == EDbObjectType.constant // constants 2789 || dbObjectType == EDbObjectType.label // labels 2790 ) { 2791 continue; 2792 } 2793 2794 // ClickHouse expression-CTE alias (WITH <expr> AS ident): resolved 2795 // as a scalar symbol in phase 1; it is neither a table column nor 2796 // a missed column, so it must not be reported as orphan. 2797 if (column.getExpressionCteRef() != null && column.getSourceTable() == null) { 2798 continue; 2799 } 2800 2801 // Check resolution status directly - ambiguous columns should be added to orphanColumns 2802 // Note: column.getColumnSource() returns the first candidate for ambiguous columns, 2803 // which would cause them to be incorrectly skipped. We need to check the resolution status first. 2804 // IMPORTANT: This check must come BEFORE the sourceTable check because Phase 1 (linkColumnToTable) 2805 // might have already set sourceTable during parsing, but TSQLResolver2 correctly marked it as ambiguous. 2806 // NOTE: Skip star columns (*) since they are handled specially via sourceTableList 2807 ResolutionResult resolution = column.getResolution(); 2808 String columnName = column.getColumnNameOnly(); 2809 boolean isStarColumn = columnName != null && columnName.equals("*"); 2810 2811 if (resolution != null && resolution.getStatus() == ResolutionStatus.AMBIGUOUS && !isStarColumn) { 2812 // Ambiguous columns should be added to orphanColumns so they appear as "missed" 2813 // Clear sourceTable if it was set by Phase 1 (linkColumnToTable) so the column 2814 // doesn't also appear as resolved in the output 2815 if (column.getSourceTable() != null) { 2816 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2817 logInfo("populateOrphanColumns: Clearing sourceTable for AMBIGUOUS column: " + column.toString() 2818 + " at (" + column.getLineNo() + "," + column.getColumnNo() + ")" 2819 + " was linked to " + column.getSourceTable().getTableName() 2820 + " with " + (resolution.getAmbiguousSource() != null ? 2821 resolution.getAmbiguousSource().getCandidateCount() : 0) + " candidates"); 2822 } 2823 column.setSourceTable(null); 2824 } 2825 // Fall through to add to orphanColumns 2826 } else { 2827 // Star columns (*) should NEVER be orphan columns - they represent all columns 2828 // from all tables and are handled specially via sourceTableList and linked 2829 // to tables in syncColumnToLegacy() which runs after this phase. 2830 if (isStarColumn) { 2831 continue; 2832 } 2833 2834 // For non-ambiguous columns, skip if they have a sourceTable 2835 if (column.getSourceTable() != null) { 2836 continue; 2837 } 2838 2839 // Also skip columns that have a ColumnSource with a valid table 2840 ColumnSource source = column.getColumnSource(); 2841 if (source != null) { 2842 TTable finalTable = source.getFinalTable(); 2843 if (finalTable != null) { 2844 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2845 logInfo("populateOrphanColumns: Skipping column with ColumnSource: " + column.toString() 2846 + " at (" + column.getLineNo() + "," + column.getColumnNo() + ")" 2847 + " -> resolved to " + finalTable.getTableName()); 2848 } 2849 continue; 2850 } 2851 // Also check overrideTable for derived table columns 2852 TTable overrideTable = source.getOverrideTable(); 2853 if (overrideTable != null) { 2854 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2855 logInfo("populateOrphanColumns: Skipping column with ColumnSource (override): " + column.toString() 2856 + " at (" + column.getLineNo() + "," + column.getColumnNo() + ")" 2857 + " -> resolved to " + overrideTable.getTableName()); 2858 } 2859 continue; 2860 } 2861 } 2862 } 2863 2864 // Debug: log columns being added to orphan 2865 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2866 ColumnSource debugSource = column.getColumnSource(); 2867 logInfo("populateOrphanColumns: Adding orphan column: " + column.toString() 2868 + " at (" + column.getLineNo() + "," + column.getColumnNo() + ")" 2869 + ", hasColumnSource=" + (debugSource != null) 2870 + (debugSource != null ? ", namespace=" + (debugSource.getSourceNamespace() != null ? 2871 debugSource.getSourceNamespace().getClass().getSimpleName() : "null") : "")); 2872 } 2873 2874 // Find the containing statement for this column 2875 TCustomSqlStatement containingStmt = findContainingStatement(column); 2876 if (containingStmt != null) { 2877 // Set ownStmt so TSQLResolver2ResultFormatter can use getOwnStmt().getFirstPhysicalTable() 2878 // to link orphan columns to the first physical table (matching TGetTableColumn behavior) 2879 column.setOwnStmt(containingStmt); 2880 2881 TObjectNameList orphanColumns = containingStmt.getOrphanColumns(); 2882 if (orphanColumns != null && addColumnByIdentityIfAbsent(orphanColumns, column)) { 2883 addedCount++; 2884 } 2885 } else { 2886 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 2887 logInfo("Could not find containing statement for orphan column: " + column.toString()); 2888 } 2889 } 2890 } 2891 logInfo("Populated " + addedCount + " orphan columns"); 2892 } 2893 2894 /** 2895 * Find the statement that contains a column reference. 2896 * First tries to use the scope information (more reliable), then falls back to AST traversal. 2897 * For PL/SQL blocks, searches for the innermost DML statement that contains the column. 2898 */ 2899 private TCustomSqlStatement findContainingStatement(TObjectName column) { 2900 // First, try to use the scope information from columnToScopeMap 2901 // The scope's node is typically the containing statement 2902 IScope scope = columnToScopeMap.get(column); 2903 if (scope != null) { 2904 TParseTreeNode scopeNode = scope.getNode(); 2905 if (scopeNode instanceof TCustomSqlStatement) { 2906 TCustomSqlStatement stmt = (TCustomSqlStatement) scopeNode; 2907 // If the scope is a PL/SQL block or procedure, search for DML statements within it 2908 // that actually contain the column (by line number) 2909 if (isPLSQLBlockStatement(stmt)) { 2910 TCustomSqlStatement dmlStmt = findDMLStatementContaining(stmt, column); 2911 if (dmlStmt != null) { 2912 return dmlStmt; 2913 } 2914 } 2915 return stmt; 2916 } 2917 } 2918 2919 // Fallback: traverse up the AST to find the nearest TCustomSqlStatement parent 2920 TParseTreeNode node = column; 2921 while (node != null) { 2922 if (node instanceof TCustomSqlStatement) { 2923 return (TCustomSqlStatement) node; 2924 } 2925 node = node.getParentObjectName(); 2926 } 2927 2928 // Last resort: search all statements for a DML statement containing the column 2929 TCustomSqlStatement result = null; 2930 if (sqlStatements.size() > 0) { 2931 for (int i = 0; i < sqlStatements.size(); i++) { 2932 TCustomSqlStatement stmt = sqlStatements.get(i); 2933 TCustomSqlStatement dmlStmt = findDMLStatementContaining(stmt, column); 2934 if (dmlStmt != null) { 2935 result = dmlStmt; 2936 break; 2937 } 2938 } 2939 if (result == null) { 2940 result = sqlStatements.get(0); 2941 } 2942 } 2943 return result; 2944 } 2945 2946 /** 2947 * Check if a statement is a PL/SQL block type statement. 2948 */ 2949 private boolean isPLSQLBlockStatement(TCustomSqlStatement stmt) { 2950 if (stmt == null) return false; 2951 String className = stmt.getClass().getSimpleName(); 2952 return className.startsWith("TPlsql") || className.startsWith("TPLSql") || 2953 className.contains("Block") || className.contains("Procedure") || 2954 className.contains("Function") || className.contains("Package"); 2955 } 2956 2957 /** 2958 * DML Statement Range for efficient line-based lookup. 2959 * Used by the DML index cache (Performance Optimization A). 2960 */ 2961 private static class DmlRange implements Comparable<DmlRange> { 2962 final long startLine; 2963 final long endLine; 2964 final TCustomSqlStatement stmt; 2965 2966 DmlRange(TCustomSqlStatement stmt) { 2967 this.stmt = stmt; 2968 this.startLine = stmt.getStartToken() != null ? stmt.getStartToken().lineNo : -1; 2969 this.endLine = stmt.getEndToken() != null ? stmt.getEndToken().lineNo : -1; 2970 } 2971 2972 boolean contains(long line) { 2973 return startLine >= 0 && startLine <= line && line <= endLine; 2974 } 2975 2976 // Sort by startLine for binary search 2977 @Override 2978 public int compareTo(DmlRange other) { 2979 return Long.compare(this.startLine, other.startLine); 2980 } 2981 } 2982 2983 /** 2984 * Cache for DML statement ranges per parent statement (Performance Optimization A). 2985 * Built lazily on first access, cleared at start of each resolve() call. 2986 * Uses IdentityHashMap because we need object identity, not equals(). 2987 */ 2988 private final Map<TCustomSqlStatement, List<DmlRange>> dmlIndexCache = new IdentityHashMap<>(); 2989 2990 /** 2991 * Build DML index for a parent statement. 2992 */ 2993 private List<DmlRange> buildDmlIndex(TCustomSqlStatement parent) { 2994 final List<DmlRange> ranges = new ArrayList<>(); 2995 parent.acceptChildren(new TParseTreeVisitor() { 2996 @Override 2997 public void preVisit(TInsertSqlStatement stmt) { 2998 ranges.add(new DmlRange(stmt)); 2999 } 3000 @Override 3001 public void preVisit(TUpdateSqlStatement stmt) { 3002 ranges.add(new DmlRange(stmt)); 3003 } 3004 @Override 3005 public void preVisit(TDeleteSqlStatement stmt) { 3006 ranges.add(new DmlRange(stmt)); 3007 } 3008 @Override 3009 public void preVisit(TSelectSqlStatement stmt) { 3010 ranges.add(new DmlRange(stmt)); 3011 } 3012 }); 3013 // Sort by startLine for efficient lookup 3014 java.util.Collections.sort(ranges); 3015 return ranges; 3016 } 3017 3018 /** 3019 * Get or build the DML index for a parent statement (Performance Optimization A). 3020 */ 3021 private List<DmlRange> getDmlIndex(TCustomSqlStatement parent) { 3022 return dmlIndexCache.computeIfAbsent(parent, this::buildDmlIndex); 3023 } 3024 3025 /** 3026 * Find the innermost DML statement (INSERT/UPDATE/DELETE/SELECT) within a parent statement 3027 * that contains the given column reference (by line number range). 3028 * Uses cached DML index for O(log N) lookup instead of O(N) traversal. 3029 */ 3030 private TCustomSqlStatement findDMLStatementContaining(TCustomSqlStatement parent, TObjectName column) { 3031 if (parent == null || column == null) return null; 3032 3033 long columnLine = column.getLineNo(); 3034 TCustomSqlStatement result = null; 3035 3036 // Use cached DML index (Performance Optimization A) 3037 List<DmlRange> ranges = getDmlIndex(parent); 3038 3039 // Find all DML statements that contain the column by line number 3040 // Need to check all ranges that could contain the column (can't use pure binary search 3041 // because ranges can overlap and we want the innermost one) 3042 for (DmlRange range : ranges) { 3043 // Optimization: if startLine > columnLine, no more ranges can contain it 3044 if (range.startLine > columnLine) { 3045 break; 3046 } 3047 if (range.contains(columnLine)) { 3048 // Found a matching DML statement - prefer the innermost one (later startLine) 3049 if (result == null || 3050 (range.startLine >= result.getStartToken().lineNo)) { 3051 result = range.stmt; 3052 } 3053 } 3054 } 3055 3056 return result; 3057 } 3058 3059 /** 3060 * Sync implicit database/schema from USE DATABASE/USE SCHEMA statements to AST. 3061 * This enables TObjectName.getAnsiSchemaName() and getAnsiCatalogName() to work correctly 3062 * for unqualified object names. 3063 * 3064 * This is similar to what TDatabaseObjectResolver does in the legacy resolver: 3065 * it visits all TObjectName nodes and sets implicitDatabaseName/implicitSchemaName 3066 * based on the current database/schema context. 3067 */ 3068 private void syncImplicitDbSchemaToAST() { 3069 // Get the tracked database context 3070 TSQLEnv env = getSqlEnv(); 3071 if (env == null) { 3072 return; 3073 } 3074 3075 String defaultCatalog = env.getDefaultCatalogName(); 3076 String defaultSchema = env.getDefaultSchemaName(); 3077 3078 // If no defaults are set, nothing to sync 3079 if ((defaultCatalog == null || defaultCatalog.isEmpty()) && 3080 (defaultSchema == null || defaultSchema.isEmpty())) { 3081 return; 3082 } 3083 3084 logDebug("Syncing implicit DB/schema to AST: catalog=" + defaultCatalog + ", schema=" + defaultSchema); 3085 3086 // The defaults above are batch-global: TUseSchema/TUseDatabase write them 3087 // to the TSQLEnv while PARSING, so by the time we get here they hold the 3088 // LAST USE of the whole script. That is wrong for any name that precedes 3089 // a Snowflake database switch, because such a switch INVALIDATES the 3090 // current schema -- Snowflake makes it PUBLIC, or leaves it unset when the 3091 // new database has no PUBLIC -- and stamping the old schema onto later 3092 // names qualifies them as <newdb>.<oldschema>, a pair that need not exist. 3093 // GitHub #715 / MantisBT 4677; same rule as TDDLSQLEnv.analyzeUseDatabase 3094 // and DatabaseContextTracker.processUseDatabase, which clear rather than 3095 // assume PUBLIC. 3096 // 3097 // So walk the statements in order and withhold the implicit schema from 3098 // the ones that follow such a switch. schemaUnsetByUseDatabase can only 3099 // ever become true for Snowflake, so every other vendor keeps the exact 3100 // batch-global behaviour it had before. 3101 String switchedDatabase = null; 3102 3103 // Visit all statements and set implicit names on TObjectName nodes 3104 for (int i = 0; i < sqlStatements.size(); i++) { 3105 TCustomSqlStatement stmt = sqlStatements.get(i); 3106 if (stmt != null) { 3107 String schemaHere = defaultSchema; 3108 if (switchedDatabase != null) { 3109 // After a switch the old schema is gone. What replaces it is 3110 // PUBLIC when the catalog proves the new database has one, 3111 // and nothing otherwise - never a guess. 3112 schemaHere = env.findSchemaAfterDatabaseSwitch(switchedDatabase); 3113 } 3114 stmt.acceptChildren(new ImplicitDbSchemaVisitor(defaultCatalog, schemaHere)); 3115 switchedDatabase = updateSwitchedDatabase(stmt, switchedDatabase); 3116 } 3117 } 3118 } 3119 3120 /** 3121 * The database a Snowflake {@code USE <database>} switched to, for the 3122 * statements AFTER {@code stmt}; null when no switch is in effect. 3123 * 3124 * <p>Any schema switch ({@code USE SCHEMA s}, {@code USE db.s}, 3125 * {@code SET SCHEMA}) ends it, because the script then names the schema 3126 * itself. Only Snowflake can start one, so this is a no-op for every other 3127 * vendor. See {@link #syncImplicitDbSchemaToAST()}. 3128 */ 3129 private static String updateSwitchedDatabase(TCustomSqlStatement stmt, String current) { 3130 if (stmt.dbvendor != EDbVendor.dbvsnowflake) { 3131 return current; 3132 } 3133 if (stmt.sqlstatementtype == ESqlStatementType.sstUseSchema 3134 || stmt.sqlstatementtype == ESqlStatementType.sstSetSchema) { 3135 return null; 3136 } 3137 if (stmt instanceof TUseDatabase) { 3138 TUseDatabase use = (TUseDatabase) stmt; 3139 if (use.isSchema() || use.getDatabaseName() == null) { 3140 return use.isSchema() ? null : current; 3141 } 3142 return use.getDatabaseName().toString(); 3143 } 3144 return current; 3145 } 3146 3147 /** 3148 * Visitor to set implicit database/schema on TObjectName nodes. 3149 */ 3150 private static class ImplicitDbSchemaVisitor extends TParseTreeVisitor { 3151 private final String defaultCatalog; 3152 private final String defaultSchema; 3153 3154 public ImplicitDbSchemaVisitor(String defaultCatalog, String defaultSchema) { 3155 this.defaultCatalog = defaultCatalog; 3156 this.defaultSchema = defaultSchema; 3157 } 3158 3159 @Override 3160 public void preVisit(TObjectName node) { 3161 if (node == null) return; 3162 3163 // Skip column objects - they don't need implicit DB/schema 3164 if (node.getDbObjectType() == EDbObjectType.column) return; 3165 3166 // Skip objects with a db_link - they refer to remote databases 3167 // and should not inherit the current session's default schema/catalog 3168 if (node.getDblink() != null) return; 3169 3170 // Set default database name if not qualified 3171 if (defaultCatalog != null && !defaultCatalog.isEmpty() && node.getDatabaseToken() == null) { 3172 node.setImplictDatabaseName(defaultCatalog); 3173 } 3174 3175 // Set default schema name if not qualified 3176 if (defaultSchema != null && !defaultSchema.isEmpty() && node.getSchemaToken() == null) { 3177 node.setImplictSchemaName(defaultSchema); 3178 } 3179 } 3180 } 3181 3182 /** 3183 * Selectively clear orphan column syntax hints (sphint) based on TSQLResolver2 resolution. 3184 * 3185 * Phase 1 (linkColumnToTable during parsing) adds sphint hints for columns it can't resolve. 3186 * TSQLResolver2 should: 3187 * 1. KEEP sphint hints for columns that are in allColumnReferences with NOT_FOUND/AMBIGUOUS status 3188 * (these are genuinely orphan/ambiguous columns) 3189 * 2. CLEAR sphint hints for all other columns: 3190 * - Columns successfully resolved (EXACT_MATCH) 3191 * - Columns filtered out by ScopeBuilder (package constants, function keywords, etc.) 3192 * - Columns in contexts TSQLResolver2 doesn't collect (MERGE VALUES, etc.) 3193 */ 3194 private void clearOrphanColumnSyntaxHints() { 3195 // Build a set of positions for columns that should KEEP their sphint hints 3196 // These are columns in allColumnReferences with NOT_FOUND or AMBIGUOUS status 3197 Set<String> orphanPositions = new HashSet<>(); 3198 3199 for (TObjectName col : allColumnReferences) { 3200 if (col == null) continue; 3201 gudusoft.gsqlparser.resolver2.model.ResolutionResult resolution = col.getResolution(); 3202 if (resolution != null) { 3203 ResolutionStatus status = resolution.getStatus(); 3204 // Only keep sphint for genuinely AMBIGUOUS columns 3205 // NOT_FOUND columns might be due to TSQLResolver2 scope issues (e.g., MERGE WHEN clause) 3206 // so we clear their sphint to match old resolver behavior 3207 if (status == ResolutionStatus.AMBIGUOUS) { 3208 TSourceToken startToken = col.getStartToken(); 3209 if (startToken != null) { 3210 String key = startToken.lineNo + ":" + startToken.columnNo; 3211 orphanPositions.add(key); 3212 } 3213 } 3214 } 3215 } 3216 3217 // Clear sphint hints for positions NOT in orphanPositions 3218 for (int i = 0; i < sqlStatements.size(); i++) { 3219 TCustomSqlStatement stmt = sqlStatements.get(i); 3220 if (stmt == null) continue; 3221 clearNonOrphanSphintHintsRecursive(stmt, orphanPositions); 3222 } 3223 } 3224 3225 /** 3226 * Recursively clear sphint hints except for genuinely orphan columns. 3227 */ 3228 private void clearNonOrphanSphintHintsRecursive(TCustomSqlStatement stmt, Set<String> orphanPositions) { 3229 if (stmt == null) return; 3230 3231 // Clear sphint hints that are NOT for genuinely orphan columns 3232 if (stmt.getSyntaxHints() != null && stmt.getSyntaxHints().size() > 0) { 3233 for (int j = stmt.getSyntaxHints().size() - 1; j >= 0; j--) { 3234 TSyntaxError syntaxError = stmt.getSyntaxHints().get(j); 3235 if (syntaxError.errortype == EErrorType.sphint) { 3236 String key = syntaxError.lineNo + ":" + syntaxError.columnNo; 3237 if (!orphanPositions.contains(key)) { 3238 // This sphint is NOT for a genuinely orphan column - clear it 3239 stmt.getSyntaxHints().remove(j); 3240 logDebug("Cleared sphint at line " + syntaxError.lineNo); 3241 } 3242 // Keep sphint hints for genuinely orphan columns (in orphanPositions) 3243 } 3244 } 3245 } 3246 3247 // Note: orphanColumns is populated by populateOrphanColumns() in Phase 4b 3248 // DO NOT clear it here - TGetTableColumn relies on orphanColumns for 3249 // linkOrphanColumnToFirstTable functionality 3250 3251 // Process nested statements 3252 for (int k = 0; k < stmt.getStatements().size(); k++) { 3253 clearNonOrphanSphintHintsRecursive(stmt.getStatements().get(k), orphanPositions); 3254 } 3255 } 3256 3257 3258 3259 /** 3260 * Filter UNNEST table's linkedColumns to keep only legitimate columns. 3261 * Phase 1 (linkColumnToTable) may incorrectly link external variables to UNNEST 3262 * when UNNEST is the only table in scope. This method removes such incorrect links. 3263 * 3264 * Legitimate columns for UNNEST: 3265 * - Implicit column: the alias (e.g., "arry_pair" from "UNNEST(...) AS arry_pair") 3266 * - WITH OFFSET column (e.g., "pos" from "WITH OFFSET AS pos") 3267 * - Derived struct field columns (from UNNEST of STRUCT arrays) 3268 */ 3269 private void filterUnnestLinkedColumns(TTable unnestTable) { 3270 if (unnestTable == null || unnestTable.getTableType() != ETableSource.unnest) { 3271 return; 3272 } 3273 3274 TObjectNameList linkedColumns = unnestTable.getLinkedColumns(); 3275 if (linkedColumns == null || linkedColumns.size() == 0) { 3276 return; 3277 } 3278 3279 // Build set of legitimate column names 3280 java.util.Set<String> legitimateNames = new java.util.HashSet<>(); 3281 3282 // 1. Implicit column (alias name) 3283 String aliasName = unnestTable.getAliasName(); 3284 if (aliasName != null && !aliasName.isEmpty()) { 3285 legitimateNames.add(aliasName.toUpperCase()); 3286 } 3287 3288 // 2. WITH OFFSET column 3289 TUnnestClause unnestClause = unnestTable.getUnnestClause(); 3290 if (unnestClause != null && unnestClause.getWithOffset() != null) { 3291 if (unnestClause.getWithOffsetAlais() != null && 3292 unnestClause.getWithOffsetAlais().getAliasName() != null) { 3293 legitimateNames.add(unnestClause.getWithOffsetAlais().getAliasName().toString().toUpperCase()); 3294 } else { 3295 legitimateNames.add("OFFSET"); 3296 } 3297 } 3298 3299 // 3. Derived struct field columns 3300 if (unnestClause != null && unnestClause.getDerivedColumnList() != null) { 3301 for (int i = 0; i < unnestClause.getDerivedColumnList().size(); i++) { 3302 TObjectName derivedCol = unnestClause.getDerivedColumnList().getObjectName(i); 3303 if (derivedCol != null) { 3304 legitimateNames.add(derivedCol.toString().toUpperCase()); 3305 } 3306 } 3307 } 3308 3309 // 4. Explicit alias columns (Presto/Trino syntax: UNNEST(...) AS t(col1, col2)) 3310 if (unnestTable.getAliasClause() != null && 3311 unnestTable.getAliasClause().getColumns() != null) { 3312 for (int i = 0; i < unnestTable.getAliasClause().getColumns().size(); i++) { 3313 TObjectName colName = unnestTable.getAliasClause().getColumns().getObjectName(i); 3314 if (colName != null) { 3315 legitimateNames.add(colName.toString().toUpperCase()); 3316 } 3317 } 3318 } 3319 3320 // Collect columns to keep 3321 java.util.List<TObjectName> toKeep = new java.util.ArrayList<>(); 3322 for (int i = 0; i < linkedColumns.size(); i++) { 3323 TObjectName col = linkedColumns.getObjectName(i); 3324 if (col != null) { 3325 String colName = col.getColumnNameOnly(); 3326 if (colName != null && legitimateNames.contains(colName.toUpperCase())) { 3327 toKeep.add(col); 3328 } 3329 } 3330 } 3331 3332 // Clear and re-add only legitimate columns 3333 linkedColumns.clear(); 3334 for (TObjectName col : toKeep) { 3335 linkedColumns.addObjectName(col); 3336 } 3337 } 3338 3339 /** 3340 * Clear linkedColumns on all tables in all statements. 3341 */ 3342 private void clearAllLinkedColumns() { 3343 // Use a set to track processed statements and avoid processing duplicates 3344 // This is important when processing subqueries within tables, as the same 3345 // subquery might be reachable from multiple paths 3346 java.util.Set<TCustomSqlStatement> processed = new java.util.HashSet<>(); 3347 for (int i = 0; i < sqlStatements.size(); i++) { 3348 clearLinkedColumnsRecursive(sqlStatements.get(i), processed); 3349 } 3350 } 3351 3352 /** 3353 * Recursively clear orphanColumns on statements. 3354 * These will be repopulated with genuinely unresolved columns in Phase 4b. 3355 */ 3356 private void clearOrphanColumnsRecursive(TCustomSqlStatement stmt) { 3357 if (stmt == null) return; 3358 3359 if (stmt.getOrphanColumns() != null) { 3360 stmt.getOrphanColumns().clear(); 3361 } 3362 3363 // Process nested statements 3364 for (int i = 0; i < stmt.getStatements().size(); i++) { 3365 clearOrphanColumnsRecursive(stmt.getStatements().get(i)); 3366 } 3367 3368 // Also handle stored procedure/function body statements 3369 if (stmt instanceof gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) { 3370 gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement sp = 3371 (gudusoft.gsqlparser.stmt.TStoredProcedureSqlStatement) stmt; 3372 for (int i = 0; i < sp.getBodyStatements().size(); i++) { 3373 clearOrphanColumnsRecursive(sp.getBodyStatements().get(i)); 3374 } 3375 } 3376 } 3377 3378 private void clearLinkedColumnsRecursive(TCustomSqlStatement stmt, java.util.Set<TCustomSqlStatement> processed) { 3379 if (stmt == null) return; 3380 3381 // Skip if already processed to avoid redundant work and potential infinite loops 3382 if (processed.contains(stmt)) { 3383 return; 3384 } 3385 processed.add(stmt); 3386 3387 // Skip DAX statements - they populate their own linkedColumns during parsing 3388 // via TDaxFunction.doParse() which calls psql.linkColumnToTable() directly. 3389 // TSQLResolver2's ScopeBuilder doesn't traverse DAX expressions, so we must 3390 // preserve the linkedColumns that DAX parsing already established. 3391 if (stmt instanceof TDaxStmt) { 3392 return; 3393 } 3394 3395 // Skip ALTER TABLE statements - they populate linkedColumns during parsing 3396 // via TAlterTableOption.doParse() which directly adds columns to the target table's 3397 // linkedColumns. TSQLResolver2's ScopeBuilder doesn't traverse these option nodes, 3398 // so we must preserve the linkedColumns that parsing already established. 3399 if (stmt instanceof TAlterTableStatement) { 3400 return; 3401 } 3402 3403 // For CREATE TABLE statements, we need special handling: 3404 // - Regular CREATE TABLE (with column definitions): Preserve constraint columns 3405 // populated during TConstraint.doParse() 3406 // - CTAS (CREATE TABLE AS SELECT): Filter out source columns incorrectly added 3407 // to target table, but preserve the correctly created alias columns 3408 boolean isCreateTable = (stmt instanceof TCreateTableSqlStatement); 3409 if (isCreateTable) { 3410 TCreateTableSqlStatement ctas = (TCreateTableSqlStatement) stmt; 3411 boolean isCTAS = (ctas.getSubQuery() != null); 3412 // For CTAS, filter out source columns from target table's linkedColumns 3413 // The old resolver incorrectly adds source columns (from the SELECT) to the target table 3414 // Keep only columns whose sourceTable is the target table itself 3415 if (isCTAS && ctas.getTargetTable() != null) { 3416 TTable targetTable = ctas.getTargetTable(); 3417 TObjectNameList linkedColumns = targetTable.getLinkedColumns(); 3418 if (linkedColumns != null && linkedColumns.size() > 0) { 3419 // Collect columns to keep (those belonging to target table) 3420 java.util.List<TObjectName> toKeep = new java.util.ArrayList<>(); 3421 for (int i = 0; i < linkedColumns.size(); i++) { 3422 TObjectName col = linkedColumns.getObjectName(i); 3423 if (col != null && col.getSourceTable() == targetTable) { 3424 toKeep.add(col); 3425 } 3426 } 3427 // Clear and re-add only the columns to keep 3428 linkedColumns.clear(); 3429 for (TObjectName col : toKeep) { 3430 linkedColumns.addObjectName(col); 3431 } 3432 } 3433 } 3434 } 3435 3436 if (!isCreateTable && stmt.tables != null) { 3437 // Check if this statement contains a TD_UNPIVOT table 3438 // TD_UNPIVOT populates linkedColumns on its inner table during TTDUnpivot.doParse() 3439 // If we clear linkedColumns here, we lose those column references 3440 boolean hasTDUnpivot = false; 3441 for (int i = 0; i < stmt.tables.size(); i++) { 3442 TTable table = stmt.tables.getTable(i); 3443 if (table != null && table.getTableType() == ETableSource.td_unpivot) { 3444 hasTDUnpivot = true; 3445 break; 3446 } 3447 } 3448 3449 for (int i = 0; i < stmt.tables.size(); i++) { 3450 TTable table = stmt.tables.getTable(i); 3451 if (table != null && table.getLinkedColumns() != null) { 3452 // For UNNEST tables, filter out incorrectly linked columns from Phase 1. 3453 // Phase 1 (linkColumnToTable) may have linked external variables to UNNEST 3454 // when it's the only table in scope. Keep only legitimate columns: 3455 // - Implicit column (the UNNEST alias, e.g., "arry_pair" from "UNNEST(...) AS arry_pair") 3456 // - WITH OFFSET column (e.g., "pos" from "WITH OFFSET AS pos") 3457 if (table.getTableType() == ETableSource.unnest) { 3458 filterUnnestLinkedColumns(table); 3459 continue; 3460 } 3461 // Skip TD_UNPIVOT tables - they don't have their own columns but 3462 // TTDUnpivot.doParse() populates columns on the inner table 3463 if (table.getTableType() == ETableSource.td_unpivot) { 3464 continue; 3465 } 3466 // If this statement contains TD_UNPIVOT, skip clearing all tables 3467 // because TD_UNPIVOT populates linkedColumns on inner tables 3468 if (hasTDUnpivot) { 3469 continue; 3470 } 3471 table.getLinkedColumns().clear(); 3472 } 3473 } 3474 } 3475 3476 // Skip recursive processing if this statement contains TD_UNPIVOT 3477 // TD_UNPIVOT's inner table (in the ON clause) has columns populated during parsing 3478 // and those columns need to be preserved 3479 boolean hasTDUnpivot = false; 3480 if (stmt.tables != null) { 3481 for (int i = 0; i < stmt.tables.size(); i++) { 3482 TTable table = stmt.tables.getTable(i); 3483 if (table != null && table.getTableType() == ETableSource.td_unpivot) { 3484 hasTDUnpivot = true; 3485 break; 3486 } 3487 } 3488 } 3489 3490 if (!hasTDUnpivot) { 3491 for (int i = 0; i < stmt.getStatements().size(); i++) { 3492 clearLinkedColumnsRecursive(stmt.getStatements().get(i), processed); 3493 } 3494 3495 // Also process subqueries within tables - these are NOT in getStatements() 3496 // but are accessed via table.getSubquery() 3497 if (stmt.tables != null) { 3498 for (int i = 0; i < stmt.tables.size(); i++) { 3499 TTable table = stmt.tables.getTable(i); 3500 if (table != null && table.getSubquery() != null) { 3501 clearLinkedColumnsRecursive(table.getSubquery(), processed); 3502 } 3503 } 3504 } 3505 } 3506 } 3507 3508 /** 3509 * Recursively fill TTable.getAttributes() for all tables in a statement. 3510 * Uses namespace data already collected during name resolution. 3511 * 3512 * Processing order is important: 3513 * 1. Process CTEs first 3514 * 2. Process leaf tables (objectname, function, etc.) - not JOIN or subquery 3515 * 3. Process subqueries (recursively) 3516 * 4. Process JOIN tables last (they depend on child tables having attributes) 3517 */ 3518 private void fillTableAttributesRecursive(TCustomSqlStatement stmt, Set<TTable> processedTables) { 3519 if (stmt == null) return; 3520 3521 // Skip DAX statements - they use their own attribute/linkedColumn mechanism 3522 // established during TDaxFunction.doParse() parsing phase. 3523 if (stmt instanceof TDaxStmt) { 3524 return; 3525 } 3526 3527 // Skip ALTER TABLE statements - they use their own linkedColumn mechanism 3528 // established during TAlterTableOption.doParse() parsing phase. 3529 if (stmt instanceof TAlterTableStatement) { 3530 return; 3531 } 3532 3533 // Skip CREATE TABLE statements - they use their own linkedColumn mechanism 3534 // established during TConstraint.doParse() parsing phase. 3535 if (stmt instanceof TCreateTableSqlStatement) { 3536 return; 3537 } 3538 3539 // Phase 1: Process CTE tables first 3540 if (stmt instanceof TSelectSqlStatement) { 3541 TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt; 3542 TCTEList cteList = selectStmt.getCteList(); 3543 if (cteList != null) { 3544 for (int i = 0; i < cteList.size(); i++) { 3545 TCTE cte = cteList.getCTE(i); 3546 if (cte != null && cte.getSubquery() != null) { 3547 fillTableAttributesRecursive(cte.getSubquery(), processedTables); 3548 } 3549 } 3550 } 3551 } 3552 3553 // Collect tables by type for proper processing order 3554 List<TTable> leafTables = new ArrayList<>(); 3555 List<TTable> subqueryTables = new ArrayList<>(); 3556 List<TTable> joinTables = new ArrayList<>(); 3557 3558 // First, collect from stmt.tables 3559 if (stmt.tables != null) { 3560 for (int i = 0; i < stmt.tables.size(); i++) { 3561 TTable table = stmt.tables.getTable(i); 3562 if (table == null || processedTables.contains(table)) continue; 3563 3564 switch (table.getTableType()) { 3565 case join: 3566 joinTables.add(table); 3567 // Also collect nested tables within the join 3568 collectNestedJoinTables(table, leafTables, subqueryTables, joinTables, processedTables); 3569 break; 3570 case subquery: 3571 subqueryTables.add(table); 3572 break; 3573 default: 3574 leafTables.add(table); 3575 break; 3576 } 3577 } 3578 } 3579 3580 // Also collect from getRelations() - JOIN tables are often stored there 3581 if (stmt.getRelations() != null) { 3582 for (int i = 0; i < stmt.getRelations().size(); i++) { 3583 IRelation rel = stmt.getRelations().get(i); 3584 if (!(rel instanceof TTable)) continue; 3585 TTable table = (TTable) rel; 3586 if (processedTables.contains(table)) continue; 3587 3588 if (table.getTableType() == ETableSource.join) { 3589 if (!joinTables.contains(table)) { 3590 joinTables.add(table); 3591 // Also collect nested tables within the join 3592 collectNestedJoinTables(table, leafTables, subqueryTables, joinTables, processedTables); 3593 } 3594 } 3595 } 3596 } 3597 3598 // Phase 2: Process leaf tables first (objectname, function, xml, etc.) 3599 for (TTable table : leafTables) { 3600 if (!processedTables.contains(table)) { 3601 fillTableAttributes(table, processedTables, stmt); 3602 processedTables.add(table); 3603 } 3604 } 3605 3606 // Phase 3: Process subqueries (recursively process their contents first) 3607 for (TTable table : subqueryTables) { 3608 if (!processedTables.contains(table)) { 3609 if (table.getSubquery() != null) { 3610 fillTableAttributesRecursive(table.getSubquery(), processedTables); 3611 } 3612 fillTableAttributes(table, processedTables, stmt); 3613 processedTables.add(table); 3614 } 3615 } 3616 3617 // Phase 4: Process JOIN tables last (they need child tables to have attributes) 3618 for (TTable table : joinTables) { 3619 if (!processedTables.contains(table)) { 3620 fillTableAttributes(table, processedTables, stmt); 3621 processedTables.add(table); 3622 } 3623 } 3624 3625 // Process nested statements 3626 for (int i = 0; i < stmt.getStatements().size(); i++) { 3627 fillTableAttributesRecursive(stmt.getStatements().get(i), processedTables); 3628 } 3629 } 3630 3631 /** 3632 * Collect nested tables within a JOIN expression. 3633 * This ensures all component tables are processed before the JOIN itself. 3634 */ 3635 private void collectNestedJoinTables(TTable joinTable, 3636 List<TTable> leafTables, 3637 List<TTable> subqueryTables, 3638 List<TTable> joinTables, 3639 Set<TTable> processedTables) { 3640 if (joinTable == null || joinTable.getJoinExpr() == null) return; 3641 3642 TJoinExpr joinExpr = joinTable.getJoinExpr(); 3643 3644 // Process left table 3645 TTable leftTable = joinExpr.getLeftTable(); 3646 if (leftTable != null && !processedTables.contains(leftTable)) { 3647 switch (leftTable.getTableType()) { 3648 case join: 3649 joinTables.add(leftTable); 3650 collectNestedJoinTables(leftTable, leafTables, subqueryTables, joinTables, processedTables); 3651 break; 3652 case subquery: 3653 subqueryTables.add(leftTable); 3654 break; 3655 default: 3656 leafTables.add(leftTable); 3657 break; 3658 } 3659 } 3660 3661 // Process right table 3662 TTable rightTable = joinExpr.getRightTable(); 3663 if (rightTable != null && !processedTables.contains(rightTable)) { 3664 switch (rightTable.getTableType()) { 3665 case join: 3666 joinTables.add(rightTable); 3667 collectNestedJoinTables(rightTable, leafTables, subqueryTables, joinTables, processedTables); 3668 break; 3669 case subquery: 3670 subqueryTables.add(rightTable); 3671 break; 3672 default: 3673 leafTables.add(rightTable); 3674 break; 3675 } 3676 } 3677 } 3678 3679 /** 3680 * Fill TTable.getAttributes() for a single table using namespace data. 3681 * This converts the namespace's columnSources to TAttributeNode objects. 3682 * 3683 * @param table The table to fill attributes for 3684 * @param processedTables Set of already processed tables to avoid duplicates 3685 * @param stmt The statement context (used for UNNEST to get the SELECT statement) 3686 */ 3687 private void fillTableAttributes(TTable table, Set<TTable> processedTables, TCustomSqlStatement stmt) { 3688 if (table == null) return; 3689 3690 // Clear existing attributes 3691 table.getAttributes().clear(); 3692 3693 String displayName = table.getDisplayName(true); 3694 if (displayName == null || displayName.isEmpty()) { 3695 displayName = table.getAliasName(); 3696 if (displayName == null || displayName.isEmpty()) { 3697 displayName = table.getName(); 3698 } 3699 } 3700 3701 // First, try to use existing namespace from ScopeBuildResult 3702 // Skip namespace lookup for UNNEST tables - they need special handling via initAttributesForUnnest 3703 INamespace existingNamespace = null; 3704 if (table.getTableType() != ETableSource.unnest) { 3705 existingNamespace = scopeBuildResult != null 3706 ? scopeBuildResult.getNamespaceForTable(table) 3707 : null; 3708 } 3709 3710 if (existingNamespace != null) { 3711 // Use existing namespace's column sources 3712 // Returns false if namespace has no real metadata (only inferred columns) 3713 if (fillAttributesFromNamespace(table, existingNamespace, displayName)) { 3714 return; 3715 } 3716 // Fall through to legacy logic if no real metadata 3717 } 3718 3719 // Fall back to type-specific handling if no namespace found 3720 switch (table.getTableType()) { 3721 case objectname: 3722 if (table.isCTEName()) { 3723 // CTE reference - use initAttributesFromCTE 3724 TCTE cte = table.getCTE(); 3725 if (cte != null) { 3726 table.initAttributesFromCTE(cte); 3727 } 3728 } else { 3729 // Physical table - create TableNamespace and extract columns 3730 fillPhysicalTableAttributes(table, displayName); 3731 } 3732 break; 3733 3734 case subquery: 3735 // Subquery - use initAttributesFromSubquery 3736 if (table.getSubquery() != null) { 3737 String prefix = ""; 3738 if (table.getAliasClause() != null) { 3739 prefix = table.getAliasClause().toString() + "."; 3740 } 3741 table.initAttributesFromSubquery(table.getSubquery(), prefix); 3742 } 3743 break; 3744 3745 case join: 3746 // JOIN - combine attributes from left and right tables 3747 // First, add USING columns to the left and right tables (if present) 3748 if (table.getJoinExpr() != null) { 3749 addUsingColumnsToTables(table.getJoinExpr()); 3750 // Then initialize the join expression's attributes (which pulls from left/right tables) 3751 table.getJoinExpr().initAttributes(0); 3752 } 3753 table.initAttributesForJoin(); 3754 break; 3755 3756 case function: 3757 // Table function 3758 table.initAttributeForTableFunction(); 3759 break; 3760 3761 case xmltable: 3762 // XML table 3763 table.initAttributeForXMLTable(); 3764 break; 3765 3766 case tableExpr: 3767 // Table expression 3768 TAttributeNode.addNodeToList( 3769 new TAttributeNode(displayName + ".*", table), 3770 table.getAttributes() 3771 ); 3772 break; 3773 3774 case rowList: 3775 // Row list 3776 table.initAttributeForRowList(); 3777 break; 3778 3779 case unnest: 3780 // UNNEST - initialize attributes using the SELECT statement context 3781 if (stmt instanceof TSelectSqlStatement) { 3782 TSelectSqlStatement select = (TSelectSqlStatement) stmt; 3783 table.initAttributesForUnnest(getSqlEnv(), select); 3784 } 3785 break; 3786 3787 case pivoted_table: 3788 // PIVOT table 3789 table.initAttributesForPivotTable(); 3790 break; 3791 } 3792 } 3793 3794 /** 3795 * Fill table attributes from an existing namespace's column sources. 3796 * This uses the namespace data that was collected during ScopeBuilder traversal. 3797 * 3798 * @return true if attributes were successfully filled, false if should fall back to legacy logic 3799 */ 3800 private boolean fillAttributesFromNamespace(TTable table, INamespace namespace, String displayName) { 3801 // Ensure namespace is validated 3802 if (!namespace.isValidated()) { 3803 namespace.validate(); 3804 } 3805 3806 // For TableNamespace without actual metadata (only inferred columns), 3807 // return false to fall back to legacy logic which uses wildcards 3808 if (namespace instanceof TableNamespace) { 3809 TableNamespace tableNs = (TableNamespace) namespace; 3810 // Check if the namespace has actual metadata by seeing if there are any columns 3811 // with high confidence from metadata sources (not inferred) 3812 Map<String, ColumnSource> columnSources = namespace.getAllColumnSources(); 3813 boolean hasRealMetadata = false; 3814 for (ColumnSource source : columnSources.values()) { 3815 if (source.getConfidence() >= 1.0 && 3816 !("inferred_from_usage".equals(source.getEvidence()))) { 3817 hasRealMetadata = true; 3818 break; 3819 } 3820 } 3821 if (!hasRealMetadata) { 3822 // No real metadata, fall back to legacy logic with wildcards 3823 return false; 3824 } 3825 3826 // Has metadata - use namespace columns 3827 for (Map.Entry<String, ColumnSource> entry : columnSources.entrySet()) { 3828 String colName = entry.getKey(); 3829 ColumnSource source = entry.getValue(); 3830 // Only include columns with real metadata, not inferred ones 3831 if (source.getConfidence() >= 1.0 && 3832 !("inferred_from_usage".equals(source.getEvidence()))) { 3833 TAttributeNode.addNodeToList( 3834 new TAttributeNode(displayName + "." + colName, table), 3835 table.getAttributes() 3836 ); 3837 } 3838 } 3839 3840 // If no columns after filtering, add wildcard 3841 if (table.getAttributes().isEmpty()) { 3842 TAttributeNode.addNodeToList( 3843 new TAttributeNode(displayName + ".*", table), 3844 table.getAttributes() 3845 ); 3846 } 3847 return true; 3848 } 3849 3850 // For other namespace types (SubqueryNamespace, CTENamespace, etc.), 3851 // use all column sources 3852 Map<String, ColumnSource> columnSources = namespace.getAllColumnSources(); 3853 if (columnSources != null && !columnSources.isEmpty()) { 3854 for (Map.Entry<String, ColumnSource> entry : columnSources.entrySet()) { 3855 String colName = entry.getKey(); 3856 TAttributeNode.addNodeToList( 3857 new TAttributeNode(displayName + "." + colName, table), 3858 table.getAttributes() 3859 ); 3860 } 3861 } 3862 3863 // If no columns found, add wildcard attribute 3864 if (table.getAttributes().isEmpty()) { 3865 TAttributeNode.addNodeToList( 3866 new TAttributeNode(displayName + ".*", table), 3867 table.getAttributes() 3868 ); 3869 } 3870 return true; 3871 } 3872 3873 /** 3874 * Fill attributes for a physical table using TableNamespace. 3875 */ 3876 private void fillPhysicalTableAttributes(TTable table, String displayName) { 3877 // Create namespace for this table with sqlEnv and vendor for qualified name resolution 3878 TSQLEnv sqlEnv = globalContext != null ? globalContext.getSqlEnv() : null; 3879 EDbVendor vendor = table.dbvendor != null ? table.dbvendor : EDbVendor.dbvoracle; 3880 TableNamespace namespace = new TableNamespace(table, config.getNameMatcher(), sqlEnv, vendor); 3881 3882 // Validate to populate columnSources 3883 namespace.validate(); 3884 3885 // Convert columnSources to TAttributeNode 3886 Map<String, ColumnSource> columnSources = namespace.getAllColumnSources(); 3887 if (columnSources != null && !columnSources.isEmpty()) { 3888 for (Map.Entry<String, ColumnSource> entry : columnSources.entrySet()) { 3889 String colName = entry.getKey(); 3890 TAttributeNode.addNodeToList( 3891 new TAttributeNode(displayName + "." + colName, table), 3892 table.getAttributes() 3893 ); 3894 } 3895 } 3896 3897 // If no columns found from metadata, add wildcard attribute 3898 // (this allows any column to potentially match) 3899 if (table.getAttributes().isEmpty()) { 3900 // Add columns from linkedColumns if available 3901 if (table.getLinkedColumns() != null && table.getLinkedColumns().size() > 0) { 3902 for (TObjectName col : table.getLinkedColumns()) { 3903 if (col.getCandidateTables() != null && col.getCandidateTables().size() > 1) { 3904 continue; // Skip ambiguous columns 3905 } 3906 TAttributeNode.addNodeToList( 3907 new TAttributeNode(displayName + "." + col.getColumnNameOnly(), table), 3908 table.getAttributes() 3909 ); 3910 } 3911 } 3912 // Add wildcard attribute 3913 TAttributeNode.addNodeToList( 3914 new TAttributeNode(displayName + ".*", table), 3915 table.getAttributes() 3916 ); 3917 } 3918 } 3919 3920 /** 3921 * Add USING columns to the left and right tables in a JOIN expression. 3922 * USING columns should appear in both tables' attribute lists before the wildcard. 3923 * This method recursively handles nested JOINs. 3924 */ 3925 private void addUsingColumnsToTables(TJoinExpr joinExpr) { 3926 if (joinExpr == null) return; 3927 3928 // Recursively handle nested joins 3929 TTable leftTable = joinExpr.getLeftTable(); 3930 TTable rightTable = joinExpr.getRightTable(); 3931 3932 if (leftTable != null && leftTable.getTableType() == ETableSource.join && leftTable.getJoinExpr() != null) { 3933 addUsingColumnsToTables(leftTable.getJoinExpr()); 3934 } 3935 if (rightTable != null && rightTable.getTableType() == ETableSource.join && rightTable.getJoinExpr() != null) { 3936 addUsingColumnsToTables(rightTable.getJoinExpr()); 3937 } 3938 3939 // Handle USING columns in this join 3940 gudusoft.gsqlparser.nodes.TObjectNameList usingColumns = joinExpr.getUsingColumns(); 3941 if (usingColumns == null || usingColumns.size() == 0) return; 3942 3943 // Add USING columns to both tables 3944 for (int i = 0; i < usingColumns.size(); i++) { 3945 TObjectName usingCol = usingColumns.getObjectName(i); 3946 if (usingCol == null) continue; 3947 String colName = usingCol.getColumnNameOnly(); 3948 3949 // Add to left table (insert before wildcard if possible) 3950 if (leftTable != null && leftTable.getTableType() != ETableSource.join) { 3951 addColumnAttributeBeforeWildcard(leftTable, colName); 3952 } 3953 3954 // Add to right table (insert before wildcard if possible) 3955 if (rightTable != null && rightTable.getTableType() != ETableSource.join) { 3956 addColumnAttributeBeforeWildcard(rightTable, colName); 3957 } 3958 } 3959 } 3960 3961 /** 3962 * Add a column attribute to a table, inserting before the wildcard (*) if present. 3963 * This ensures USING columns appear before the wildcard in the attribute list. 3964 */ 3965 private void addColumnAttributeBeforeWildcard(TTable table, String columnName) { 3966 if (table == null || columnName == null) return; 3967 3968 String displayName = table.getDisplayName(true); 3969 if (displayName == null || displayName.isEmpty()) { 3970 displayName = table.getAliasName(); 3971 if (displayName == null || displayName.isEmpty()) { 3972 displayName = table.getName(); 3973 } 3974 } 3975 3976 String attrName = displayName + "." + columnName; 3977 3978 // Check if attribute already exists 3979 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 3980 ArrayList<TAttributeNode> attrs = table.getAttributes(); 3981 for (TAttributeNode attr : attrs) { 3982 if (SQLUtil.compareIdentifier(dbVendor, ESQLDataObjectType.dotColumn, attr.getName(), attrName)) { 3983 return; // Already exists 3984 } 3985 } 3986 3987 // Find the wildcard position 3988 int wildcardIndex = -1; 3989 for (int i = 0; i < attrs.size(); i++) { 3990 if (attrs.get(i).getName().endsWith(".*")) { 3991 wildcardIndex = i; 3992 break; 3993 } 3994 } 3995 3996 // Insert before wildcard or add to end 3997 TAttributeNode newAttr = new TAttributeNode(attrName, table); 3998 if (wildcardIndex >= 0) { 3999 attrs.add(wildcardIndex, newAttr); 4000 } else { 4001 TAttributeNode.addNodeToList(newAttr, attrs); 4002 } 4003 } 4004 4005 /** 4006 * Sync a single column to legacy structures. 4007 * @return true if column was synced (had a sourceTable) 4008 */ 4009 private boolean syncColumnToLegacy(TObjectName column) { 4010 if (column == null) return false; 4011 4012 // Special handling for star columns (SELECT *) 4013 // Star columns represent ALL tables in the FROM clause and should be synced to ALL tables 4014 // in their sourceTableList, not just the first one. 4015 String columnName = column.getColumnNameOnly(); 4016 if (columnName != null && columnName.equals("*")) { 4017 java.util.ArrayList<TTable> sourceTableList = column.getSourceTableList(); 4018 if (sourceTableList != null && sourceTableList.size() > 0) { 4019 boolean synced = false; 4020 for (TTable starTable : sourceTableList) { 4021 if (starTable == null) continue; 4022 // Skip subquery types - the star should be linked to physical tables 4023 if (starTable.getTableType() == ETableSource.subquery) continue; 4024 gudusoft.gsqlparser.nodes.TObjectNameList starLinkedColumns = starTable.getLinkedColumns(); 4025 if (starLinkedColumns != null && addColumnByIdentityIfAbsent(starLinkedColumns, column)) { 4026 synced = true; 4027 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4028 logInfo("syncColumnToLegacy: Synced star column to sourceTableList table: " 4029 + starTable.getTableName()); 4030 } 4031 } 4032 } 4033 return synced; 4034 } 4035 } 4036 4037 // Check if column is AMBIGUOUS - don't sync to legacy if it's ambiguous 4038 // Ambiguous columns should be added to orphanColumns, not linkedColumns 4039 // NOTE: Skip this check for star columns (*) since they are handled specially 4040 // via sourceTableList and should be linked to all tables in the FROM clause 4041 ResolutionResult resolution = column.getResolution(); 4042 if (resolution != null && resolution.getStatus() == ResolutionStatus.AMBIGUOUS) { 4043 // Don't treat star columns as ambiguous - they're supposed to match all tables 4044 if (columnName != null && columnName.equals("*")) { 4045 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4046 logInfo("syncColumnToLegacy: Star column has AMBIGUOUS status, proceeding with normal sync"); 4047 } 4048 // Fall through to normal processing 4049 } else { 4050 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4051 logInfo("syncColumnToLegacy: Skipping AMBIGUOUS column: " + column.toString() 4052 + " with " + (resolution.getAmbiguousSource() != null ? 4053 resolution.getAmbiguousSource().getCandidateCount() : 0) + " candidates"); 4054 } 4055 // Clear sourceTable if it was set by Phase 1 (linkColumnToTable) 4056 // This ensures the column will be treated as orphan by TGetTableColumn 4057 if (column.getSourceTable() != null) { 4058 column.setSourceTable(null); 4059 } 4060 return false; 4061 } 4062 } 4063 4064 TTable sourceTable = column.getSourceTable(); 4065 ColumnSource source = column.getColumnSource(); 4066 4067 // Handle columns resolved through PlsqlVariableNamespace 4068 // These are stored procedure variables/parameters - mark them as variables 4069 // so they won't be added to orphan columns 4070 if (source != null && source.getSourceNamespace() instanceof gudusoft.gsqlparser.resolver2.namespace.PlsqlVariableNamespace) { 4071 column.setDbObjectTypeDirectly(EDbObjectType.variable); 4072 // Variables don't need to be linked to tables 4073 return false; 4074 } 4075 4076 // Fix for subquery columns: When a column is EXPLICITLY QUALIFIED with a subquery alias 4077 // (e.g., mm.material_id), the old resolver Phase 1 may have incorrectly set sourceTable 4078 // to the physical table inside the subquery. TSQLResolver2 should correct this to point 4079 // to the subquery TTable itself. This preserves the intermediate layer for data lineage: 4080 // mm.material_id -> subquery mm -> physical table 4081 // 4082 // IMPORTANT: Only apply this correction for QUALIFIED columns. Unqualified columns 4083 // (like those inferred from star column expansion) should keep their physical table 4084 // sourceTable for proper data lineage tracing. 4085 if (source != null && column.isQualified()) { 4086 INamespace ns = source.getSourceNamespace(); 4087 if (ns instanceof SubqueryNamespace) { 4088 TTable subqueryTable = ns.getSourceTable(); 4089 // If the subquery's TTable is different from the current sourceTable, 4090 // use the subquery's TTable to maintain proper semantic layering 4091 if (subqueryTable != null && subqueryTable != sourceTable) { 4092 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4093 logInfo("syncColumnToLegacy: Correcting sourceTable from " + 4094 (sourceTable != null ? sourceTable.getTableName() : "null") + 4095 " to subquery " + subqueryTable.getTableName() + " for qualified column " + column.toString()); 4096 } 4097 sourceTable = subqueryTable; 4098 column.setSourceTable(sourceTable); 4099 } 4100 } 4101 } 4102 4103 // If sourceTable is null, try to get it from ColumnSource 4104 // This handles columns resolved to derived tables (subqueries with aliases) 4105 // where TSQLResolver2 resolved via ColumnSource but didn't set sourceTable on TObjectName 4106 if (sourceTable == null && source != null) { 4107 // For alias columns (isColumnAlias) or passthroughs to aliases (getFinalColumnName != null), 4108 // prefer the immediate source table (subquery/CTE) over the traced physical table. 4109 // The alias name doesn't exist in the physical table, so linking with alias name is wrong. 4110 boolean isAliasColumn = source.isColumnAlias() || source.getFinalColumnName() != null; 4111 if (isAliasColumn) { 4112 INamespace ns = source.getSourceNamespace(); 4113 if (ns != null) { 4114 TTable immediateTable = ns.getSourceTable(); 4115 if (immediateTable != null) { 4116 sourceTable = immediateTable; 4117 column.setSourceTable(sourceTable); 4118 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4119 logInfo("syncColumnToLegacy: Set sourceTable to immediate source for alias column " 4120 + column.toString() + " -> " + immediateTable.getTableName()); 4121 } 4122 } 4123 } 4124 } 4125 if (sourceTable == null) { 4126 TTable finalTable = source.getFinalTable(); 4127 if (finalTable != null) { 4128 sourceTable = finalTable; 4129 column.setSourceTable(sourceTable); 4130 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4131 logInfo("syncColumnToLegacy: Set sourceTable from ColumnSource.getFinalTable() for " 4132 + column.toString() + " -> " + finalTable.getTableName()); 4133 } 4134 } else { 4135 // Try getAllFinalTables() - this may succeed when getFinalTable() returns null 4136 // For example, columns inferred through star push-down may have overrideTable set 4137 // which getAllFinalTables() will return as a single-element list 4138 java.util.List<TTable> allFinalTables = source.getAllFinalTables(); 4139 if (allFinalTables != null && !allFinalTables.isEmpty()) { 4140 // Use the first non-subquery table from allFinalTables 4141 for (TTable candidateTable : allFinalTables) { 4142 if (candidateTable != null && candidateTable.getTableType() != ETableSource.subquery) { 4143 sourceTable = candidateTable; 4144 column.setSourceTable(sourceTable); 4145 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4146 logInfo("syncColumnToLegacy: Set sourceTable from ColumnSource.getAllFinalTables() for " 4147 + column.toString() + " -> " + candidateTable.getTableName()); 4148 } 4149 break; 4150 } 4151 } 4152 } 4153 4154 // Fallback: try overrideTable for cases like derived tables in JOIN ON clauses 4155 if (sourceTable == null) { 4156 TTable overrideTable = source.getOverrideTable(); 4157 if (overrideTable != null) { 4158 sourceTable = overrideTable; 4159 column.setSourceTable(sourceTable); 4160 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4161 logInfo("syncColumnToLegacy: Set sourceTable from ColumnSource.getOverrideTable() for " 4162 + column.toString() + " -> " + overrideTable.getTableName()); 4163 } 4164 } 4165 } 4166 } 4167 } 4168 } 4169 4170 if (sourceTable == null) { 4171 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE && source != null) { 4172 logInfo("syncColumnToLegacy: Column " + column.toString() 4173 + " has ColumnSource but no table. Namespace: " 4174 + (source.getSourceNamespace() != null ? source.getSourceNamespace().getClass().getSimpleName() : "null") 4175 + ", evidence: " + source.getEvidence()); 4176 } 4177 return false; 4178 } 4179 4180 // For struct-field access (e.g., customer.customer_id in BigQuery), 4181 // create a synthetic column representing the base column (e.g., "customer") 4182 // instead of using the original column which has the field name (e.g., "customer_id") 4183 if (source != null && source.isStructFieldAccess()) { 4184 String baseColumnName = source.getExposedName(); 4185 if (baseColumnName != null && !baseColumnName.isEmpty()) { 4186 // Create synthetic TObjectName for the base column 4187 EDbVendor vendor = config != null ? config.getVendor() : EDbVendor.dbvbigquery; 4188 TObjectName baseColumn = TObjectName.createObjectName( 4189 vendor, EDbObjectType.column, baseColumnName); 4190 baseColumn.setSourceTable(sourceTable); 4191 4192 // Add the base column to linkedColumns (avoid duplicates by name) 4193 gudusoft.gsqlparser.nodes.TObjectNameList linkedColumns = sourceTable.getLinkedColumns(); 4194 if (linkedColumns != null && !containsColumnByName(linkedColumns, baseColumnName)) { 4195 addColumnByIdentityIfAbsent(linkedColumns, baseColumn); 4196 } 4197 return true; // Skip adding the original struct-qualified column to linkedColumns. 4198 // DataFlowAnalyzer uses FieldPath from the original TObjectName to match 4199 // against the synthetic base column via getStructFieldFullName(). 4200 } 4201 } 4202 4203 // 1. Add to TTable.linkedColumns (avoid duplicates) 4204 gudusoft.gsqlparser.nodes.TObjectNameList linkedColumns = sourceTable.getLinkedColumns(); 4205 if (linkedColumns != null) { 4206 // Mantis 4651: a traced star clone stands for a physical column that a 4207 // direct reference elsewhere in the statement may already have linked to 4208 // this very table; adding it again would report the column twice from 4209 // TTable.getLinkedColumns(). 4210 // 4211 // The existing entry must already carry this table as its sourceTable to 4212 // count as covering the clone. An entry linked here by one of the 4213 // trace-through paths keeps pointing at the derived table it came from, 4214 // and consumers filtering linkedColumns by sourceTable — for instance 4215 // ModelBindingManager.guessTable — reject those, so treating a same-named 4216 // one as coverage would silently drop the physical link. 4217 boolean redundantClone = tracedStarClones.contains(column) 4218 && containsColumnLinkedFromTable(linkedColumns, column.getColumnNameOnly(), 4219 sourceTable); 4220 if (!redundantClone) { 4221 addColumnByIdentityIfAbsent(linkedColumns, column); 4222 } 4223 } 4224 4225 // 2. For UNION scenarios, also add to all final tables from UNION branches 4226 // This is critical for star column push-down tests that expect columns to be 4227 // linked to ALL tables in a UNION, not just the first one. 4228 if (source != null) { 4229 java.util.List<TTable> allFinalTables = source.getAllFinalTables(); 4230 if (allFinalTables != null && allFinalTables.size() > 1) { 4231 for (TTable unionTable : allFinalTables) { 4232 if (unionTable == null || unionTable == sourceTable) continue; 4233 // Skip subquery types - only link to physical tables 4234 if (unionTable.getTableType() == ETableSource.subquery) continue; 4235 gudusoft.gsqlparser.nodes.TObjectNameList unionLinkedColumns = unionTable.getLinkedColumns(); 4236 if (unionLinkedColumns != null) { 4237 addColumnByIdentityIfAbsent(unionLinkedColumns, column); 4238 } 4239 } 4240 } 4241 4242 // 2b. For CTE columns, also link to the CTE reference table 4243 // When a column is resolved through a CTE, it should be linked to both: 4244 // - The CTE reference table (immediate source) 4245 // - The underlying physical tables (final source) 4246 INamespace ns = source.getSourceNamespace(); 4247 if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.CTENamespace) { 4248 gudusoft.gsqlparser.resolver2.namespace.CTENamespace cteNs = 4249 (gudusoft.gsqlparser.resolver2.namespace.CTENamespace) ns; 4250 TTable cteTable = cteNs.getReferencingTable(); 4251 if (cteTable != null && cteTable != sourceTable) { 4252 gudusoft.gsqlparser.nodes.TObjectNameList cteLinkedColumns = cteTable.getLinkedColumns(); 4253 if (cteLinkedColumns != null) { 4254 addColumnByIdentityIfAbsent(cteLinkedColumns, column); 4255 } 4256 } 4257 } 4258 4259 // 2c. For subquery columns, also link to the underlying physical tables 4260 // When sourceTable is a subquery (e.g., qualified column S.id from MERGE USING subquery), 4261 // TGetTableColumn needs the column to be linked to physical tables for output. 4262 // Use getFinalTable() to trace through to the ultimate physical table. 4263 // IMPORTANT: Only link if a column with the same name doesn't already exist - 4264 // this avoids duplicates when both outer and inner queries reference the same column. 4265 // EXCEPTION: Skip MERGE ON clause columns - they should not be linked to the source 4266 // subquery's underlying table because they may belong to the target table instead. 4267 if (sourceTable.getTableType() == ETableSource.subquery) { 4268 // Skip UNQUALIFIED join condition columns - they should not be traced to the source 4269 // subquery's underlying table via star column expansion. 4270 // This is particularly important for MERGE ON clause columns which may 4271 // belong to the target table rather than the source subquery. 4272 // QUALIFIED columns (like S.id) should still be traced as they explicitly reference 4273 // the source subquery. 4274 // Note: We check location only because ownStmt may be null for unresolved columns. 4275 boolean isUnqualifiedJoinConditionColumn = (column.getLocation() == ESqlClause.joinCondition) 4276 && (column.getTableString() == null || column.getTableString().isEmpty()); 4277 if (isUnqualifiedJoinConditionColumn && TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4278 logInfo("syncColumnToLegacy: Skipping unqualified join condition column " + column.toString() + 4279 " - should not be traced to subquery's underlying table"); 4280 } 4281 4282 // Skip alias columns - the alias name doesn't exist in the physical table, 4283 // so linking an alias-named column to the physical table produces wrong output 4284 // (e.g., TestTableEmployee.name instead of TestTableEmployee.ename). 4285 // getFinalTable() traces through aliases to find the physical table, but the 4286 // column name is still the alias. Only non-alias columns should be linked. 4287 boolean isAliasColumnForLinking = source.isColumnAlias() || source.getFinalColumnName() != null; 4288 4289 if (!isUnqualifiedJoinConditionColumn && !isAliasColumnForLinking) { 4290 TTable finalTable = source.getFinalTable(); 4291 if (finalTable != null && finalTable != sourceTable && 4292 finalTable.getTableType() != ETableSource.subquery) { 4293 gudusoft.gsqlparser.nodes.TObjectNameList finalLinkedColumns = finalTable.getLinkedColumns(); 4294 // Mantis 4651: skip when createTracedColumnClones() already produced a 4295 // clone for this (physical table, column name). The clone carries the 4296 // same link with sourceTable pointing at the physical table — which is 4297 // what consumers that filter linkedColumns by sourceTable expect — so 4298 // linking the original reference here as well would only duplicate it. 4299 if (finalLinkedColumns != null 4300 && !hasColumnKey(tracedStarCloneKeys, finalTable, 4301 column.getColumnNameOnly()) 4302 && !containsColumnByName(finalLinkedColumns, column.getColumnNameOnly()) 4303 && addColumnByIdentityIfAbsent(finalLinkedColumns, column)) { 4304 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4305 logInfo("syncColumnToLegacy: Also linked " + column.toString() + 4306 " to underlying physical table " + finalTable.getTableName()); 4307 } 4308 } 4309 } 4310 } 4311 } 4312 4313 } 4314 4315 // 3. Sync linkedColumnDef and sourceColumn from ColumnSource 4316 if (source != null) { 4317 Object defNode = source.getDefinitionNode(); 4318 4319 // Set linkedColumnDef if definition is a TColumnDefinition 4320 if (defNode instanceof gudusoft.gsqlparser.nodes.TColumnDefinition) { 4321 column.setLinkedColumnDef((gudusoft.gsqlparser.nodes.TColumnDefinition) defNode); 4322 } 4323 4324 // Set sourceColumn if definition is a TResultColumn 4325 // BUT skip for CTE explicit columns - these reference the CTE column name (e.g., "mgr_dept") 4326 // not the underlying SELECT column (e.g., "grp"). The CTE column is a TObjectName, 4327 // not a TResultColumn, so we cannot set it as sourceColumn. 4328 if (defNode instanceof TResultColumn) { 4329 String evidence = source.getEvidence(); 4330 boolean isCTEExplicitColumn = evidence != null && evidence.startsWith("cte_explicit_column"); 4331 if (!isCTEExplicitColumn) { 4332 column.setSourceColumn((TResultColumn) defNode); 4333 } 4334 } 4335 // Special case: for star-inferred columns, set sourceColumn to the star column 4336 // The definitionNode is intentionally null to avoid affecting formatter output, 4337 // but we still need to set sourceColumn for legacy API compatibility. 4338 // Use setSourceColumnOnly to avoid changing dbObjectType which affects filtering. 4339 else if (defNode == null && source.getEvidence() != null 4340 && source.getEvidence().contains("auto_inferred")) { 4341 // This is a star-inferred column - get the star column from the namespace 4342 INamespace namespace = source.getSourceNamespace(); 4343 if (namespace != null) { 4344 TResultColumn starColumn = namespace.getStarColumn(); 4345 if (starColumn != null) { 4346 column.setSourceColumnOnly(starColumn); 4347 } 4348 } 4349 } 4350 } 4351 4352 return true; 4353 } 4354 4355 /** 4356 * Check if a column already exists in the list (by identity). 4357 */ 4358 private boolean addColumnByIdentityIfAbsent(TObjectNameList list, TObjectName column) { 4359 // Identity-index mirror of {@code list}. Equivalent to the previous 4360 // linear {@code containsColumn} scan (which tested {@code ==}), but O(1). 4361 // 4362 // Correctness (provably identical to the linear scan): 4363 // These lists (linkedColumns/orphanColumns) are append-only within a 4364 // resolution run, and the index is updated in lockstep on every add 4365 // below. The only way the index can drift from the list is an append 4366 // made outside this method; such an append changes list.size() without 4367 // changing the index size, so the size guard rebuilds the index before 4368 // it is consulted. Therefore, at every membership test the index equals 4369 // the identity-set of the list's current contents, so the add/skip 4370 // decision matches the linear scan exactly. 4371 Set<TObjectName> columns = linkedColumnIdentityCache.get(list); 4372 if (columns == null || columns.size() != list.size()) { 4373 columns = java.util.Collections.newSetFromMap(new IdentityHashMap<TObjectName, Boolean>()); 4374 for (int i = 0; i < list.size(); i++) { 4375 columns.add(list.getObjectName(i)); 4376 } 4377 linkedColumnIdentityCache.put(list, columns); 4378 } 4379 if (!columns.add(column)) { 4380 return false; 4381 } 4382 list.addObjectName(column); 4383 return true; 4384 } 4385 4386 /** 4387 * Mantis 4651 — canonical identity of a column-name segment under the statement's 4388 * vendor, so that key equality is exactly {@link SQLUtil#sameName}. 4389 * 4390 * <p>A folded string would not do. {@code toLowerCase()} (what the clone dedup 4391 * used before) collapses {@code "A"} and {@code "a"}, which on a 4392 * quoted-case-preserving vendor — PostgreSQL, Oracle — are two different columns; 4393 * and rendering a {@link CanonKey} to text re-splits them on the collation-based 4394 * vendors, whose canonical text is diagnostic rather than authoritative.</p> 4395 */ 4396 private CanonKey canonColumnKey(String columnName) { 4397 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 4398 return SQLUtil.canonKey(dbVendor, ESQLDataObjectType.dotColumn, 4399 columnName != null ? columnName : ""); 4400 } 4401 4402 /** 4403 * Mantis 4651 — record {@code (table, columnName)} in an identity-keyed index. 4404 * 4405 * @return true when the pair was not already present 4406 */ 4407 private boolean addColumnKey(Map<TTable, Set<CanonKey>> index, TTable table, String columnName) { 4408 Set<CanonKey> keys = index.get(table); 4409 if (keys == null) { 4410 keys = new HashSet<CanonKey>(); 4411 index.put(table, keys); 4412 } 4413 return keys.add(canonColumnKey(columnName)); 4414 } 4415 4416 /** Mantis 4651 — companion lookup for {@link #addColumnKey}. */ 4417 private boolean hasColumnKey(Map<TTable, Set<CanonKey>> index, TTable table, String columnName) { 4418 Set<CanonKey> keys = index.get(table); 4419 return keys != null && keys.contains(canonColumnKey(columnName)); 4420 } 4421 4422 /** 4423 * Mantis 4651 — like {@link #containsColumnByName}, but only counts entries that 4424 * are already linked <em>from</em> {@code table}, i.e. whose {@code sourceTable} 4425 * is that table. An entry that merely traces through to {@code table} while 4426 * pointing at some derived table is not equivalent to a physical link. 4427 */ 4428 private boolean containsColumnLinkedFromTable(TObjectNameList list, String columnName, 4429 TTable table) { 4430 if (columnName == null) return false; 4431 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 4432 for (int i = 0; i < list.size(); i++) { 4433 TObjectName col = list.getObjectName(i); 4434 if (col != null && col.getSourceTable() == table 4435 && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, 4436 col.getColumnNameOnly(), columnName)) { 4437 return true; 4438 } 4439 } 4440 return false; 4441 } 4442 4443 /** 4444 * Check if a column with the given name already exists in the list. 4445 * Used for struct-field access where we create synthetic columns. 4446 */ 4447 private boolean containsColumnByName(gudusoft.gsqlparser.nodes.TObjectNameList list, String columnName) { 4448 if (columnName == null) return false; 4449 // P0d.3a: canonical equality replaces the hand-rolled strip-quotes-then- 4450 // equalsIgnoreCase (which collapsed quoted-case distinctions on 4451 // quoted-case-preserving vendors). 4452 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 4453 for (int i = 0; i < list.size(); i++) { 4454 TObjectName col = list.getObjectName(i); 4455 if (col != null) { 4456 if (SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, col.getColumnNameOnly(), columnName)) { 4457 return true; 4458 } 4459 } 4460 } 4461 return false; 4462 } 4463 4464 /** 4465 * Check if a subquery SELECT statement has an explicit (non-star) column with the given name. 4466 * This is used to determine whether to create traced column clones: 4467 * - If the column matches an explicit column in the subquery, don't clone (stays at subquery level) 4468 * - If the column doesn't match explicit columns (must come from star), clone to physical table 4469 * 4470 * @param subquery the SELECT statement to check 4471 * @param columnName the column name to look for (may have quotes) 4472 * @return true if the subquery has an explicit column matching the name 4473 */ 4474 private boolean subqueryHasExplicitColumn(TSelectSqlStatement subquery, String columnName) { 4475 if (subquery == null || columnName == null) { 4476 return false; 4477 } 4478 4479 // For combined queries (UNION/INTERSECT/EXCEPT), follow left chain iteratively 4480 TSelectSqlStatement current = subquery; 4481 while (current.isCombinedQuery()) { 4482 current = current.getLeftStmt(); 4483 if (current == null) { 4484 return false; 4485 } 4486 } 4487 subquery = current; 4488 4489 TResultColumnList resultColumns = subquery.getResultColumnList(); 4490 if (resultColumns == null) { 4491 return false; 4492 } 4493 4494 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 4495 4496 for (int i = 0; i < resultColumns.size(); i++) { 4497 TResultColumn rc = resultColumns.getResultColumn(i); 4498 if (rc == null) { 4499 continue; 4500 } 4501 4502 String colStr = rc.toString(); 4503 // Skip star columns - they're not explicit columns 4504 if (colStr != null && (colStr.equals("*") || colStr.endsWith(".*"))) { 4505 continue; 4506 } 4507 4508 // Get the effective column name (alias if present, otherwise the column name) 4509 String effectiveName = null; 4510 if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) { 4511 effectiveName = rc.getAliasClause().getAliasName().toString(); 4512 } else if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) { 4513 // For simple column references like "t1.COL1", get the column name 4514 effectiveName = rc.getExpr().getObjectOperand().getColumnNameOnly(); 4515 } 4516 4517 if (effectiveName != null) { 4518 if (SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, effectiveName, columnName)) { 4519 return true; 4520 } 4521 } 4522 } 4523 4524 return false; 4525 } 4526 4527 /** 4528 * Expand star columns using push-down inferred columns from namespaces. 4529 * 4530 * This is the core of the star column push-down algorithm: 4531 * 1. Find all star columns in SELECT lists 4532 * 2. For each star column, find its source namespace(s) 4533 * 3. Get inferred columns from the namespace (collected during resolution) 4534 * 4. Expand the star column by populating attributeNodesDerivedFromFromClause 4535 * 4536 * This enables star column expansion without TSQLEnv metadata by using 4537 * columns referenced in outer queries to infer what the star expands to. 4538 */ 4539 private void expandStarColumnsUsingPushDown() { 4540 int expandedCount = 0; 4541 Set<TCustomSqlStatement> processedStmts = new HashSet<>(); 4542 4543 // Track expanded star columns by their string representation for syncing 4544 Map<String, ArrayList<TAttributeNode>> expandedStarCols = new HashMap<>(); 4545 4546 // Process all statements recursively 4547 for (int i = 0; i < sqlStatements.size(); i++) { 4548 expandedCount += expandStarColumnsInStatement(sqlStatements.get(i), processedStmts, expandedStarCols); 4549 } 4550 4551 // Sync expanded attributes to column references in getAllColumnReferences() 4552 // The result column TObjectNames might be different instances than those collected 4553 // during scope building, so we need to copy the expanded attrs 4554 if (scopeBuildResult != null && !expandedStarCols.isEmpty()) { 4555 for (TObjectName colRef : scopeBuildResult.getAllColumnReferences()) { 4556 if (colRef == null) continue; 4557 String colStr = colRef.toString(); 4558 if (colStr == null || !colStr.endsWith("*")) continue; 4559 4560 // Skip if already has expanded attrs 4561 ArrayList<TAttributeNode> existingAttrs = colRef.getAttributeNodesDerivedFromFromClause(); 4562 if (existingAttrs != null && !existingAttrs.isEmpty()) continue; 4563 4564 // Find matching expanded star column 4565 ArrayList<TAttributeNode> expandedAttrs = expandedStarCols.get(colStr); 4566 if (expandedAttrs != null && !expandedAttrs.isEmpty()) { 4567 // Copy the expanded attrs to this column reference 4568 for (TAttributeNode attr : expandedAttrs) { 4569 TAttributeNode.addNodeToList(attr, existingAttrs); 4570 } 4571 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4572 logInfo("Synced " + expandedAttrs.size() + " expanded attrs to column reference: " + colStr); 4573 } 4574 } 4575 } 4576 } 4577 4578 logInfo("Expanded star columns using push-down: " + expandedCount + " columns added"); 4579 } 4580 4581 /** 4582 * Recursively expand star columns in a statement and its nested statements. 4583 * Uses processedStmts to track ALL statements (not just SELECTs) to prevent infinite loops. 4584 */ 4585 private int expandStarColumnsInStatement(TCustomSqlStatement stmt, Set<TCustomSqlStatement> processedStmts, 4586 Map<String, ArrayList<TAttributeNode>> expandedStarCols) { 4587 if (stmt == null) return 0; 4588 4589 // Cycle detection: skip if already processed this statement 4590 if (processedStmts.contains(stmt)) { 4591 return 0; 4592 } 4593 processedStmts.add(stmt); 4594 4595 int count = 0; 4596 4597 // Handle SELECT statements 4598 if (stmt instanceof TSelectSqlStatement) { 4599 TSelectSqlStatement select = (TSelectSqlStatement) stmt; 4600 count += expandStarColumnsInSelect(select, expandedStarCols); 4601 4602 // Handle UNION/INTERSECT/EXCEPT - iteratively collect all branches 4603 if (select.isCombinedQuery()) { 4604 Deque<TSelectSqlStatement> unionStack = new ArrayDeque<>(); 4605 if (select.getLeftStmt() != null) unionStack.push(select.getLeftStmt()); 4606 if (select.getRightStmt() != null) unionStack.push(select.getRightStmt()); 4607 while (!unionStack.isEmpty()) { 4608 TSelectSqlStatement branch = unionStack.pop(); 4609 if (branch == null || processedStmts.contains(branch)) continue; 4610 processedStmts.add(branch); 4611 count += expandStarColumnsInSelect(branch, expandedStarCols); 4612 if (branch.isCombinedQuery()) { 4613 if (branch.getLeftStmt() != null) unionStack.push(branch.getLeftStmt()); 4614 if (branch.getRightStmt() != null) unionStack.push(branch.getRightStmt()); 4615 } else { 4616 // Process tables with subqueries in this branch 4617 if (branch.tables != null) { 4618 for (int i = 0; i < branch.tables.size(); i++) { 4619 TTable table = branch.tables.getTable(i); 4620 if (table != null && table.getSubquery() != null) { 4621 count += expandStarColumnsInStatement(table.getSubquery(), processedStmts, expandedStarCols); 4622 } 4623 } 4624 } 4625 if (branch.getCteList() != null) { 4626 for (int i = 0; i < branch.getCteList().size(); i++) { 4627 TCTE cte = branch.getCteList().getCTE(i); 4628 if (cte != null && cte.getSubquery() != null) { 4629 count += expandStarColumnsInStatement(cte.getSubquery(), processedStmts, expandedStarCols); 4630 } 4631 } 4632 } 4633 } 4634 } 4635 } 4636 } 4637 4638 // Handle MERGE statements specially - process the USING clause 4639 if (stmt instanceof gudusoft.gsqlparser.stmt.TMergeSqlStatement) { 4640 gudusoft.gsqlparser.stmt.TMergeSqlStatement merge = (gudusoft.gsqlparser.stmt.TMergeSqlStatement) stmt; 4641 TTable usingTable = merge.getUsingTable(); 4642 if (usingTable != null && usingTable.getSubquery() != null) { 4643 count += expandStarColumnsInStatement(usingTable.getSubquery(), processedStmts, expandedStarCols); 4644 } 4645 } 4646 4647 // Process nested statements 4648 if (stmt.getStatements() != null) { 4649 for (int i = 0; i < stmt.getStatements().size(); i++) { 4650 Object nested = stmt.getStatements().get(i); 4651 if (nested instanceof TCustomSqlStatement) { 4652 count += expandStarColumnsInStatement((TCustomSqlStatement) nested, processedStmts, expandedStarCols); 4653 } 4654 } 4655 } 4656 4657 // Process tables with subqueries 4658 if (stmt.tables != null) { 4659 for (int i = 0; i < stmt.tables.size(); i++) { 4660 TTable table = stmt.tables.getTable(i); 4661 if (table != null && table.getSubquery() != null) { 4662 count += expandStarColumnsInStatement(table.getSubquery(), processedStmts, expandedStarCols); 4663 } 4664 } 4665 } 4666 4667 // Process CTEs 4668 if (stmt.getCteList() != null) { 4669 for (int i = 0; i < stmt.getCteList().size(); i++) { 4670 TCTE cte = stmt.getCteList().getCTE(i); 4671 if (cte != null && cte.getSubquery() != null) { 4672 count += expandStarColumnsInStatement(cte.getSubquery(), processedStmts, expandedStarCols); 4673 } 4674 } 4675 } 4676 4677 return count; 4678 } 4679 4680 /** 4681 * Expand star columns in a SELECT statement's result column list. 4682 */ 4683 private int expandStarColumnsInSelect(TSelectSqlStatement select, Map<String, ArrayList<TAttributeNode>> expandedStarCols) { 4684 if (select == null || select.getResultColumnList() == null) return 0; 4685 4686 int count = 0; 4687 TResultColumnList resultCols = select.getResultColumnList(); 4688 4689 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4690 logInfo("expandStarColumnsInSelect: Processing SELECT with " + resultCols.size() + " result columns"); 4691 } 4692 4693 for (int i = 0; i < resultCols.size(); i++) { 4694 TResultColumn rc = resultCols.getResultColumn(i); 4695 if (rc == null || rc.getExpr() == null) continue; 4696 4697 TObjectName objName = rc.getExpr().getObjectOperand(); 4698 if (objName == null) continue; 4699 4700 String colStr = objName.toString(); 4701 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE && colStr != null) { 4702 logInfo("expandStarColumnsInSelect: Column " + i + ": " + colStr); 4703 } 4704 if (colStr == null || !colStr.endsWith("*")) continue; 4705 4706 // This is a star column - expand it 4707 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4708 logInfo("expandStarColumnsInSelect: Found star column: " + colStr); 4709 } 4710 count += expandSingleStarColumn(objName, select, colStr, rc); 4711 4712 // Track the expanded attrs for syncing to column references 4713 ArrayList<TAttributeNode> attrList = objName.getAttributeNodesDerivedFromFromClause(); 4714 if (attrList != null && !attrList.isEmpty()) { 4715 expandedStarCols.put(colStr, attrList); 4716 } 4717 } 4718 4719 return count; 4720 } 4721 4722 /** 4723 * Expand a single star column using push-down inferred columns. 4724 * 4725 * @param starColumn The star column TObjectName (e.g., "*" or "src.*") 4726 * @param select The containing SELECT statement 4727 * @param colStr The string representation of the star column 4728 * @param resultColumn The TResultColumn containing the star (for EXCEPT column list) 4729 * @return Number of columns added 4730 */ 4731 private int expandSingleStarColumn(TObjectName starColumn, TSelectSqlStatement select, String colStr, TResultColumn resultColumn) { 4732 ArrayList<TAttributeNode> attrList = starColumn.getAttributeNodesDerivedFromFromClause(); 4733 4734 // Skip if already expanded 4735 if (!attrList.isEmpty()) { 4736 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4737 logInfo("expandSingleStarColumn: " + colStr + " already expanded with " + attrList.size() + " attrs"); 4738 } 4739 return 0; 4740 } 4741 4742 // Collect EXCEPT column names to exclude from expansion 4743 // (BigQuery: SELECT * EXCEPT (col1, col2) FROM ...) 4744 Set<String> exceptColumns = new HashSet<>(); 4745 if (resultColumn != null) { 4746 TObjectNameList exceptList = resultColumn.getExceptColumnList(); 4747 if (exceptList != null && exceptList.size() > 0) { 4748 for (int i = 0; i < exceptList.size(); i++) { 4749 TObjectName exceptCol = exceptList.getObjectName(i); 4750 if (exceptCol != null) { 4751 String exceptName = exceptCol.getColumnNameOnly(); 4752 if (exceptName != null && !exceptName.isEmpty()) { 4753 exceptColumns.add(exceptName.toUpperCase()); 4754 } 4755 } 4756 } 4757 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4758 logInfo("expandSingleStarColumn: Found " + exceptColumns.size() + 4759 " EXCEPT columns: " + exceptColumns); 4760 } 4761 } 4762 } 4763 4764 int count = 0; 4765 boolean isQualified = colStr.contains(".") && !colStr.equals("*"); 4766 4767 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4768 logInfo("expandSingleStarColumn: " + colStr + " isQualified=" + isQualified); 4769 } 4770 4771 if (isQualified) { 4772 // Qualified star (e.g., "src.*") - find the specific table/namespace 4773 String tablePrefix = colStr.substring(0, colStr.lastIndexOf('.')); 4774 count += expandQualifiedStar(starColumn, select, tablePrefix, attrList, exceptColumns); 4775 } else { 4776 // Unqualified star (*) - expand from all tables in FROM clause 4777 count += expandUnqualifiedStar(starColumn, select, attrList, exceptColumns); 4778 } 4779 4780 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4781 logInfo("expandSingleStarColumn: " + colStr + " expanded to " + count + " columns"); 4782 } 4783 4784 return count; 4785 } 4786 4787 /** 4788 * Expand a qualified star column (e.g., "src.*") using namespace inferred columns. 4789 * 4790 * @param starColumn The star column TObjectName 4791 * @param select The containing SELECT statement 4792 * @param tablePrefix The table prefix (e.g., "src" from "src.*") 4793 * @param attrList The list to add expanded attributes to 4794 * @param exceptColumns Column names to exclude (from EXCEPT clause), uppercase 4795 */ 4796 private int expandQualifiedStar(TObjectName starColumn, TSelectSqlStatement select, 4797 String tablePrefix, ArrayList<TAttributeNode> attrList, 4798 Set<String> exceptColumns) { 4799 int count = 0; 4800 4801 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4802 logInfo("expandQualifiedStar: tablePrefix=" + tablePrefix + 4803 ", exceptColumns=" + (exceptColumns != null ? exceptColumns : "none")); 4804 } 4805 4806 // Find the source table by alias or name 4807 TTable sourceTable = findTableByPrefixInSelect(select, tablePrefix); 4808 if (sourceTable == null) { 4809 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4810 logInfo("expandQualifiedStar: No source table found for " + tablePrefix); 4811 } 4812 // Fall back to just adding the qualified star attribute 4813 TAttributeNode.addNodeToList( 4814 new TAttributeNode(tablePrefix + ".*", null), 4815 attrList 4816 ); 4817 return 0; 4818 } 4819 4820 // Collect inferred columns from multiple sources: 4821 // 1. The table's own namespace (TableNamespace) 4822 // 2. If the SELECT is a CTE definition, the CTE's namespace 4823 // 3. If the SELECT is a subquery, the containing scope's namespace 4824 Set<String> allInferredCols = new HashSet<>(); 4825 4826 // Source 1: Get namespace for this table 4827 INamespace tableNamespace = scopeBuildResult != null 4828 ? scopeBuildResult.getNamespaceForTable(sourceTable) 4829 : null; 4830 4831 if (tableNamespace != null) { 4832 Set<String> inferredCols = tableNamespace.getInferredColumns(); 4833 if (inferredCols != null) { 4834 allInferredCols.addAll(inferredCols); 4835 } 4836 } 4837 4838 // Source 2: Check if this SELECT is part of a CTE definition 4839 // If so, the CTE namespace may have inferred columns from outer queries 4840 Set<String> cteInferredCols = getInferredColumnsFromContainingCTE(select); 4841 if (cteInferredCols != null) { 4842 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4843 logInfo("expandQualifiedStar: Adding " + cteInferredCols.size() + 4844 " CTE inferred columns for " + tablePrefix); 4845 } 4846 allInferredCols.addAll(cteInferredCols); 4847 } 4848 4849 // Source 3: Check the SELECT's output scope for inferred columns 4850 // IMPORTANT: For qualified star columns (like ta.*), only use scope-level inferred columns 4851 // if they actually exist in this table's namespace. Otherwise we'd incorrectly add columns 4852 // from other tables in the FROM clause to this star's expanded attributes. 4853 IScope selectScope = scopeBuildResult != null 4854 ? scopeBuildResult.getScopeForStatement(select) 4855 : null; 4856 if (selectScope != null) { 4857 Set<String> scopeInferredCols = getInferredColumnsFromScope(selectScope); 4858 if (scopeInferredCols != null && tableNamespace != null) { 4859 // Only add scope-level inferred columns that actually exist in this table's namespace 4860 // This prevents columns from other tables being incorrectly associated with this star 4861 Map<String, ColumnSource> columnSources = tableNamespace.getAllColumnSources(); 4862 Set<String> tableInferredCols = tableNamespace.getInferredColumns(); 4863 for (String scopeCol : scopeInferredCols) { 4864 // Check if this column can be resolved within this table's namespace 4865 boolean hasInNamespace = (columnSources != null && columnSources.containsKey(scopeCol)) || 4866 (tableInferredCols != null && tableInferredCols.contains(scopeCol)); 4867 if (hasInNamespace) { 4868 allInferredCols.add(scopeCol); 4869 } 4870 } 4871 } else if (scopeInferredCols != null && tableNamespace == null) { 4872 // No table namespace - add all scope columns (fallback for edge cases) 4873 allInferredCols.addAll(scopeInferredCols); 4874 } 4875 } 4876 4877 if (!allInferredCols.isEmpty()) { 4878 // Expand using inferred columns, filtering out EXCEPT columns 4879 for (String colName : allInferredCols) { 4880 // Skip columns in EXCEPT clause 4881 if (exceptColumns != null && !exceptColumns.isEmpty() && 4882 exceptColumns.contains(colName.toUpperCase())) { 4883 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4884 logInfo("expandQualifiedStar: Skipping EXCEPT column: " + colName); 4885 } 4886 continue; 4887 } 4888 String attrName = tablePrefix + "." + colName; 4889 TAttributeNode.addNodeToList( 4890 new TAttributeNode(attrName, sourceTable), 4891 attrList 4892 ); 4893 count++; 4894 } 4895 } else if (tableNamespace != null) { 4896 // No inferred columns - try to get from namespace's column sources 4897 Map<String, ColumnSource> columnSources = tableNamespace.getAllColumnSources(); 4898 if (columnSources != null && !columnSources.isEmpty()) { 4899 for (String colName : columnSources.keySet()) { 4900 // Skip columns in EXCEPT clause 4901 if (exceptColumns != null && !exceptColumns.isEmpty() && 4902 exceptColumns.contains(colName.toUpperCase())) { 4903 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4904 logInfo("expandQualifiedStar: Skipping EXCEPT column from sources: " + colName); 4905 } 4906 continue; 4907 } 4908 String attrName = tablePrefix + "." + colName; 4909 TAttributeNode.addNodeToList( 4910 new TAttributeNode(attrName, sourceTable), 4911 attrList 4912 ); 4913 count++; 4914 } 4915 } 4916 } 4917 4918 // If no columns were added, add the star as fallback 4919 if (count == 0) { 4920 TAttributeNode.addNodeToList( 4921 new TAttributeNode(tablePrefix + ".*", sourceTable), 4922 attrList 4923 ); 4924 } 4925 4926 return count; 4927 } 4928 4929 /** 4930 * Get inferred columns from a CTE that contains the given SELECT statement. 4931 * Used for push-down: when outer queries reference columns from a CTE, 4932 * those columns are inferred in the CTE's namespace and should be used 4933 * to expand star columns in the CTE's SELECT. 4934 */ 4935 private Set<String> getInferredColumnsFromContainingCTE(TSelectSqlStatement select) { 4936 if (select == null || scopeBuildResult == null || namespaceEnhancer == null) { 4937 return null; 4938 } 4939 4940 // Find the CTE that defines this SELECT 4941 Set<INamespace> starNamespaces = namespaceEnhancer.getStarNamespaces(); 4942 if (starNamespaces == null) { 4943 return null; 4944 } 4945 4946 for (INamespace ns : starNamespaces) { 4947 if (ns instanceof CTENamespace) { 4948 CTENamespace cteNs = (CTENamespace) ns; 4949 TSelectSqlStatement cteSelect = cteNs.getSelectStatement(); 4950 // Check both by reference and by start token position 4951 if (cteSelect == select || 4952 (cteSelect != null && select != null && 4953 cteSelect.getStartToken() != null && select.getStartToken() != null && 4954 cteSelect.getStartToken().posinlist == select.getStartToken().posinlist)) { 4955 Set<String> inferredCols = cteNs.getInferredColumns(); 4956 if (inferredCols != null && !inferredCols.isEmpty()) { 4957 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4958 logInfo("getInferredColumnsFromContainingCTE: Found CTE " + cteNs.getDisplayName() + 4959 " with " + inferredCols.size() + " inferred columns"); 4960 } 4961 return inferredCols; 4962 } 4963 } 4964 } else if (ns instanceof SubqueryNamespace) { 4965 SubqueryNamespace subNs = (SubqueryNamespace) ns; 4966 TSelectSqlStatement subSelect = subNs.getSelectStatement(); 4967 if (subSelect == select || 4968 (subSelect != null && select != null && 4969 subSelect.getStartToken() != null && select.getStartToken() != null && 4970 subSelect.getStartToken().posinlist == select.getStartToken().posinlist)) { 4971 Set<String> inferredCols = subNs.getInferredColumns(); 4972 if (inferredCols != null && !inferredCols.isEmpty()) { 4973 if (TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) { 4974 logInfo("getInferredColumnsFromContainingCTE: Found Subquery with " + 4975 inferredCols.size() + " inferred columns"); 4976 } 4977 return inferredCols; 4978 } 4979 } 4980 } 4981 } 4982 4983 return null; 4984 } 4985 4986 /** 4987 * Get inferred columns from namespaces in a scope's FROM clause. 4988 */ 4989 private Set<String> getInferredColumnsFromScope(IScope scope) { 4990 if (scope == null) { 4991 return null; 4992 } 4993 4994 Set<String> result = new HashSet<>(); 4995 4996 // Check all namespaces in the scope's children 4997 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : scope.getChildren()) { 4998 INamespace ns = child.getNamespace(); 4999 if (ns != null) { 5000 Set<String> inferredCols = ns.getInferredColumns(); 5001 if (inferredCols != null) { 5002 result.addAll(inferredCols); 5003 } 5004 } 5005 } 5006 5007 return result.isEmpty() ? null : result; 5008 } 5009 5010 /** 5011 * Expand an unqualified star column (*) using all tables in FROM clause. 5012 * 5013 * @param starColumn The star column TObjectName 5014 * @param select The containing SELECT statement 5015 * @param attrList The list to add expanded attributes to 5016 * @param exceptColumns Column names to exclude (from EXCEPT clause), uppercase 5017 */ 5018 private int expandUnqualifiedStar(TObjectName starColumn, TSelectSqlStatement select, 5019 ArrayList<TAttributeNode> attrList, Set<String> exceptColumns) { 5020 int count = 0; 5021 5022 if (select.tables == null) return 0; 5023 5024 for (int i = 0; i < select.tables.size(); i++) { 5025 TTable table = select.tables.getTable(i); 5026 if (table == null) continue; 5027 5028 // Skip certain table types 5029 if (table.getTableType() == ETableSource.join) continue; 5030 5031 String tablePrefix = table.getAliasName(); 5032 if (tablePrefix == null || tablePrefix.isEmpty()) { 5033 tablePrefix = table.getName(); 5034 } 5035 if (tablePrefix == null) continue; 5036 5037 // Get namespace for this table 5038 INamespace namespace = scopeBuildResult != null 5039 ? scopeBuildResult.getNamespaceForTable(table) 5040 : null; 5041 5042 if (namespace != null) { 5043 Set<String> inferredCols = namespace.getInferredColumns(); 5044 5045 if (inferredCols != null && !inferredCols.isEmpty()) { 5046 for (String colName : inferredCols) { 5047 // Skip columns in EXCEPT clause 5048 if (exceptColumns != null && !exceptColumns.isEmpty() && 5049 exceptColumns.contains(colName.toUpperCase())) { 5050 continue; 5051 } 5052 String attrName = tablePrefix + "." + colName; 5053 TAttributeNode.addNodeToList( 5054 new TAttributeNode(attrName, table), 5055 attrList 5056 ); 5057 count++; 5058 } 5059 } else { 5060 Map<String, ColumnSource> columnSources = namespace.getAllColumnSources(); 5061 if (columnSources != null && !columnSources.isEmpty()) { 5062 for (String colName : columnSources.keySet()) { 5063 // Skip columns in EXCEPT clause 5064 if (exceptColumns != null && !exceptColumns.isEmpty() && 5065 exceptColumns.contains(colName.toUpperCase())) { 5066 continue; 5067 } 5068 String attrName = tablePrefix + "." + colName; 5069 TAttributeNode.addNodeToList( 5070 new TAttributeNode(attrName, table), 5071 attrList 5072 ); 5073 count++; 5074 } 5075 } 5076 } 5077 } 5078 5079 // If no columns for this table, add the star as fallback 5080 if (count == 0 || (namespace != null && namespace.getInferredColumns().isEmpty() 5081 && namespace.getAllColumnSources().isEmpty())) { 5082 TAttributeNode.addNodeToList( 5083 new TAttributeNode(tablePrefix + ".*", table), 5084 attrList 5085 ); 5086 } 5087 } 5088 5089 return count; 5090 } 5091 5092 /** 5093 * Find a table by its prefix (alias or name) in a SELECT statement. 5094 */ 5095 private TTable findTableByPrefixInSelect(TSelectSqlStatement select, String prefix) { 5096 if (select == null || select.tables == null || prefix == null) return null; 5097 5098 // P0d.3a: canonical equality replaces the hand-rolled quote/backtick/bracket 5099 // stripper — the engine strips the vendor's own quote forms and folds by quote 5100 // state, so quoted-case distinctions survive where the vendor preserves them. 5101 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 5102 String trimmedPrefix = prefix.trim(); 5103 5104 for (int i = 0; i < select.tables.size(); i++) { 5105 TTable table = select.tables.getTable(i); 5106 if (table == null) continue; 5107 5108 // Check alias first 5109 String alias = table.getAliasName(); 5110 if (alias != null && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotTable, alias.trim(), trimmedPrefix)) { 5111 return table; 5112 } 5113 5114 // Check table name 5115 String name = table.getName(); 5116 if (name != null && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotTable, name.trim(), trimmedPrefix)) { 5117 return table; 5118 } 5119 5120 // Check full table name (with schema) 5121 if (table.getTableName() != null) { 5122 String fullName = table.getTableName().toString(); 5123 if (fullName != null && SQLUtil.compareIdentifier(dbVendor, ESQLDataObjectType.dotTable, fullName.trim(), trimmedPrefix)) { 5124 return table; 5125 } 5126 } 5127 } 5128 5129 return null; 5130 } 5131 5132 /** 5133 * Get resolution statistics 5134 */ 5135 public ResolutionStatistics getStatistics() { 5136 return resolutionContext.getStatistics(); 5137 } 5138 5139 /** 5140 * Get the resolution context (for advanced queries) 5141 */ 5142 public ResolutionContext getContext() { 5143 return resolutionContext; 5144 } 5145 5146 /** 5147 * Get the global scope 5148 */ 5149 public GlobalScope getGlobalScope() { 5150 return globalScope; 5151 } 5152 5153 /** 5154 * Get the configuration 5155 */ 5156 public TSQLResolverConfig getConfig() { 5157 return config; 5158 } 5159 5160 /** 5161 * Get the pass history (for iterative resolution analysis) 5162 * 5163 * @return list of all resolution passes (empty if non-iterative or not yet resolved) 5164 */ 5165 public List<ResolutionPass> getPassHistory() { 5166 return new ArrayList<>(passHistory); 5167 } 5168 5169 /** 5170 * Get the convergence detector (for iterative resolution analysis) 5171 * 5172 * @return convergence detector (null if iterative resolution is disabled) 5173 */ 5174 public ConvergenceDetector getConvergenceDetector() { 5175 return convergenceDetector; 5176 } 5177 5178 /** 5179 * Get the scope build result (for testing and analysis) 5180 * 5181 * @return scope build result from ScopeBuilder (null if not yet resolved) 5182 */ 5183 public ScopeBuildResult getScopeBuildResult() { 5184 return scopeBuildResult; 5185 } 5186 5187 /** 5188 * Return the diagnostic outcome of the most recent {@link #resolve()} call. 5189 * 5190 * @return an immutable non-null outcome 5191 */ 5192 public RunOutcome getRunOutcome() { 5193 return runOutcome; 5194 } 5195 5196 /** 5197 * Get the resolution result access interface. 5198 * This provides a clean, statement-centric API for accessing resolution results. 5199 * 5200 * <p>Usage example:</p> 5201 * <pre> 5202 * TSQLResolver2 resolver = new TSQLResolver2(null, parser.sqlstatements); 5203 * resolver.resolve(); 5204 * 5205 * IResolutionResult result = resolver.getResult(); 5206 * 5207 * for (TCustomSqlStatement stmt : parser.sqlstatements) { 5208 * for (TTable table : result.getTables(stmt)) { 5209 * System.out.println("Table: " + table.getFullName()); 5210 * for (TObjectName col : result.getColumnsForTable(stmt, table)) { 5211 * System.out.println(" Column: " + col.getColumnNameOnly()); 5212 * } 5213 * } 5214 * } 5215 * </pre> 5216 * 5217 * @return resolution result access interface 5218 * @throws IllegalStateException if resolve() has not been called 5219 */ 5220 public IResolutionResult getResult() { 5221 if (scopeBuildResult == null) { 5222 throw new IllegalStateException( 5223 "Must call resolve() before getResult()"); 5224 } 5225 return new ResolutionResultImpl(scopeBuildResult, sqlStatements); 5226 } 5227 5228 // ===== Binding Diagnostic API (plan §5.3, S2 stub / S5 wired) ===== 5229 5230 /** 5231 * Aggregate binding result populated by {@link BindingDiagnosticPostPass} 5232 * after iterative resolution converges (S5). Always non-null — including 5233 * when binding flags are off, when {@code resolve()} has not been called, 5234 * or when {@link gudusoft.gsqlparser.TGSqlParser#parse()} returned a 5235 * syntax error (plan §5.6, §12). 5236 * 5237 * @return the binding result; never null 5238 */ 5239 public BindingResult getBindingResult() { 5240 return bindingResult != null ? bindingResult : BindingResult.empty(); 5241 } 5242 5243 /** 5244 * Convenience accessor for {@link #getBindingResult()}{@code .getDiagnostics()}. 5245 * 5246 * @return the diagnostics list; never null 5247 */ 5248 public List<BindingDiagnostic> getBindingDiagnostics() { 5249 return getBindingResult().getDiagnostics(); 5250 } 5251 5252 /** 5253 * Convenience accessor for {@link #getBindingResult()}{@code .hasErrors()}. 5254 * 5255 * @return whether any ERROR-severity binding diagnostic was emitted 5256 */ 5257 public boolean hasBindingErrors() { 5258 return getBindingResult().hasErrors(); 5259 } 5260 5261 // ===== Star Column Reverse Inference Support (Principle 3) ===== 5262 5263 /** 5264 * Star Column push-down context for reverse inference. 5265 * Tracks which columns should be added to which Namespaces based on 5266 * outer layer references. 5267 */ 5268 private static class StarPushDownContext { 5269 /** Namespace -> (ColumnName -> Confidence) */ 5270 private final Map<INamespace, Map<String, Double>> pushDownMap = new HashMap<>(); 5271 5272 /** 5273 * Record that a column should be added to a namespace. 5274 * If the same column is pushed multiple times, keep the highest confidence. 5275 */ 5276 public void pushColumn(INamespace namespace, String columnName, double confidence) { 5277 Map<String, Double> columns = pushDownMap.computeIfAbsent(namespace, k -> new HashMap<>()); 5278 columns.put(columnName, Math.max(confidence, columns.getOrDefault(columnName, 0.0))); 5279 } 5280 5281 /** 5282 * Get all columns that should be pushed to each namespace. 5283 */ 5284 public Map<INamespace, java.util.Set<String>> getAllPushDownColumns() { 5285 Map<INamespace, java.util.Set<String>> result = new HashMap<>(); 5286 for (Map.Entry<INamespace, Map<String, Double>> entry : pushDownMap.entrySet()) { 5287 result.put(entry.getKey(), entry.getValue().keySet()); 5288 } 5289 return result; 5290 } 5291 5292 /** 5293 * Get the confidence score for a specific column in a namespace. 5294 */ 5295 public double getConfidence(INamespace namespace, String columnName) { 5296 return pushDownMap.getOrDefault(namespace, java.util.Collections.emptyMap()) 5297 .getOrDefault(columnName, 0.0); 5298 } 5299 5300 /** 5301 * Get the total number of columns to be pushed down across all namespaces. 5302 */ 5303 public int getTotalPushedColumns() { 5304 return pushDownMap.values().stream() 5305 .mapToInt(Map::size) 5306 .sum(); 5307 } 5308 } 5309 5310 /** 5311 * Represents a star column source (CTE or subquery with SELECT *). 5312 * Used for reverse inference to track which columns are required from the star. 5313 */ 5314 private static class StarColumnSource { 5315 private final String name; // CTE name or subquery alias 5316 private final INamespace namespace; // The namespace for this source 5317 private final INamespace underlyingTableNamespace; // Namespace of the table behind SELECT * 5318 private final java.util.Set<String> requiredColumns = new java.util.HashSet<>(); 5319 5320 public StarColumnSource(String name, INamespace namespace, INamespace underlyingTableNamespace) { 5321 this.name = name; 5322 this.namespace = namespace; 5323 this.underlyingTableNamespace = underlyingTableNamespace; 5324 } 5325 5326 public String getName() { 5327 return name; 5328 } 5329 5330 public INamespace getNamespace() { 5331 return namespace; 5332 } 5333 5334 public void addRequiredColumn(String columnName) { 5335 requiredColumns.add(columnName); 5336 } 5337 5338 public java.util.Set<String> getRequiredColumns() { 5339 return requiredColumns; 5340 } 5341 5342 public boolean hasUnderlyingTable() { 5343 return underlyingTableNamespace != null; 5344 } 5345 5346 public INamespace getUnderlyingTableNamespace() { 5347 return underlyingTableNamespace; 5348 } 5349 5350 @Override 5351 public String toString() { 5352 return String.format("StarColumnSource[%s, required=%d]", name, requiredColumns.size()); 5353 } 5354 } 5355 5356 /** 5357 * Collect all star column sources (CTEs and subqueries with SELECT *). 5358 * Traverses the scope tree to find CTENamespace and SubqueryNamespace 5359 * that use SELECT * in their subqueries. 5360 */ 5361 private List<StarColumnSource> collectAllStarColumnSources() { 5362 List<StarColumnSource> sources = new ArrayList<>(); 5363 5364 // Traverse global scope tree 5365 if (globalScope != null) { 5366 collectStarSourcesFromScope(globalScope, sources); 5367 } 5368 5369 // Also traverse UPDATE scopes (for Teradata UPDATE...FROM syntax) 5370 if (scopeBuilder != null) { 5371 for (UpdateScope updateScope : scopeBuilder.getUpdateScopeMap().values()) { 5372 collectStarSourcesFromScope(updateScope, sources); 5373 } 5374 for (DeleteScope deleteScope : scopeBuilder.getDeleteScopeMap().values()) { 5375 collectStarSourcesFromScope(deleteScope, sources); 5376 } 5377 } 5378 5379 logDebug("Collected " + sources.size() + " star column sources"); 5380 return sources; 5381 } 5382 5383 /** 5384 * Recursively collect star column sources from a scope and its children. 5385 */ 5386 private void collectStarSourcesFromScope(IScope scope, List<StarColumnSource> sources) { 5387 // Check all child namespaces in this scope 5388 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : scope.getChildren()) { 5389 INamespace namespace = child.getNamespace(); 5390 5391 // Use the new interface method to check for star columns 5392 if (namespace.hasStarColumn()) { 5393 TSelectSqlStatement selectStmt = namespace.getSelectStatement(); 5394 INamespace underlyingNs = selectStmt != null ? getFirstTableNamespace(selectStmt) : null; 5395 5396 StarColumnSource starSource = new StarColumnSource( 5397 namespace.getDisplayName(), 5398 namespace, 5399 underlyingNs 5400 ); 5401 sources.add(starSource); 5402 5403 logDebug("Found star source: " + namespace.getDisplayName()); 5404 } 5405 } 5406 5407 // Recursively traverse child scopes based on scope type 5408 if (scope instanceof SelectScope) { 5409 SelectScope selectScope = (SelectScope) scope; 5410 if (selectScope.getFromScope() != null) { 5411 collectStarSourcesFromScope(selectScope.getFromScope(), sources); 5412 } 5413 } else if (scope instanceof UpdateScope) { 5414 UpdateScope updateScope = (UpdateScope) scope; 5415 if (updateScope.getFromScope() != null) { 5416 collectStarSourcesFromScope(updateScope.getFromScope(), sources); 5417 } 5418 } else if (scope instanceof DeleteScope) { 5419 DeleteScope deleteScope = (DeleteScope) scope; 5420 if (deleteScope.getFromScope() != null) { 5421 collectStarSourcesFromScope(deleteScope.getFromScope(), sources); 5422 } 5423 } 5424 } 5425 5426 5427 /** 5428 * Get the first table namespace from a SELECT statement's FROM clause. 5429 * Returns the DynamicStarSource if available. 5430 */ 5431 private INamespace getFirstTableNamespace(TSelectSqlStatement select) { 5432 if (select == null || select.tables == null || select.tables.size() == 0) { 5433 return null; 5434 } 5435 5436 // Get first table 5437 TTable firstTable = select.tables.getTable(0); 5438 String tableName = firstTable.getAliasName() != null 5439 ? firstTable.getAliasName() 5440 : firstTable.getName(); 5441 5442 // Search for corresponding namespace in all dynamic namespaces 5443 List<INamespace> dynamicNamespaces = getAllDynamicNamespaces(); 5444 for (INamespace ns : dynamicNamespaces) { 5445 if (ns.getDisplayName().equals(tableName)) { 5446 return ns; 5447 } 5448 } 5449 5450 return null; 5451 } 5452 5453 /** 5454 * Collect all outer references to a star column source. 5455 * Searches through allColumnReferences for columns that reference this star source. 5456 */ 5457 private List<TObjectName> collectOuterReferencesToSource(StarColumnSource starSource) { 5458 List<TObjectName> references = new ArrayList<>(); 5459 5460 if (starSource == null || starSource.getName() == null) { 5461 return references; 5462 } 5463 5464 String sourceName = starSource.getName(); 5465 EDbVendor dbVendor = sqlStatements.get(0).dbvendor; 5466 5467 // Search through all collected column references 5468 for (TObjectName objName : allColumnReferences) { 5469 if (objName == null) { 5470 continue; 5471 } 5472 5473 // Check if this column reference is from the star source 5474 // E.g., for CTE named "my_cte", check if objName is like "my_cte.col1" 5475 String tableQualifier = getTableQualifier(objName); 5476 5477 if (tableQualifier != null && SQLUtil.compareIdentifier(dbVendor, ESQLDataObjectType.dotTable, tableQualifier, sourceName)) { 5478 references.add(objName); 5479 logDebug("Found outer reference: " + objName + " -> " + sourceName); 5480 } 5481 } 5482 5483 logDebug("Collected " + references.size() + " outer references for: " + sourceName); 5484 return references; 5485 } 5486 5487 /** 5488 * Get the table qualifier from a TObjectName. 5489 * E.g., for "schema.table.column", returns "table" 5490 * E.g., for "table.column", returns "table" 5491 * E.g., for "column", returns null 5492 */ 5493 private String getTableQualifier(TObjectName objName) { 5494 if (objName == null) { 5495 return null; 5496 } 5497 5498 // TObjectName has parts like: [schema, table, column] 5499 // or [table, column] 5500 // or [column] 5501 5502 // If there are 3 or more parts, the second-to-last is the table 5503 // If there are 2 parts, the first is the table 5504 // If there is 1 part, there's no table qualifier 5505 5506 String fullName = objName.toString(); 5507 String[] parts = fullName.split("\\."); 5508 5509 if (parts.length >= 3) { 5510 // schema.table.column -> return table 5511 return parts[parts.length - 2]; 5512 } else if (parts.length == 2) { 5513 // table.column -> return table 5514 return parts[0]; 5515 } else { 5516 // Just column name, no qualifier 5517 return null; 5518 } 5519 } 5520 5521 /** 5522 * Get all DynamicStarSource namespaces from the scope tree. 5523 * This is used to apply inference results to namespaces that need enhancement. 5524 */ 5525 private List<INamespace> getAllDynamicNamespaces() { 5526 List<INamespace> result = new ArrayList<>(); 5527 5528 // Collect from global scope tree 5529 if (globalScope != null) { 5530 collectDynamicNamespacesFromScope(globalScope, result); 5531 } 5532 5533 return result; 5534 } 5535 5536 /** 5537 * Recursively collect DynamicStarSource namespaces from a scope and its children. 5538 */ 5539 private void collectDynamicNamespacesFromScope(IScope scope, List<INamespace> result) { 5540 if (scope == null) { 5541 return; 5542 } 5543 5544 // Get all child namespaces from this scope 5545 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : scope.getChildren()) { 5546 INamespace namespace = child.getNamespace(); 5547 if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.DynamicStarSource) { 5548 result.add(namespace); 5549 logDebug("Found DynamicStarSource: " + namespace.getDisplayName()); 5550 } 5551 } 5552 5553 // Recursively traverse child scopes based on scope type 5554 if (scope instanceof SelectScope) { 5555 SelectScope selectScope = (SelectScope) scope; 5556 5557 // Traverse FROM scope 5558 if (selectScope.getFromScope() != null) { 5559 collectDynamicNamespacesFromScope(selectScope.getFromScope(), result); 5560 } 5561 } else if (scope instanceof CTEScope) { 5562 CTEScope cteScope = (CTEScope) scope; 5563 5564 // CTEs are already included in the children check above 5565 // But we need to check their subqueries by traversing nested scopes 5566 // The CTE namespaces themselves contain references to subquery scopes 5567 } else if (scope instanceof FromScope) { 5568 FromScope fromScope = (FromScope) scope; 5569 5570 // FROM scope children are already checked above 5571 // No additional child scopes to traverse 5572 } else if (scope instanceof GroupByScope) { 5573 GroupByScope groupByScope = (GroupByScope) scope; 5574 5575 // GroupBy scope typically doesn't have child scopes 5576 } else if (scope instanceof HavingScope) { 5577 HavingScope havingScope = (HavingScope) scope; 5578 5579 // Having scope typically doesn't have child scopes 5580 } else if (scope instanceof OrderByScope) { 5581 OrderByScope orderByScope = (OrderByScope) scope; 5582 5583 // OrderBy scope typically doesn't have child scopes 5584 } 5585 5586 // Additionally, traverse parent-child scope relationships 5587 // by checking if any of the namespaces contain nested SELECT statements 5588 for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : scope.getChildren()) { 5589 INamespace namespace = child.getNamespace(); 5590 5591 // If this is a SubqueryNamespace, it contains a SELECT with its own scope tree 5592 if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) { 5593 // Subquery scopes are processed during scope building 5594 // and would be in statementScopeCache if we tracked them 5595 } 5596 } 5597 } 5598 5599 // ===== Logging helpers ===== 5600 5601 private void logInfo(String message) { 5602 TBaseType.log("[TSQLResolver2] " + message, TLog.INFO); 5603 } 5604 5605 private void logDebug(String message) { 5606 TBaseType.log("[TSQLResolver2] " + message, TLog.DEBUG); 5607 } 5608 5609 private void logError(String message) { 5610 TBaseType.log("[TSQLResolver2] " + message, TLog.ERROR); 5611 } 5612}