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