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