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