001package gudusoft.gsqlparser.resolver2;
002
003import gudusoft.gsqlparser.*;
004import gudusoft.gsqlparser.compiler.TContext;
005import gudusoft.gsqlparser.nodes.*;
006import gudusoft.gsqlparser.resolver2.matcher.DefaultNameMatcher;
007import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
008import gudusoft.gsqlparser.resolver2.namespace.CTENamespace;
009import gudusoft.gsqlparser.resolver2.namespace.INamespace;
010import gudusoft.gsqlparser.resolver2.namespace.PivotNamespace;
011import gudusoft.gsqlparser.resolver2.namespace.PlsqlVariableNamespace;
012import gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace;
013import gudusoft.gsqlparser.resolver2.namespace.TableNamespace;
014import gudusoft.gsqlparser.resolver2.namespace.UnionNamespace;
015import gudusoft.gsqlparser.resolver2.namespace.UnnestNamespace;
016import gudusoft.gsqlparser.resolver2.namespace.ValuesNamespace;
017import gudusoft.gsqlparser.resolver2.scope.*;
018import gudusoft.gsqlparser.resolver2.model.ScopeChild;
019import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
020import gudusoft.gsqlparser.sqlenv.TSQLEnv;
021import gudusoft.gsqlparser.stmt.TCommonBlock;
022import gudusoft.gsqlparser.util.OraclePseudoColumnUtil;
023import gudusoft.gsqlparser.util.SQLUtil;
024import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement;
025import gudusoft.gsqlparser.stmt.TCreateTriggerStmt;
026import gudusoft.gsqlparser.stmt.TAlterTableStatement;
027import gudusoft.gsqlparser.stmt.TCreateIndexSqlStatement;
028import gudusoft.gsqlparser.stmt.TDeleteSqlStatement;
029import gudusoft.gsqlparser.stmt.TDropIndexSqlStatement;
030import gudusoft.gsqlparser.stmt.TInsertSqlStatement;
031import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
032import gudusoft.gsqlparser.stmt.TUpdateSqlStatement;
033import gudusoft.gsqlparser.stmt.TMergeSqlStatement;
034import gudusoft.gsqlparser.stmt.TExecImmeStmt;
035import gudusoft.gsqlparser.stmt.TVarDeclStmt;
036import gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateProcedure;
037import gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateFunction;
038import gudusoft.gsqlparser.stmt.oracle.TPlsqlCreateTrigger;
039import gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage;
040import gudusoft.gsqlparser.resolver2.namespace.OraclePackageNamespace;
041import gudusoft.gsqlparser.stmt.TCreateProcedureStmt;
042import gudusoft.gsqlparser.stmt.TCreateFunctionStmt;
043import gudusoft.gsqlparser.stmt.TBlockSqlStatement;
044import gudusoft.gsqlparser.stmt.mysql.TMySQLCreateProcedure;
045import gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare;
046import gudusoft.gsqlparser.stmt.mssql.TMssqlReturn;
047import gudusoft.gsqlparser.stmt.db2.TDb2SqlVariableDeclaration;
048import gudusoft.gsqlparser.stmt.db2.TDb2DeclareCursorStatement;
049import gudusoft.gsqlparser.stmt.db2.TDb2CreateFunction;
050import gudusoft.gsqlparser.stmt.db2.TDb2ReturnStmt;
051import gudusoft.gsqlparser.stmt.TForStmt;
052import gudusoft.gsqlparser.nodes.teradata.TTDUnpivot;
053import gudusoft.gsqlparser.stmt.TLoopStmt;
054import gudusoft.gsqlparser.stmt.TCursorDeclStmt;
055import gudusoft.gsqlparser.stmt.TOpenforStmt;
056import gudusoft.gsqlparser.stmt.snowflake.TCreateFileFormatStmt;
057import gudusoft.gsqlparser.stmt.snowflake.TCreateStageStmt;
058import gudusoft.gsqlparser.stmt.snowflake.TCreatePipeStmt;
059import gudusoft.gsqlparser.nodes.TDeclareVariable;
060import gudusoft.gsqlparser.nodes.mssql.TMssqlCreateTriggerUpdateColumn;
061import gudusoft.gsqlparser.util.TBuiltFunctionUtil;
062import gudusoft.gsqlparser.util.TNiladicFunctionUtil;
063
064import java.util.*;
065
066/**
067 * Builds a complete scope tree using the Visitor pattern.
068 *
069 * <p>This class traverses the AST and creates a properly nested scope tree
070 * that reflects the SQL structure. All SELECT statements (including subqueries
071 * and CTEs) get their own SelectScope with correct parent-child relationships.
072 *
073 * <p>Key features:
074 * <ul>
075 *   <li>Handles nested subqueries with correct parent scope</li>
076 *   <li>Handles CTEs with forward reference support</li>
077 *   <li>Collects all column references with their scope mappings</li>
078 *   <li>Supports complex SQL patterns (CTE + subquery combinations)</li>
079 * </ul>
080 *
081 * <p>Usage:
082 * <pre>
083 * ScopeBuilder builder = new ScopeBuilder(context, nameMatcher);
084 * ScopeBuildResult result = builder.build(statements);
085 * </pre>
086 */
087public class ScopeBuilder extends TParseTreeVisitor {
088
089    // ========== Configuration ==========
090
091    /** Global context from parser */
092    private final TContext globalContext;
093
094    /** Name matcher for case sensitivity */
095    private final INameMatcher nameMatcher;
096
097    /** SQL environment for table metadata lookup */
098    private TSQLEnv sqlEnv;
099
100    /** Database vendor */
101    private EDbVendor dbVendor = EDbVendor.dbvoracle;
102
103    /**
104     * Strategy for handling ambiguous columns.
105     * -1 means use global TBaseType.GUESS_COLUMN_STRATEGY.
106     * This value is passed to namespaces for config-based isolation.
107     */
108    private int guessColumnStrategy = -1;
109
110    // ========== Scope Stack ==========
111
112    /** Scope stack - tracks current scope during traversal */
113    private final Stack<IScope> scopeStack = new Stack<>();
114
115    /** Global scope - root of scope tree */
116    private GlobalScope globalScope;
117
118    // ========== Result Collection ==========
119
120    /** Column reference -> Scope mapping */
121    private final Map<TObjectName, IScope> columnToScopeMap = new LinkedHashMap<>();
122
123    /** All column references in traversal order */
124    private final List<TObjectName> allColumnReferences = new ArrayList<>();
125
126    /** Statement -> SelectScope mapping */
127    private final Map<TSelectSqlStatement, SelectScope> statementScopeMap = new LinkedHashMap<>();
128
129    /** Statement -> UpdateScope mapping */
130    private final Map<TUpdateSqlStatement, UpdateScope> updateScopeMap = new LinkedHashMap<>();
131
132    /** Statement -> MergeScope mapping */
133    private final Map<TMergeSqlStatement, MergeScope> mergeScopeMap = new LinkedHashMap<>();
134
135    /** Statement -> DeleteScope mapping */
136    private final Map<TDeleteSqlStatement, DeleteScope> deleteScopeMap = new LinkedHashMap<>();
137
138    // ========== Current State ==========
139
140    /** Current SelectScope being built */
141    private SelectScope currentSelectScope;
142
143    /** Current UpdateScope being built */
144    private UpdateScope currentUpdateScope;
145
146    /** Current MergeScope being built */
147    private MergeScope currentMergeScope;
148
149    /** Current DeleteScope being built */
150    private DeleteScope currentDeleteScope;
151
152    /** Current FromScope being built */
153    private FromScope currentFromScope;
154
155    /** Stack to save/restore FromScope when processing nested subqueries */
156    private final Stack<FromScope> fromScopeStack = new Stack<>();
157
158    /** Current CTEScope being built */
159    private CTEScope currentCTEScope;
160
161    /** Depth counter for CTE definition processing.
162     *  Incremented in preVisit(TCTE), decremented in postVisit(TCTE).
163     *  When > 0, we're inside a CTE body, so CTAS target column handling should be skipped. */
164    private int cteDefinitionDepth = 0;
165
166    /** Set of TObjectName nodes that are table references (not column references) */
167    private final Set<TObjectName> tableNameReferences = new HashSet<>();
168
169    /** Set of TObjectName nodes that are SQL Server proprietary column aliases (not column references) */
170    private final Set<TObjectName> sqlServerProprietaryAliases = new HashSet<>();
171
172    /** Set of TObjectName nodes that are tuple alias columns (CTAS output columns, not references) */
173    private final Set<TObjectName> tupleAliasColumns = new HashSet<>();
174
175    /** Set of TObjectName nodes that are CTAS target columns (columns created by CREATE TABLE AS SELECT).
176     *  These include standard alias columns and simple column reference columns.
177     *  They are column DEFINITIONS that create new output columns in the target table, NOT column references.
178     *  They should NOT be re-resolved through name resolution. */
179    private final Set<TObjectName> ctasTargetColumns = new HashSet<>();
180
181    /** Set of TObjectName nodes that are VALUES table alias column definitions.
182     *  These are column NAME definitions in VALUES table alias clauses like:
183     *  VALUES (1, 'a') AS t(id, name) - 'id' and 'name' are definitions, not references.
184     *  They should NOT be collected as column references. */
185    private final Set<TObjectName> valuesTableAliasColumns = new HashSet<>();
186
187    /** Set of TObjectName nodes that are result column alias names (not column references).
188     *  These include standard AS aliases and Teradata NAMED aliases like "COUNT(1)(NAMED CNT_LOGIN)". */
189    private final Set<TObjectName> resultColumnAliasNames = new HashSet<>();
190
191    /** Set of TObjectName nodes that are SET clause target columns (UPDATE SET left-side columns).
192     *  These columns already have sourceTable correctly set to the UPDATE target table
193     *  and should NOT be re-resolved through star column push-down. */
194    private final Set<TObjectName> setClauseTargetColumns = new HashSet<>();
195
196    /** Set of TObjectName nodes that are INSERT ALL target columns (from TInsertIntoValue columnList).
197     *  These columns already have sourceTable correctly set to the INSERT target table
198     *  and should NOT be re-resolved against the subquery scope. */
199    private final Set<TObjectName> insertAllTargetColumns = new HashSet<>();
200
201    /** Map of MERGE INSERT VALUES columns to their USING (source) table.
202     *  These columns have sourceTable set to the USING table in preVisit(TMergeInsertClause).
203     *  After name resolution (needed for star column push-down when USING is a subquery),
204     *  their sourceTable must be restored to the USING table to ensure correct data lineage. */
205    private final Map<TObjectName, TTable> mergeInsertValuesColumns = new java.util.IdentityHashMap<>();
206
207    /** Set of unqualified MERGE INSERT VALUES columns that are PROVABLY not projected by the
208     *  USING source (the source is a derived table whose full projection list is visible in the
209     *  AST and does not contain the name).
210     *
211     *  In "WHEN NOT MATCHED THEN INSERT ... VALUES (...)" the only relation in scope is the USING
212     *  source — the target row does not exist yet — so such an identifier is genuinely undefined
213     *  (a host/bind variable, a typo, ...). It must stay unresolved rather than be linked to the
214     *  USING table, which would tell consumers "this is a real column of that table" when it
215     *  demonstrably is not. Collected in preVisit(TMergeInsertClause); preVisit(TObjectName) then
216     *  keeps them in allColumnReferences but out of columnToScopeMap, which is what stops every
217     *  later resolution pass from binding them. */
218    private final Set<TObjectName> mergeInsertValuesUnboundColumns =
219            java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<TObjectName, Boolean>());
220
221    /** Set of TObjectName nodes that are function keyword arguments (e.g., SECOND in TIMESTAMP_DIFF).
222     *  These are marked during TFunctionCall traversal so they can be skipped during column collection. */
223    private final Set<TObjectName> functionKeywordArguments = new HashSet<>();
224
225    /** Set of TObjectName nodes that are named argument parameter names (e.g., INPUT in "INPUT => value").
226     *  These are marked during TExpression traversal and should NOT be treated as column references.
227     *  Named arguments use the "=>" syntax (e.g., Snowflake FLATTEN: "INPUT => parse_json(col), outer => TRUE"). */
228    private final Set<TObjectName> namedArgumentParameters = new HashSet<>();
229
230    /** Set of TObjectName nodes that are PIVOT IN clause items.
231     *  These define pivot column names and should NOT be resolved as column references to the source table.
232     *  Their sourceTable is already correctly set to the pivot table by addPivotInClauseColumns(). */
233    private final Set<TObjectName> pivotInClauseColumns = new HashSet<>();
234
235    /** Set of TObjectName nodes that are UNPIVOT definition columns (value and FOR columns).
236     *  These are column DEFINITIONS that create new output columns, NOT column references.
237     *  Example: UNPIVOT (yearly_total FOR order_mode IN (...))
238     *  - yearly_total is a value column definition
239     *  - order_mode is a FOR column definition
240     *  Both should NOT be collected as column references. */
241    private final Set<TObjectName> unpivotDefinitionColumns = new HashSet<>();
242
243    /** Variable declaration names (from DECLARE statements) - these are NOT column references */
244    private final Set<TObjectName> variableDeclarationNames = new HashSet<>();
245
246    /** Lambda parameter names - these are NOT column references but local function parameters
247     *  e.g., in "transform(array, x -> x + 1)", x is a lambda parameter, not a table column */
248    private final Set<TObjectName> lambdaParameters = new HashSet<>();
249
250    /** DDL target object names - these are NOT column references but object names in DDL statements.
251     *  Examples: file format name in CREATE FILE FORMAT, stage name in CREATE STAGE, pipe name in CREATE PIPE.
252     *  These should be skipped during column collection. */
253    private final Set<TObjectName> ddlTargetNames = new HashSet<>();
254
255    /** Stack of lambda parameter name sets - used to track current lambda context during traversal.
256     *  When inside a lambda expression, the parameter names are pushed onto this stack.
257     *  This allows checking if a TObjectName is a lambda parameter without walking up the AST. */
258    private final Stack<Set<String>> lambdaParameterStack = new Stack<>();
259
260    /** Map of TSelectSqlStatement -> Set of lateral column alias names defined in its SELECT list.
261     *  Used to detect lateral column aliases (Snowflake, BigQuery) where aliases defined earlier
262     *  in the SELECT list can be referenced by later columns. */
263    private final Map<TSelectSqlStatement, Set<String>> selectLateralAliases = new LinkedHashMap<>();
264
265    /** The alias of the current result column being processed (normalized, lowercase).
266     *  Used to exclude the current result column's own alias from lateral alias matching,
267     *  since a column reference inside the expression that DEFINES an alias cannot be
268     *  a reference TO that alias - it must be a reference to the source table column.
269     *  e.g., in "CASE WHEN Model_ID = '' THEN NULL ELSE TRIM(Model_ID) END AS Model_ID",
270     *  the Model_ID references inside CASE are source table columns, not lateral alias references. */
271    private String currentResultColumnAlias = null;
272
273    /** Flag indicating if we're currently inside a result column context.
274     *  Lateral alias matching should ONLY apply inside result columns (SELECT list),
275     *  NOT in FROM clause, WHERE clause, etc. Column references in those places
276     *  are source table columns, not lateral alias references. */
277    private boolean inResultColumnContext = false;
278
279    /** Map of TTable to its SubqueryNamespace for deferred validation */
280    private final Map<TTable, SubqueryNamespace> pendingSubqueryValidation = new LinkedHashMap<>();
281
282    /** Map of TTable to its INamespace - used for legacy compatibility to fill TTable.getAttributes() */
283    private final Map<TTable, INamespace> tableToNamespaceMap = new LinkedHashMap<>();
284
285    /** Map of USING column -> right-side TTable for JOIN...USING resolution priority */
286    private final Map<TObjectName, TTable> usingColumnToRightTable = new LinkedHashMap<>();
287
288    /** Map of USING column -> left-side TTable for JOIN...USING (both tables should be reported) */
289    private final Map<TObjectName, TTable> usingColumnToLeftTable = new LinkedHashMap<>();
290
291    /** Current right-side table of a JOIN...USING clause being processed */
292    private TTable currentUsingJoinRightTable = null;
293
294    /** Current trigger target table (for SQL Server deleted/inserted virtual table resolution) */
295    private TTable currentTriggerTargetTable = null;
296
297    /** Current PL/SQL block scope being built */
298    private PlsqlBlockScope currentPlsqlBlockScope = null;
299
300    /** Stack of PL/SQL block scopes for nested blocks */
301    private final Stack<PlsqlBlockScope> plsqlBlockScopeStack = new Stack<>();
302
303    /** Stack to save/restore trigger target table for nested triggers */
304    private final Stack<TTable> triggerTargetTableStack = new Stack<>();
305
306    /** Set of TTable objects that are virtual trigger tables (deleted/inserted) that should be skipped in output */
307    private final Set<TTable> virtualTriggerTables = new HashSet<>();
308
309    /** Set of TFunctionCall objects that are table-valued functions (from FROM clause).
310     *  These should NOT be treated as column method calls in preVisit(TFunctionCall).
311     *  For example, [exce].[sampleTable]() is a table function, not column.method(). */
312    private final Set<TFunctionCall> tableValuedFunctionCalls = new HashSet<>();
313
314    /** Last table processed in the current FROM clause - used to find left side of JOIN...USING */
315    private TTable lastProcessedFromTable = null;
316
317    /** All tables processed so far in the current FROM clause - used for chained USING joins.
318     *  In a query like "t1 JOIN t2 USING (c1) JOIN t3 USING (c2)", when processing USING (c2),
319     *  both t1 and t2 should be considered as left-side tables, not just t2.
320     *  This list is reset when entering a new FROM clause. */
321    private List<TTable> currentJoinChainTables = new ArrayList<>();
322
323    /** Current CTAS target table - set when processing CREATE TABLE AS SELECT */
324    private TTable currentCTASTargetTable = null;
325
326    /** The main SELECT scope for CTAS - only result columns at this level become CTAS target columns.
327     *  This prevents subquery result columns from being incorrectly registered as CTAS target columns. */
328    private SelectScope ctasMainSelectScope = null;
329
330    /** Current UPDATE target table - set when processing UPDATE statement */
331    private TTable currentUpdateTargetTable = null;
332    /** @see #preVisit(TUpdateSqlStatement) — gates direct trace certification. */
333    private boolean currentUpdateHasSingleTable = true;
334
335    /** Current MERGE target table - set when processing MERGE statement */
336    private TTable currentMergeTargetTable = null;
337
338    /** Current INSERT target table - set when processing INSERT statement */
339    private TTable currentInsertTargetTable = null;
340
341    /** Current DELETE target table - set when processing DELETE statement */
342    private TTable currentDeleteTargetTable = null;
343
344    /** All CTAS target tables collected during scope building */
345    private Set<TTable> allCtasTargetTables = new HashSet<>();
346
347    /** Flag to track when we're inside EXECUTE IMMEDIATE dynamic string expression */
348    private boolean insideExecuteImmediateDynamicExpr = false;
349
350    /** Counter to track when we're processing PIVOT/UNPIVOT source relations.
351     *  When > 0, subqueries should NOT be added to FromScope because they are
352     *  source tables for PIVOT/UNPIVOT, not directly visible in the outer query.
353     *  PIVOT/UNPIVOT transforms the source data and only the PIVOT/UNPIVOT table
354     *  should be visible, not the underlying source subquery. */
355    private int pivotSourceProcessingDepth = 0;
356
357    /** Set of cursor FOR loop record names (e.g., "rec" in "for rec in (SELECT ...)") */
358    private final Set<String> cursorForLoopRecordNames = new HashSet<>();
359
360    // ========== Oracle Package Support ==========
361
362    /** Registry of Oracle packages for cross-package resolution */
363    private OraclePackageRegistry packageRegistry;
364
365    /** Current package scope (when inside package body) */
366    private OraclePackageScope currentPackageScope = null;
367
368    /** Stack of package scopes for nested package handling */
369    private final Stack<OraclePackageScope> packageScopeStack = new Stack<>();
370
371    // ========== Cursor Variable Tracking ==========
372
373    /** Set of known cursor variable names (lowercase) in current scope chain */
374    private final Set<String> cursorVariableNames = new HashSet<>();
375
376    // ========== Constructor ==========
377
378    /**
379     * Binding-trace capture sink (dynamic-SQL publication proof, R4 step 3);
380     * {@code null} unless opted in. Direct {@code setSourceTable} bindings
381     * bypass NameResolver entirely, so the typed clusters below register their
382     * traces here. Uninstrumented direct sites simply produce NO trace, which
383     * the proof treats as unprovable — fail-closed, never wrong.
384     */
385    private gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry bindingTraceRegistry;
386
387    public void setBindingTraceRegistry(
388            gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry registry) {
389        this.bindingTraceRegistry = registry;
390    }
391
392    /** Register a typed direct-binding trace (observation-only; null-safe). */
393    private void traceDirectBinding(TObjectName reference,
394            gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind kind, TTable target) {
395        if (bindingTraceRegistry != null) {
396            bindingTraceRegistry.register(gudusoft.gsqlparser.resolver2.binding.BindingTrace
397                    .direct(reference, kind, target));
398        }
399    }
400
401    public ScopeBuilder(TContext globalContext, INameMatcher nameMatcher) {
402        this.globalContext = globalContext;
403        this.nameMatcher = nameMatcher != null ? nameMatcher : new DefaultNameMatcher();
404        // Get TSQLEnv from global context if available
405        if (globalContext != null) {
406            this.sqlEnv = globalContext.getSqlEnv();
407        }
408    }
409
410    public ScopeBuilder(TContext globalContext) {
411        this(globalContext, new DefaultNameMatcher());
412    }
413
414    /**
415     * Set the TSQLEnv for table metadata lookup.
416     * This can be used to override the TSQLEnv from globalContext.
417     *
418     * @param sqlEnv the SQL environment containing table metadata
419     */
420    public void setSqlEnv(TSQLEnv sqlEnv) {
421        this.sqlEnv = sqlEnv;
422    }
423
424    /**
425     * Get the TSQLEnv used for table metadata lookup.
426     *
427     * @return the SQL environment, or null if not set
428     */
429    public TSQLEnv getSqlEnv() {
430        return sqlEnv;
431    }
432
433    /**
434     * Set the strategy for handling ambiguous columns.
435     * This value will be passed to namespaces for config-based isolation.
436     *
437     * @param strategy One of TBaseType.GUESS_COLUMN_STRATEGY_* constants, or -1 to use global default
438     */
439    public void setGuessColumnStrategy(int strategy) {
440        this.guessColumnStrategy = strategy;
441    }
442
443    /**
444     * Get the strategy for handling ambiguous columns.
445     *
446     * @return The strategy constant, or -1 if not set (use global default)
447     */
448    public int getGuessColumnStrategy() {
449        return guessColumnStrategy;
450    }
451
452    // ========== Main Entry Point ==========
453
454    /**
455     * Build scope tree for the given SQL statements.
456     *
457     * @param statements SQL statements to process
458     * @return Build result containing scope tree and column mappings
459     */
460    public ScopeBuildResult build(TStatementList statements) {
461        // Initialize
462        reset();
463
464        // Create global scope
465        globalScope = new GlobalScope(globalContext, nameMatcher);
466        scopeStack.push(globalScope);
467
468        // Detect database vendor
469        if (statements != null && statements.size() > 0) {
470            dbVendor = statements.get(0).dbvendor;
471        }
472
473        // Pre-traversal: Build package registry for Oracle
474        if (dbVendor == EDbVendor.dbvoracle && statements != null) {
475            packageRegistry = new OraclePackageRegistry();
476            packageRegistry.setDebug(DEBUG_SCOPE_BUILD);
477            packageRegistry.buildFromStatements(statements);
478            if (DEBUG_SCOPE_BUILD && packageRegistry.size() > 0) {
479                System.out.println("[DEBUG] Built package registry with " +
480                    packageRegistry.size() + " packages");
481            }
482        }
483
484        // Process each statement
485        if (statements != null) {
486            for (int i = 0; i < statements.size(); i++) {
487                Object stmt = statements.get(i);
488                if (DEBUG_SCOPE_BUILD) {
489                    System.out.println("[DEBUG] build(): Processing statement " + i + " of type " + stmt.getClass().getName());
490                }
491                if (stmt instanceof TParseTreeNode) {
492                    // For certain statement types, we need to call accept() to trigger preVisit()
493                    // - TCommonBlock: to set up the PL/SQL block scope
494                    // - TDb2CreateFunction: to set up the function scope and handle parameters/variables
495                    // - TPlsqlCreatePackage: to set up the package scope for package body
496                    // For other statements, use acceptChildren() to maintain original behavior
497                    if (stmt instanceof gudusoft.gsqlparser.stmt.TCommonBlock ||
498                        stmt instanceof TDb2CreateFunction ||
499                        stmt instanceof TPlsqlCreatePackage) {
500                        ((TParseTreeNode) stmt).accept(this);
501                    } else {
502                        ((TParseTreeNode) stmt).acceptChildren(this);
503                    }
504                }
505            }
506        }
507
508        // Post-process: remove function keyword arguments that were marked during traversal
509        // This is necessary because TFunctionCall is visited AFTER its child TObjectName nodes
510        if (!functionKeywordArguments.isEmpty()) {
511            allColumnReferences.removeAll(functionKeywordArguments);
512            for (TObjectName keywordArg : functionKeywordArguments) {
513                columnToScopeMap.remove(keywordArg);
514            }
515            if (DEBUG_SCOPE_BUILD) {
516                System.out.println("[DEBUG] Post-process: removed " + functionKeywordArguments.size() +
517                    " function keyword arguments from column references");
518            }
519        }
520
521        // Build result
522        return new ScopeBuildResult(
523            globalScope,
524            columnToScopeMap,
525            allColumnReferences,
526            statementScopeMap,
527            usingColumnToRightTable,
528            usingColumnToLeftTable,
529            tableToNamespaceMap,
530            allCtasTargetTables
531        );
532    }
533
534    /**
535     * Reset all state for a new build
536     */
537    private void reset() {
538        scopeStack.clear();
539        columnToScopeMap.clear();
540        allColumnReferences.clear();
541        statementScopeMap.clear();
542        updateScopeMap.clear();
543        mergeScopeMap.clear();
544        deleteScopeMap.clear();
545        tableNameReferences.clear();
546        tableValuedFunctionCalls.clear();
547        tupleAliasColumns.clear();
548        ctasTargetColumns.clear();
549        valuesTableAliasColumns.clear();
550        resultColumnAliasNames.clear();
551        functionKeywordArguments.clear();
552        namedArgumentParameters.clear();
553        pivotInClauseColumns.clear();
554        insertAllTargetColumns.clear();
555        mergeInsertValuesUnboundColumns.clear();
556        ddlTargetNames.clear();
557        selectLateralAliases.clear();
558        fromScopeStack.clear();
559        pendingSubqueryValidation.clear();
560        tableToNamespaceMap.clear();
561        currentSelectScope = null;
562        currentUpdateScope = null;
563        currentMergeScope = null;
564        currentDeleteScope = null;
565        currentFromScope = null;
566        currentCTEScope = null;
567        cteDefinitionDepth = 0;
568        currentMergeTargetTable = null;
569        currentCTASTargetTable = null;
570        ctasMainSelectScope = null;
571        allCtasTargetTables.clear();
572        currentPlsqlBlockScope = null;
573        plsqlBlockScopeStack.clear();
574        cursorForLoopRecordNames.clear();
575        // Package support
576        if (packageRegistry != null) {
577            packageRegistry.clear();
578        }
579        packageRegistry = null;
580        currentPackageScope = null;
581        packageScopeStack.clear();
582        // Cursor variable tracking
583        cursorVariableNames.clear();
584        globalScope = null;
585    }
586
587    // ========== CREATE TABLE AS SELECT Statement ==========
588
589    @Override
590    public void preVisit(TCreateTableSqlStatement stmt) {
591        // Track CTAS target table for registering output columns
592        if (stmt.getSubQuery() != null && stmt.getTargetTable() != null) {
593            currentCTASTargetTable = stmt.getTargetTable();
594            // Also add to the set of all CTAS target tables for filtering in formatter
595            allCtasTargetTables.add(currentCTASTargetTable);
596        }
597    }
598
599    @Override
600    public void postVisit(TCreateTableSqlStatement stmt) {
601        // Clear CTAS context after processing
602        currentCTASTargetTable = null;
603        ctasMainSelectScope = null;
604    }
605
606    // ========== Constraint Columns ==========
607
608    @Override
609    public void preVisit(TConstraint constraint) {
610        // Collect FOREIGN KEY constraint column list as column references
611        // These columns belong to the table being created/altered
612        // Example: FOREIGN KEY (ProductID, SpecialOfferID) REFERENCES ...
613        // The columns ProductID, SpecialOfferID in the FK list are references to the current table
614        //
615        // NOTE: We do NOT collect the referencedColumnList (columns from REFERENCES clause)
616        // because those are already added to the referenced table's linkedColumns during
617        // TConstraint.doParse() and will be output correctly from there.
618        if (constraint.getConstraint_type() == EConstraintType.foreign_key ||
619            constraint.getConstraint_type() == EConstraintType.reference) {
620
621            TPTNodeList<TColumnWithSortOrder> columnList = constraint.getColumnList();
622            if (columnList != null) {
623                for (int i = 0; i < columnList.size(); i++) {
624                    TColumnWithSortOrder col = columnList.getElement(i);
625                    if (col != null && col.getColumnName() != null) {
626                        TObjectName colName = col.getColumnName();
627                        // Add to column references - the ownerTable is set by TConstraint.doParse()
628                        // We need to also set sourceTable for proper output
629                        if (colName.getSourceTable() == null && col.getOwnerTable() != null) {
630                            colName.setSourceTable(col.getOwnerTable());
631                        }
632                        allColumnReferences.add(colName);
633                    }
634                }
635            }
636        }
637    }
638
639    // ========== SELECT Statement ==========
640
641    // Debug flag
642    private static final boolean DEBUG_SCOPE_BUILD = false;
643
644    // Stack to save/restore pivotSourceProcessingDepth when entering subqueries
645    // This ensures subquery's own tables are added to its FromScope even when inside PIVOT source
646    private java.util.Deque<Integer> pivotSourceDepthStack = new java.util.ArrayDeque<>();
647
648    @Override
649    public void preVisit(TSelectSqlStatement stmt) {
650        // Save and reset pivotSourceProcessingDepth for subqueries inside PIVOT source.
651        // This ensures that when a PIVOT source contains a subquery, the subquery's own tables
652        // (like #sample in: SELECT * FROM (SELECT col1 FROM #sample) p UNPIVOT ...)
653        // are added to the subquery's FromScope, not filtered out by pivotSourceProcessingDepth.
654        // The depth will be restored in postVisit(TSelectSqlStatement).
655        pivotSourceDepthStack.push(pivotSourceProcessingDepth);
656        pivotSourceProcessingDepth = 0;
657
658        // Determine parent scope
659        IScope parentScope = determineParentScopeForSelect(stmt);
660
661        // Create SelectScope
662        SelectScope selectScope = new SelectScope(parentScope, stmt);
663        statementScopeMap.put(stmt, selectScope);
664
665        // Push to stack
666        scopeStack.push(selectScope);
667        currentSelectScope = selectScope;
668
669        // Track the main SELECT scope for CTAS - only the first SELECT in CTAS context
670        // Subqueries within CTAS should NOT register their result columns as CTAS target columns
671        if (currentCTASTargetTable != null && ctasMainSelectScope == null) {
672            ctasMainSelectScope = selectScope;
673        }
674
675        if (DEBUG_SCOPE_BUILD) {
676            String stmtPreview = stmt.toString().length() > 50
677                ? stmt.toString().substring(0, 50) + "..."
678                : stmt.toString();
679            System.out.println("[DEBUG] preVisit(SELECT): " + stmtPreview.replace("\n", " "));
680        }
681
682        // Collect lateral column aliases from the SELECT list (for Snowflake, BigQuery lateral alias support)
683        // These are aliases that can be referenced by later columns in the same SELECT list
684        collectLateralAliases(stmt);
685
686        // Note: FROM scope is created in preVisit(TFromClause)
687        // TSelectSqlStatement.acceptChildren() DOES visit TFromClause when
688        // TBaseType.USE_JOINEXPR_INSTEAD_OF_JOIN is true (which is the default)
689    }
690
691    @Override
692    public void postVisit(TSelectSqlStatement stmt) {
693        // Pop CTEScope if present (it was left on stack in postVisit(TCTEList))
694        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof CTEScope) {
695            scopeStack.pop();
696            currentCTEScope = findEnclosingCTEScope();
697        }
698
699        // Handle Teradata implicit derived tables for SELECT with no explicit FROM clause
700        // According to teradata_implicit_derived_tables_zh.md:
701        // When there are NO explicit tables AND exactly 1 implicit derived table,
702        // unqualified columns should be linked to that implicit derived table
703        SelectScope selectScope = statementScopeMap.get(stmt);
704        // Check if FROM scope is null OR empty (no children)
705        // The parser may create an empty FROM scope for implicit derived tables
706        boolean fromScopeEmpty = selectScope == null ||
707                                  selectScope.getFromScope() == null ||
708                                  selectScope.getFromScope().getChildren().isEmpty();
709        if (selectScope != null && fromScopeEmpty &&
710            dbVendor == EDbVendor.dbvteradata && stmt.tables != null) {
711
712            // Collect implicit derived tables (created by Phase 1 old resolver)
713            List<TTable> implicitDerivedTables = new ArrayList<>();
714            for (int i = 0; i < stmt.tables.size(); i++) {
715                TTable table = stmt.tables.getTable(i);
716                if (table.getEffectType() == ETableEffectType.tetImplicitLateralDerivedTable) {
717                    implicitDerivedTables.add(table);
718                }
719            }
720
721            // If there are implicit derived tables and FROM scope is empty,
722            // add them to the FROM scope so unqualified columns can resolve
723            if (!implicitDerivedTables.isEmpty()) {
724                // Use existing FROM scope if present, otherwise create new one
725                FromScope fromScope = selectScope.getFromScope();
726                if (fromScope == null) {
727                    fromScope = new FromScope(selectScope, stmt);
728                    selectScope.setFromScope(fromScope);
729                }
730
731                // Use a Set to avoid adding duplicate tables (same name).
732                // S2: key by vendor-aware identifier so quoted-sensitive
733                // dialects (Oracle / Postgres quoted) and BigQuery's
734                // case-sensitive table rule do not collapse distinct names.
735                Set<String> addedTableNames = new HashSet<>();
736                for (TTable implicitTable : implicitDerivedTables) {
737                    String tableName = implicitTable.getName();
738                    String tableKey = keyForTable(tableName);
739                    if (addedTableNames.contains(tableKey)) {
740                        continue; // Skip duplicate table
741                    }
742                    addedTableNames.add(tableKey);
743
744                    // Create TableNamespace for the implicit derived table
745                    TableNamespace tableNs = new TableNamespace(implicitTable, nameMatcher, sqlEnv);
746                    tableNs.validate();
747                    String alias = implicitTable.getAliasName();
748                    if (alias == null || alias.isEmpty()) {
749                        alias = implicitTable.getName();
750                    }
751                    fromScope.addChild(tableNs, alias, false);
752                    tableToNamespaceMap.put(implicitTable, tableNs);
753
754                    if (DEBUG_SCOPE_BUILD) {
755                        System.out.println("[DEBUG] Added Teradata implicit derived table: " + implicitTable.getName());
756                    }
757                }
758            }
759        }
760
761        // Pop SelectScope
762        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof SelectScope) {
763            scopeStack.pop();
764        }
765
766        // Restore current SelectScope
767        currentSelectScope = findEnclosingSelectScope();
768
769        // Restore pivotSourceProcessingDepth (saved in preVisit)
770        // This ensures that after exiting a subquery, we return to the correct PIVOT processing state
771        if (!pivotSourceDepthStack.isEmpty()) {
772            pivotSourceProcessingDepth = pivotSourceDepthStack.pop();
773        }
774
775        // Clear currentFromScope when exiting a truly top-level SELECT statement.
776        // This prevents the FROM scope from leaking into subsequent statements like INSERT.
777        // A truly top-level SELECT is one where:
778        // 1. The fromScopeStack is empty (no nested subquery to restore)
779        // 2. The currentSelectScope is null (no enclosing SELECT to reference)
780        // 3. We're not inside an UPDATE, DELETE, or MERGE statement that has its own FROM scope
781        // For subqueries, the FROM scope is restored via fromScopeStack in postVisit(TFromClause).
782        if (fromScopeStack.isEmpty() && currentSelectScope == null &&
783            currentUpdateScope == null && currentDeleteScope == null && currentMergeScope == null) {
784            currentFromScope = null;
785        }
786
787        // Clean up lateral aliases for this statement
788        selectLateralAliases.remove(stmt);
789    }
790
791    /**
792     * Collect lateral column aliases from a SELECT statement's result column list.
793     * Lateral column aliases are aliases that can be referenced by later columns
794     * in the same SELECT list (supported by Snowflake, BigQuery, etc.)
795     */
796    private void collectLateralAliases(TSelectSqlStatement stmt) {
797        TResultColumnList resultCols = stmt.getResultColumnList();
798        if (resultCols == null || resultCols.size() == 0) {
799            return;
800        }
801
802        Set<String> aliases = new HashSet<>();
803        for (int i = 0; i < resultCols.size(); i++) {
804            TResultColumn rc = resultCols.getResultColumn(i);
805            if (rc == null || rc.getAliasClause() == null) {
806                continue;
807            }
808
809            // Get the alias name
810            TAliasClause aliasClause = rc.getAliasClause();
811            if (aliasClause.getAliasName() != null) {
812                String aliasName = aliasClause.getAliasName().toString();
813                // Normalize the alias name (strip quotes for matching)
814                aliasName = normalizeAliasName(aliasName);
815                if (aliasName != null && !aliasName.isEmpty()) {
816                    // S2: vendor-aware key for the lateral alias set so
817                    // case rules match the lookup site below.
818                    aliases.add(keyForColumn(aliasName));
819                    if (DEBUG_SCOPE_BUILD) {
820                        System.out.println("[DEBUG] Collected lateral alias: " + aliasName);
821                    }
822                }
823            }
824        }
825
826        if (!aliases.isEmpty()) {
827            selectLateralAliases.put(stmt, aliases);
828        }
829    }
830
831    /**
832     * Slice S2: produce a vendor-aware key for table identifier sets / maps.
833     * Quote-aware: identifiers like Oracle {@code "MyTbl"} keep their case
834     * (quoted-sensitive) while unquoted ones fold per vendor rules. This is
835     * what raw {@code String#toLowerCase()} cannot do.
836     */
837    private String keyForTable(String name) {
838        if (name == null || name.isEmpty()) return name;
839        return gudusoft.gsqlparser.sqlenv.IdentifierService.normalizeStatic(
840                dbVendor,
841                gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotTable,
842                name);
843    }
844
845    /**
846     * Slice S2: produce a vendor-aware key for column identifier sets / maps.
847     */
848    private String keyForColumn(String name) {
849        if (name == null || name.isEmpty()) return name;
850        return gudusoft.gsqlparser.sqlenv.IdentifierService.normalizeStatic(
851                dbVendor,
852                gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn,
853                name);
854    }
855
856    /**
857     * Normalize an alias name by stripping surrounding quotes.
858     */
859    private String normalizeAliasName(String name) {
860        if (name == null || name.isEmpty()) return name;
861        // Remove surrounding double quotes
862        if (name.startsWith("\"") && name.endsWith("\"") && name.length() > 2) {
863            return name.substring(1, name.length() - 1);
864        }
865        // Remove surrounding backticks
866        if (name.startsWith("`") && name.endsWith("`") && name.length() > 2) {
867            return name.substring(1, name.length() - 1);
868        }
869        // Remove surrounding square brackets
870        if (name.startsWith("[") && name.endsWith("]") && name.length() > 2) {
871            return name.substring(1, name.length() - 1);
872        }
873        return name;
874    }
875
876    /**
877     * Check if a column name matches a lateral alias in the current SELECT scope.
878     * This is used to filter out references to lateral column aliases.
879     */
880    private boolean isLateralColumnAlias(String columnName) {
881        if (columnName == null || columnName.isEmpty()) {
882            return false;
883        }
884
885        // Lateral aliases should ONLY apply inside result column context (SELECT list).
886        // Column references in FROM clause, WHERE clause, etc. are source table columns,
887        // not lateral alias references.
888        if (!inResultColumnContext) {
889            return false;
890        }
891
892        // Only check for lateral aliases in vendors that support them
893        // and where we want to filter them out from column references.
894        // Note: Redshift supports lateral aliases but test expects them to be reported as columns
895        if (dbVendor != EDbVendor.dbvsnowflake &&
896            dbVendor != EDbVendor.dbvbigquery &&
897            dbVendor != EDbVendor.dbvdatabricks &&
898            dbVendor != EDbVendor.dbvsparksql) {
899            return false;
900        }
901
902        // Check if current SELECT has this alias
903        if (currentSelectScope != null && currentSelectScope.getNode() instanceof TSelectSqlStatement) {
904            TSelectSqlStatement stmt = (TSelectSqlStatement) currentSelectScope.getNode();
905            Set<String> aliases = selectLateralAliases.get(stmt);
906            if (aliases != null) {
907                // S2: same vendor-aware key as the storage site above.
908                String normalizedName = keyForColumn(normalizeAliasName(columnName));
909                if (aliases.contains(normalizedName)) {
910                    // IMPORTANT: Exclude the current result column's own alias from lateral alias matching.
911                    // A column reference inside the expression that DEFINES an alias cannot be
912                    // a reference TO that alias - it must be a reference to the source table column.
913                    // e.g., in "CASE WHEN Model_ID = '' THEN NULL ELSE TRIM(Model_ID) END AS Model_ID",
914                    // Model_ID inside the CASE is a source table column, not a lateral alias reference.
915                    if (currentResultColumnAlias != null && normalizedName.equals(currentResultColumnAlias)) {
916                        if (DEBUG_SCOPE_BUILD) {
917                            System.out.println("[DEBUG] Skipping lateral alias check for current result column alias: " + columnName);
918                        }
919                        return false;
920                    }
921                    if (DEBUG_SCOPE_BUILD) {
922                        System.out.println("[DEBUG] Found lateral alias reference: " + columnName);
923                    }
924                    return true;
925                }
926            }
927        }
928
929        return false;
930    }
931
932    /**
933     * Determine the parent scope for a SELECT statement.
934     *
935     * <p>Rules:
936     * <ul>
937     *   <li>CTE subquery: parent is CTEScope</li>
938     *   <li>FROM subquery: parent is enclosing SelectScope</li>
939     *   <li>Scalar subquery: parent is enclosing SelectScope</li>
940     *   <li>Top-level: parent is GlobalScope</li>
941     * </ul>
942     */
943    private IScope determineParentScopeForSelect(TSelectSqlStatement stmt) {
944        // If we have a CTE scope on the stack and this is a CTE subquery,
945        // use the CTE scope as parent
946        if (currentCTEScope != null && isQueryOfCTE(stmt)) {
947            return currentCTEScope;
948        }
949
950        // Otherwise, find appropriate parent from stack
951        // Skip any non-SELECT scopes (FromScope, etc.)
952        for (int i = scopeStack.size() - 1; i >= 0; i--) {
953            IScope scope = scopeStack.get(i);
954            if (scope instanceof SelectScope || scope instanceof CTEScope ||
955                scope instanceof PlsqlBlockScope || scope instanceof GlobalScope) {
956                return scope;
957            }
958        }
959
960        return scopeStack.isEmpty() ? globalScope : scopeStack.peek();
961    }
962
963    /**
964     * Check if a SELECT statement is the query of a CTE
965     */
966    private boolean isQueryOfCTE(TSelectSqlStatement stmt) {
967        TParseTreeNode parent = stmt.getParentStmt();
968        return parent instanceof TCTE;
969    }
970
971    /**
972     * Find the enclosing SelectScope in the stack
973     */
974    private SelectScope findEnclosingSelectScope() {
975        for (int i = scopeStack.size() - 1; i >= 0; i--) {
976            IScope scope = scopeStack.get(i);
977            if (scope instanceof SelectScope) {
978                return (SelectScope) scope;
979            }
980        }
981        return null;
982    }
983
984    // ========== UPDATE Statement ==========
985
986    @Override
987    public void preVisit(TUpdateSqlStatement stmt) {
988        // Determine parent scope
989        IScope parentScope = determineParentScopeForUpdate(stmt);
990
991        // Create UpdateScope
992        UpdateScope updateScope = new UpdateScope(parentScope, stmt);
993        updateScopeMap.put(stmt, updateScope);
994
995        // Push to stack
996        scopeStack.push(updateScope);
997        currentUpdateScope = updateScope;
998
999        // Set the current UPDATE target table for SET clause column linking
1000        currentUpdateTargetTable = stmt.getTargetTable();
1001        // A multi-table UPDATE ("UPDATE t1 JOIN t2 ... SET only_t2 = 1") has no
1002        // grammar-guaranteed single target: an unqualified LHS may legitimately
1003        // bind to a non-target table. The legacy sourceTable assignment below
1004        // still points at the target, but that must not be CERTIFIED as a proof
1005        // trace, so binding traces are issued only for the single-table form.
1006        currentUpdateHasSingleTable = stmt.tables == null || stmt.tables.size() <= 1;
1007
1008        if (DEBUG_SCOPE_BUILD) {
1009            String stmtPreview = stmt.toString().length() > 50
1010                ? stmt.toString().substring(0, 50) + "..."
1011                : stmt.toString();
1012            System.out.println("[DEBUG] preVisit(UPDATE): " + stmtPreview.replace("\n", " ") + ", parentScope=" + parentScope);
1013        }
1014
1015        // Create FromScope for UPDATE's tables (target table + FROM clause tables)
1016        // The tables will be added when the visitor visits TTable nodes via acceptChildren()
1017        if (stmt.tables != null && stmt.tables.size() > 0) {
1018            // Save current FROM scope if any
1019            if (currentFromScope != null) {
1020                fromScopeStack.push(currentFromScope);
1021            }
1022
1023            // Create FromScope for UPDATE's tables
1024            FromScope fromScope = new FromScope(updateScope, stmt.tables);
1025            updateScope.setFromScope(fromScope);
1026            currentFromScope = fromScope;
1027
1028            // Tables will be processed when acceptChildren() visits TTable nodes
1029            // via preVisit(TTable) which checks currentFromScope
1030        }
1031
1032        // Visit JOIN ON conditions by traversing relations with type ETableSource.join
1033        // This ensures columns in ON clauses are collected into allColumnReferences
1034        // (Similar to DELETE statement handling)
1035        for (TTable relation : stmt.getRelations()) {
1036            if (relation.getTableType() == ETableSource.join && relation.getJoinExpr() != null) {
1037                visitJoinExprConditions(relation.getJoinExpr());
1038            }
1039        }
1040    }
1041
1042    @Override
1043    public void postVisit(TUpdateSqlStatement stmt) {
1044        // Pop scope
1045        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof UpdateScope) {
1046            scopeStack.pop();
1047        }
1048
1049        // Restore current UpdateScope
1050        currentUpdateScope = findEnclosingUpdateScope();
1051
1052        // Clear current UPDATE target table
1053        currentUpdateTargetTable = null;
1054        currentUpdateHasSingleTable = true;
1055
1056        // Restore FROM scope
1057        if (!fromScopeStack.isEmpty()) {
1058            currentFromScope = fromScopeStack.pop();
1059        } else {
1060            currentFromScope = null;
1061        }
1062    }
1063
1064    // ========== INSERT Statement ==========
1065
1066    /** Tracks the SelectScope for INSERT ALL subquery, so VALUES columns can resolve to it */
1067    private SelectScope currentInsertAllSubqueryScope = null;
1068
1069    @Override
1070    public void preVisit(TInsertSqlStatement stmt) {
1071        // Set the current INSERT target table for OUTPUT clause column linking
1072        currentInsertTargetTable = stmt.getTargetTable();
1073
1074        // Explicit INSERT target column list: these are bound to the target
1075        // table by Phase 1 (linkColumnToTable) before resolver2 runs, so the
1076        // scope machinery never produces a complete trace for them. Record the
1077        // typed direct binding here (the binder IS the INSERT target).
1078        if (bindingTraceRegistry != null && currentInsertTargetTable != null
1079                && stmt.getColumnList() != null) {
1080            for (int i = 0; i < stmt.getColumnList().size(); i++) {
1081                TObjectName col = stmt.getColumnList().getObjectName(i);
1082                // Unqualified only: a qualifier that disagrees with the INSERT
1083                // target must not be certified (see the UPDATE SET note).
1084                if (col != null && col.getTableToken() == null
1085                        && col.getSourceTable() == currentInsertTargetTable) {
1086                    traceDirectBinding(col,
1087                            gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.DML_TARGET,
1088                            currentInsertTargetTable);
1089                }
1090            }
1091        }
1092
1093        // Handle INSERT ALL (Oracle multi-table insert)
1094        // The subquery provides source columns that are referenced in VALUES clauses
1095        // We need to pre-build the subquery scope so VALUES columns can resolve
1096        if (stmt.isInsertAll() && stmt.getSubQuery() != null) {
1097            TSelectSqlStatement subQuery = stmt.getSubQuery();
1098
1099            // Determine parent scope
1100            IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
1101
1102            // Create SelectScope for the subquery
1103            SelectScope subqueryScope = new SelectScope(parentScope, subQuery);
1104            currentInsertAllSubqueryScope = subqueryScope;
1105
1106            // Build FromScope with the subquery's tables
1107            FromScope fromScope = new FromScope(subqueryScope, subQuery);
1108            buildInsertAllFromScope(fromScope, subQuery);
1109            subqueryScope.setFromScope(fromScope);
1110
1111            // Push the scope so VALUES columns can resolve against it
1112            scopeStack.push(subqueryScope);
1113            currentSelectScope = subqueryScope;
1114
1115            if (DEBUG_SCOPE_BUILD) {
1116                System.out.println("[DEBUG] preVisit(INSERT ALL): Created subquery scope with " +
1117                    fromScope.getChildren().size() + " tables");
1118            }
1119        }
1120
1121        if (DEBUG_SCOPE_BUILD) {
1122            String stmtPreview = stmt.toString().length() > 50
1123                ? stmt.toString().substring(0, 50) + "..."
1124                : stmt.toString();
1125            System.out.println("[DEBUG] preVisit(INSERT): " + stmtPreview.replace("\n", " ") +
1126                ", targetTable=" + (currentInsertTargetTable != null ? currentInsertTargetTable.getName() : "null") +
1127                ", isInsertAll=" + stmt.isInsertAll());
1128        }
1129    }
1130
1131    @Override
1132    public void postVisit(TInsertSqlStatement stmt) {
1133        // Pop INSERT ALL subquery scope if we pushed one
1134        if (stmt.isInsertAll() && currentInsertAllSubqueryScope != null) {
1135            if (!scopeStack.isEmpty() && scopeStack.peek() == currentInsertAllSubqueryScope) {
1136                scopeStack.pop();
1137            }
1138            currentInsertAllSubqueryScope = null;
1139            currentSelectScope = findEnclosingSelectScope();
1140        }
1141
1142        // Clear current INSERT target table
1143        currentInsertTargetTable = null;
1144    }
1145
1146    /**
1147     * Build FromScope for INSERT ALL subquery.
1148     * This adds the subquery's FROM tables to the scope so VALUES columns can resolve.
1149     */
1150    private void buildInsertAllFromScope(FromScope fromScope, TSelectSqlStatement select) {
1151        if (select == null || select.tables == null) {
1152            return;
1153        }
1154
1155        // Add namespace for each table in the FROM clause
1156        for (int i = 0; i < select.tables.size(); i++) {
1157            TTable table = select.tables.getTable(i);
1158            if (table == null) {
1159                continue;
1160            }
1161
1162            // Skip INSERT target tables (they're in the tables list but not part of FROM)
1163            if (table.getEffectType() == ETableEffectType.tetInsert) {
1164                continue;
1165            }
1166
1167            // Create TableNamespace for this table
1168            TableNamespace tableNs = new TableNamespace(table, nameMatcher, sqlEnv);
1169            tableNs.validate();
1170
1171            // Determine alias - use table alias if available, otherwise table name
1172            String alias = table.getAliasName();
1173            if (alias == null || alias.isEmpty()) {
1174                alias = table.getName();
1175            }
1176
1177            fromScope.addChild(tableNs, alias, false);
1178            tableToNamespaceMap.put(table, tableNs);
1179
1180            if (DEBUG_SCOPE_BUILD) {
1181                System.out.println("[DEBUG] buildInsertAllFromScope: Added table " +
1182                    table.getName() + " (alias: " + alias + ") to INSERT ALL subquery scope");
1183            }
1184        }
1185    }
1186
1187    // ========== INSERT ALL Target Columns - Track columns that already have sourceTable ==========
1188
1189    @Override
1190    public void preVisit(TInsertIntoValue insertIntoValue) {
1191        // Track INSERT ALL target columns (from columnList) that already have sourceTable set
1192        // These should NOT be re-resolved against the subquery scope
1193        TObjectNameList columnList = insertIntoValue.getColumnList();
1194        if (columnList != null) {
1195            for (int i = 0; i < columnList.size(); i++) {
1196                TObjectName column = columnList.getObjectName(i);
1197                if (column != null && column.getSourceTable() != null) {
1198                    insertAllTargetColumns.add(column);
1199                    if (DEBUG_SCOPE_BUILD) {
1200                        System.out.println("[DEBUG] preVisit(TInsertIntoValue): Tracked INSERT ALL target column: " +
1201                            column.toString() + " -> " + column.getSourceTable().getName());
1202                    }
1203                }
1204            }
1205        }
1206    }
1207
1208    /**
1209     * Determine the parent scope for an UPDATE statement.
1210     */
1211    private IScope determineParentScopeForUpdate(TUpdateSqlStatement stmt) {
1212        // If we have a CTE scope on the stack, use it
1213        if (currentCTEScope != null) {
1214            return currentCTEScope;
1215        }
1216
1217        // Otherwise, find appropriate parent from stack
1218        for (int i = scopeStack.size() - 1; i >= 0; i--) {
1219            IScope scope = scopeStack.get(i);
1220            if (scope instanceof SelectScope || scope instanceof UpdateScope ||
1221                scope instanceof CTEScope || scope instanceof PlsqlBlockScope ||
1222                scope instanceof GlobalScope) {
1223                return scope;
1224            }
1225        }
1226
1227        return scopeStack.isEmpty() ? globalScope : scopeStack.peek();
1228    }
1229
1230    /**
1231     * Find the enclosing UpdateScope in the stack
1232     */
1233    private UpdateScope findEnclosingUpdateScope() {
1234        for (int i = scopeStack.size() - 1; i >= 0; i--) {
1235            IScope scope = scopeStack.get(i);
1236            if (scope instanceof UpdateScope) {
1237                return (UpdateScope) scope;
1238            }
1239        }
1240        return null;
1241    }
1242
1243    // ========== DELETE Statement ==========
1244
1245    @Override
1246    public void preVisit(TDeleteSqlStatement stmt) {
1247        // Determine parent scope
1248        IScope parentScope = determineParentScopeForDelete(stmt);
1249
1250        // Create DeleteScope
1251        DeleteScope deleteScope = new DeleteScope(parentScope, stmt);
1252        deleteScopeMap.put(stmt, deleteScope);
1253
1254        // Push to stack
1255        scopeStack.push(deleteScope);
1256        currentDeleteScope = deleteScope;
1257
1258        // Set the current DELETE target table for OUTPUT clause column linking
1259        currentDeleteTargetTable = stmt.getTargetTable();
1260
1261        if (DEBUG_SCOPE_BUILD) {
1262            String stmtPreview = stmt.toString().length() > 50
1263                ? stmt.toString().substring(0, 50) + "..."
1264                : stmt.toString();
1265            System.out.println("[DEBUG] preVisit(DELETE): " + stmtPreview.replace("\n", " ") +
1266                ", targetTable=" + (currentDeleteTargetTable != null ? currentDeleteTargetTable.getName() : "null"));
1267        }
1268
1269        // Create FromScope for DELETE's tables (target table + FROM clause tables)
1270        // The tables will be added when the visitor visits TTable nodes via acceptChildren()
1271        if (stmt.tables != null && stmt.tables.size() > 0) {
1272            // Save current FROM scope if any
1273            if (currentFromScope != null) {
1274                fromScopeStack.push(currentFromScope);
1275            }
1276
1277            // Create FromScope for DELETE's tables
1278            FromScope fromScope = new FromScope(deleteScope, stmt.tables);
1279            deleteScope.setFromScope(fromScope);
1280            currentFromScope = fromScope;
1281
1282            // Tables will be processed when acceptChildren() visits TTable nodes
1283            // via preVisit(TTable) which checks currentFromScope.
1284            //
1285            // Slice 84 — joined DELETE FROM-side tables (in
1286            // {@code referenceJoins}) are NOT visited by
1287            // {@code TDeleteSqlStatement.acceptChildren}, which only
1288            // walks the inherited {@code joins} list. As a result the
1289            // FromScope's children would be missing the joined-DELETE
1290            // read sources (e.g. PG `DELETE FROM e USING d` →
1291            // `departments` would never reach `preVisit(TTable)`),
1292            // and WHERE / ON refs against them would resolve to
1293            // NOT_FOUND.
1294            //
1295            // Walk referenceJoins explicitly here so the tables are
1296            // registered before the rest of acceptChildren walks
1297            // WHERE / ON / etc. Drivers and JoinItem right-side
1298            // tables are visited via their own acceptChildren; ON
1299            // conditions are intentionally NOT walked here — line
1300            // 1219's `visitJoinExprConditions` already collects them
1301            // through the join-typed wrapper in
1302            // {@code stmt.getRelations()} (verified for PG USING-with-
1303            // JOIN and MSSQL FROM-FROM via parser probes). Walking
1304            // them here would double-collect ON refs.
1305            TJoinList refJoins = stmt.getReferenceJoins();
1306            if (refJoins != null && refJoins.size() > 0) {
1307                for (int ji = 0; ji < refJoins.size(); ji++) {
1308                    TJoin rj = refJoins.getJoin(ji);
1309                    if (rj == null) continue;
1310                    if (rj.getTable() != null) {
1311                        rj.getTable().acceptChildren(this);
1312                    }
1313                    TJoinItemList items = rj.getJoinItems();
1314                    if (items == null) continue;
1315                    for (int k = 0; k < items.size(); k++) {
1316                        TJoinItem item = items.getJoinItem(k);
1317                        if (item == null || item.getTable() == null) continue;
1318                        item.getTable().acceptChildren(this);
1319                    }
1320                }
1321            }
1322        }
1323
1324        // Visit JOIN ON conditions by traversing relations with type ETableSource.join
1325        // Use getRelations() and getJoinExpr() instead of deprecated getReferenceJoins()/TJoin
1326        for (TTable relation : stmt.getRelations()) {
1327            if (relation.getTableType() == ETableSource.join && relation.getJoinExpr() != null) {
1328                visitJoinExprConditions(relation.getJoinExpr());
1329            }
1330        }
1331    }
1332
1333    /**
1334     * Recursively visit a TJoinExpr tree to collect column references from ON conditions.
1335     * This handles nested joins where left/right tables can themselves be join expressions.
1336     */
1337    private void visitJoinExprConditions(TJoinExpr joinExpr) {
1338        if (joinExpr == null) {
1339            return;
1340        }
1341
1342        // Visit the ON condition if present
1343        if (joinExpr.getOnCondition() != null) {
1344            joinExpr.getOnCondition().acceptChildren(this);
1345        }
1346
1347        // Recursively handle left table if it's a join
1348        TTable leftTable = joinExpr.getLeftTable();
1349        if (leftTable != null && leftTable.getTableType() == ETableSource.join && leftTable.getJoinExpr() != null) {
1350            visitJoinExprConditions(leftTable.getJoinExpr());
1351        }
1352
1353        // Recursively handle right table if it's a join
1354        TTable rightTable = joinExpr.getRightTable();
1355        if (rightTable != null && rightTable.getTableType() == ETableSource.join && rightTable.getJoinExpr() != null) {
1356            visitJoinExprConditions(rightTable.getJoinExpr());
1357        }
1358    }
1359
1360    @Override
1361    public void postVisit(TDeleteSqlStatement stmt) {
1362        // Pop scope
1363        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof DeleteScope) {
1364            scopeStack.pop();
1365        }
1366
1367        // Restore current DeleteScope
1368        currentDeleteScope = findEnclosingDeleteScope();
1369
1370        // Clear current DELETE target table
1371        currentDeleteTargetTable = null;
1372
1373        // Restore FROM scope
1374        if (!fromScopeStack.isEmpty()) {
1375            currentFromScope = fromScopeStack.pop();
1376        } else {
1377            currentFromScope = null;
1378        }
1379    }
1380
1381    // ========== OUTPUT Clause ==========
1382
1383    @Override
1384    public void preVisit(TOutputClause outputClause) {
1385        if (outputClause == null || outputClause.getSelectItemList() == null) {
1386            return;
1387        }
1388
1389        // Only process for SQL Server / Azure SQL
1390        if (dbVendor != EDbVendor.dbvmssql && dbVendor != EDbVendor.dbvazuresql) {
1391            return;
1392        }
1393
1394        // Process each column in the OUTPUT clause
1395        // These columns may reference inserted/deleted pseudo-tables
1396        // which need to be resolved to the DML statement's target table
1397        //
1398        // Phase 1 (TOutputClause.doParse) already swaps tokens and sets pseudoTableType.
1399        // Phase 2 here sets the correct sourceTable (which may differ in trigger context)
1400        // and adds to allColumnReferences for the resolver.
1401        TResultColumnList selectList = outputClause.getSelectItemList();
1402        for (int i = 0; i < selectList.size(); i++) {
1403            TResultColumn resultColumn = selectList.getResultColumn(i);
1404            if (resultColumn != null && resultColumn.getFieldAttr() != null) {
1405                TObjectName columnRef = resultColumn.getFieldAttr();
1406
1407                boolean isPseudoTableColumn = false;
1408
1409                if (columnRef.getPseudoTableType() != EPseudoTableType.none) {
1410                    // Phase 1 already set pseudoTableType — just need to set sourceTable
1411                    isPseudoTableColumn = true;
1412                } else {
1413                    // Fallback: check raw tokens in case Phase 1 didn't run
1414                    TSourceToken objectToken = columnRef.getObjectToken();
1415                    TSourceToken partToken = columnRef.getPartToken();
1416                    TSourceToken propertyToken = columnRef.getPropertyToken();
1417
1418                    if (objectToken != null && propertyToken == null) {
1419                        // Common case: objectToken=inserted/deleted, partToken=columnName
1420                        String objName = objectToken.toString().toUpperCase();
1421                        if ("INSERTED".equals(objName) || "DELETED".equals(objName)) {
1422                            isPseudoTableColumn = true;
1423                            columnRef.setPseudoTableType("INSERTED".equals(objName)
1424                                    ? EPseudoTableType.inserted : EPseudoTableType.deleted);
1425                        }
1426                    } else if (partToken != null && propertyToken != null) {
1427                        // Edge case: partToken=inserted/deleted, propertyToken=columnName (needs swap)
1428                        String partName = partToken.toString().toUpperCase();
1429                        if ("INSERTED".equals(partName) || "DELETED".equals(partName)) {
1430                            isPseudoTableColumn = true;
1431                            columnRef.setPseudoTableType("INSERTED".equals(partName)
1432                                    ? EPseudoTableType.inserted : EPseudoTableType.deleted);
1433                            columnRef.setObjectToken(partToken);
1434                            columnRef.setPartToken(propertyToken);
1435                            columnRef.setPropertyToken(null);
1436                        }
1437                    }
1438                }
1439
1440                // Determine which DML target table to use.
1441                // Priority: trigger > merge > insert/update/delete
1442                // The MERGE target takes priority over enclosing DML statements because
1443                // the OUTPUT clause belongs to the MERGE itself, and inserted/deleted
1444                // pseudo-tables refer to MERGE target rows, not the enclosing INSERT/UPDATE/DELETE target.
1445                TTable targetTable = null;
1446                if (currentTriggerTargetTable != null) {
1447                    targetTable = currentTriggerTargetTable;
1448                } else if (currentMergeTargetTable != null) {
1449                    targetTable = currentMergeTargetTable;
1450                } else if (currentInsertTargetTable != null) {
1451                    targetTable = currentInsertTargetTable;
1452                } else if (currentUpdateTargetTable != null) {
1453                    targetTable = currentUpdateTargetTable;
1454                } else if (currentDeleteTargetTable != null) {
1455                    targetTable = currentDeleteTargetTable;
1456                }
1457
1458                if (isPseudoTableColumn) {
1459                    if (targetTable != null) {
1460                        columnRef.setSourceTable(targetTable);
1461                        traceDirectBinding(columnRef,
1462                                gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.DML_TARGET,
1463                                targetTable);
1464                        allColumnReferences.add(columnRef);
1465
1466                        if (DEBUG_SCOPE_BUILD) {
1467                            System.out.println("[DEBUG] OUTPUT clause column '" + columnRef.toString() +
1468                                "' (column=" + columnRef.getColumnNameOnly() + ") linked to target table '" +
1469                                targetTable.getName() + "'");
1470                        }
1471                    }
1472                } else if (targetTable != null && "$action".equalsIgnoreCase(columnRef.getColumnNameOnly())) {
1473                    // $action is a MERGE-specific pseudo-column that returns 'INSERT', 'UPDATE', or 'DELETE'.
1474                    // Link it to the target table and add to allColumnReferences to maintain SQL text order
1475                    // with neighboring inserted/deleted pseudo-table columns.
1476                    // Preserve dbObjectType (constant) since setSourceTable changes it to column.
1477                    EDbObjectType savedType = columnRef.getDbObjectType();
1478                    columnRef.setSourceTable(targetTable);
1479                    columnRef.setDbObjectTypeDirectly(savedType);
1480                    traceDirectBinding(columnRef,
1481                            gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.DML_TARGET,
1482                            targetTable);
1483                    allColumnReferences.add(columnRef);
1484
1485                    if (DEBUG_SCOPE_BUILD) {
1486                        System.out.println("[DEBUG] OUTPUT clause $action column linked to target table '" +
1487                            targetTable.getName() + "'");
1488                    }
1489                }
1490            }
1491        }
1492    }
1493
1494    /**
1495     * Determine the parent scope for a DELETE statement.
1496     */
1497    private IScope determineParentScopeForDelete(TDeleteSqlStatement stmt) {
1498        // If we have a CTE scope on the stack, use it
1499        if (currentCTEScope != null) {
1500            return currentCTEScope;
1501        }
1502
1503        // Otherwise, find appropriate parent from stack
1504        for (int i = scopeStack.size() - 1; i >= 0; i--) {
1505            IScope scope = scopeStack.get(i);
1506            if (scope instanceof SelectScope || scope instanceof UpdateScope ||
1507                scope instanceof DeleteScope || scope instanceof CTEScope ||
1508                scope instanceof PlsqlBlockScope ||
1509                scope instanceof GlobalScope) {
1510                return scope;
1511            }
1512        }
1513
1514        return scopeStack.isEmpty() ? globalScope : scopeStack.peek();
1515    }
1516
1517    /**
1518     * Find the enclosing DeleteScope in the stack
1519     */
1520    private DeleteScope findEnclosingDeleteScope() {
1521        for (int i = scopeStack.size() - 1; i >= 0; i--) {
1522            IScope scope = scopeStack.get(i);
1523            if (scope instanceof DeleteScope) {
1524                return (DeleteScope) scope;
1525            }
1526        }
1527        return null;
1528    }
1529
1530    // ========== CREATE TRIGGER Statement ==========
1531
1532    @Override
1533    public void preVisit(TCreateTriggerStmt stmt) {
1534        // For SQL Server triggers, track the target table so we can resolve
1535        // deleted/inserted virtual tables to the actual trigger target table
1536        if (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql) {
1537            TTable targetTable = stmt.getOnTable();
1538            if (targetTable != null) {
1539                // Save current trigger target if any (for nested triggers, though rare)
1540                if (currentTriggerTargetTable != null) {
1541                    triggerTargetTableStack.push(currentTriggerTargetTable);
1542                }
1543                currentTriggerTargetTable = targetTable;
1544
1545                if (DEBUG_SCOPE_BUILD) {
1546                    System.out.println("[DEBUG] preVisit(CREATE TRIGGER): target table = " + targetTable.getName());
1547                }
1548            }
1549        }
1550    }
1551
1552    @Override
1553    public void postVisit(TCreateTriggerStmt stmt) {
1554        // Restore previous trigger target table
1555        if (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql) {
1556            if (!triggerTargetTableStack.isEmpty()) {
1557                currentTriggerTargetTable = triggerTargetTableStack.pop();
1558            } else {
1559                currentTriggerTargetTable = null;
1560            }
1561        }
1562    }
1563
1564    @Override
1565    public void preVisit(TPlsqlCreateTrigger stmt) {
1566        // Oracle trigger: track FOLLOWS trigger list names to avoid collecting as column references
1567        // These are trigger names, not column references
1568        if (stmt.getFollowsTriggerList() != null) {
1569            TObjectNameList followsList = stmt.getFollowsTriggerList();
1570            for (int i = 0; i < followsList.size(); i++) {
1571                TObjectName triggerName = followsList.getObjectName(i);
1572                if (triggerName != null) {
1573                    ddlTargetNames.add(triggerName);
1574                    if (DEBUG_SCOPE_BUILD) {
1575                        System.out.println("[DEBUG] preVisit(TPlsqlCreateTrigger): marked FOLLOWS trigger = " + triggerName);
1576                    }
1577                }
1578            }
1579        }
1580    }
1581
1582    // ========== CREATE INDEX Statement ==========
1583
1584    @Override
1585    public void preVisit(TCreateIndexSqlStatement stmt) {
1586        // Mark the table name as a table reference (not column)
1587        TObjectName tableName = stmt.getTableName();
1588        if (tableName != null) {
1589            ddlTargetNames.add(tableName);
1590            if (DEBUG_SCOPE_BUILD) {
1591                System.out.println("[DEBUG] preVisit(CREATE INDEX): marked table reference = " + tableName);
1592            }
1593        }
1594
1595        // Handle the columns in the index - these should be linked to the table
1596        if (stmt.tables != null && stmt.tables.size() > 0) {
1597            TTable targetTable = stmt.tables.getTable(0);
1598            TOrderByItemList columnList = stmt.getColumnNameList();
1599            if (columnList != null && targetTable != null) {
1600                for (int i = 0; i < columnList.size(); i++) {
1601                    TOrderByItem orderByItem = columnList.getOrderByItem(i);
1602                    if (orderByItem != null && orderByItem.getSortKey() != null) {
1603                        TExpression sortKey = orderByItem.getSortKey();
1604                        if (sortKey.getExpressionType() == EExpressionType.simple_object_name_t) {
1605                            TObjectName columnName = sortKey.getObjectOperand();
1606                            if (columnName != null) {
1607                                columnName.setSourceTable(targetTable);
1608                                allColumnReferences.add(columnName);
1609                                if (DEBUG_SCOPE_BUILD) {
1610                                    System.out.println("[DEBUG] CREATE INDEX column '" + columnName +
1611                                        "' linked to table '" + targetTable.getName() + "'");
1612                                }
1613                            }
1614                        }
1615                    }
1616                }
1617            }
1618        }
1619    }
1620
1621    // ========== DROP INDEX Statement ==========
1622
1623    @Override
1624    public void preVisit(TDropIndexSqlStatement stmt) {
1625        // For dialects such as SQLite, the standalone index name is the
1626        // principal child visited by TDropIndexSqlStatement.
1627        TObjectName indexName = stmt.getIndexName();
1628        if (indexName != null) {
1629            ddlTargetNames.add(indexName);
1630        }
1631
1632        TObjectNameList indexNames = stmt.getIndexNameList();
1633        if (indexNames != null) {
1634            for (int i = 0; i < indexNames.size(); i++) {
1635                TObjectName listedIndexName = indexNames.getObjectName(i);
1636                if (listedIndexName != null) {
1637                    ddlTargetNames.add(listedIndexName);
1638                }
1639            }
1640        }
1641
1642        // Mark the table name as a table reference (not column)
1643        TObjectName tableName = stmt.getTableName();
1644        if (tableName != null) {
1645            ddlTargetNames.add(tableName);
1646            if (DEBUG_SCOPE_BUILD) {
1647                System.out.println("[DEBUG] preVisit(DROP INDEX): marked table reference = " + tableName);
1648            }
1649        }
1650
1651        // For SQL Server, also handle TDropIndexItem list
1652        TDropIndexItemList dropIndexItems = stmt.getDropIndexItemList();
1653        if (dropIndexItems != null) {
1654            for (int i = 0; i < dropIndexItems.size(); i++) {
1655                TDropIndexItem item = dropIndexItems.getDropIndexItem(i);
1656                if (item != null) {
1657                    // Mark index name to avoid being collected as column
1658                    TObjectName itemIndexName = item.getIndexName();
1659                    if (itemIndexName != null) {
1660                        ddlTargetNames.add(itemIndexName);
1661                    }
1662                    // Mark table name (objectName in TDropIndexItem)
1663                    TObjectName objectName = item.getObjectName();
1664                    if (objectName != null) {
1665                        ddlTargetNames.add(objectName);
1666                        if (DEBUG_SCOPE_BUILD) {
1667                            System.out.println("[DEBUG] preVisit(DROP INDEX): marked table reference from item = " + objectName);
1668                        }
1669                    }
1670                }
1671            }
1672        }
1673    }
1674
1675    // ========== EXECUTE IMMEDIATE - Skip variable references in dynamic SQL expression ==========
1676
1677    @Override
1678    public void preVisit(TExecImmeStmt stmt) {
1679        // Set flag to indicate we're inside EXECUTE IMMEDIATE
1680        // This prevents the dynamic SQL variable from being collected as a column reference
1681        insideExecuteImmediateDynamicExpr = true;
1682        if (DEBUG_SCOPE_BUILD) {
1683            System.out.println("[DEBUG] preVisit(TExecImmeStmt): entering EXECUTE IMMEDIATE");
1684        }
1685    }
1686
1687    @Override
1688    public void postVisit(TExecImmeStmt stmt) {
1689        // Clear the flag when leaving EXECUTE IMMEDIATE
1690        insideExecuteImmediateDynamicExpr = false;
1691        if (DEBUG_SCOPE_BUILD) {
1692            System.out.println("[DEBUG] postVisit(TExecImmeStmt): leaving EXECUTE IMMEDIATE");
1693        }
1694    }
1695
1696    // ========== Cursor FOR Loop - Track record names to avoid false column detection ==========
1697
1698    @Override
1699    public void preVisit(TLoopStmt stmt) {
1700        // Track cursor FOR loop record names (e.g., "rec" in "for rec in (SELECT ...)")
1701        // These are implicitly declared record variables, and their field access like "rec.field"
1702        // should not be collected as column references
1703        if (stmt.getKind() == TLoopStmt.cursor_for_loop) {
1704            TObjectName recordName = stmt.getRecordName();
1705            if (recordName != null) {
1706                String recNameStr = recordName.toString();
1707                if (recNameStr != null && !recNameStr.isEmpty()) {
1708                    cursorForLoopRecordNames.add(recNameStr.toLowerCase(Locale.ROOT));
1709                    if (DEBUG_SCOPE_BUILD) {
1710                        System.out.println("[DEBUG] preVisit(TLoopStmt): registered cursor FOR loop record = " + recNameStr);
1711                    }
1712                }
1713            }
1714        }
1715    }
1716
1717    @Override
1718    public void postVisit(TLoopStmt stmt) {
1719        // Remove cursor FOR loop record name when leaving the loop scope
1720        if (stmt.getKind() == TLoopStmt.cursor_for_loop) {
1721            TObjectName recordName = stmt.getRecordName();
1722            if (recordName != null) {
1723                String recNameStr = recordName.toString();
1724                if (recNameStr != null && !recNameStr.isEmpty()) {
1725                    cursorForLoopRecordNames.remove(recNameStr.toLowerCase(Locale.ROOT));
1726                    if (DEBUG_SCOPE_BUILD) {
1727                        System.out.println("[DEBUG] postVisit(TLoopStmt): removed cursor FOR loop record = " + recNameStr);
1728                    }
1729                }
1730            }
1731        }
1732    }
1733
1734    // ========== Snowflake DDL Statements - Track target names to avoid false column detection ==========
1735
1736    @Override
1737    public void preVisit(TCreateFileFormatStmt stmt) {
1738        // Track file format name to avoid collecting it as a column reference
1739        if (stmt.getFileFormatName() != null) {
1740            ddlTargetNames.add(stmt.getFileFormatName());
1741            if (DEBUG_SCOPE_BUILD) {
1742                System.out.println("[DEBUG] preVisit(TCreateFileFormatStmt): marked DDL target = " + stmt.getFileFormatName());
1743            }
1744        }
1745    }
1746
1747    @Override
1748    public void preVisit(TCreateStageStmt stmt) {
1749        // Track stage name to avoid collecting it as a column reference
1750        if (stmt.getStageName() != null) {
1751            ddlTargetNames.add(stmt.getStageName());
1752            if (DEBUG_SCOPE_BUILD) {
1753                System.out.println("[DEBUG] preVisit(TCreateStageStmt): marked DDL target = " + stmt.getStageName());
1754            }
1755        }
1756    }
1757
1758    @Override
1759    public void preVisit(TCreatePipeStmt stmt) {
1760        // Track pipe name to avoid collecting it as a column reference
1761        if (stmt.getPipeName() != null) {
1762            ddlTargetNames.add(stmt.getPipeName());
1763            if (DEBUG_SCOPE_BUILD) {
1764                System.out.println("[DEBUG] preVisit(TCreatePipeStmt): marked DDL target = " + stmt.getPipeName());
1765            }
1766        }
1767    }
1768
1769    @Override
1770    public void preVisit(TAlterTableStatement stmt) {
1771        if (DEBUG_SCOPE_BUILD) {
1772            System.out.println("[DEBUG] preVisit(TAlterTableStatement): " + stmt.sqlstatementtype);
1773        }
1774        // Track columns being modified in ALTER TABLE as DDL targets
1775        // These are not column references - they're column definitions/targets
1776        if (stmt.getAlterTableOptionList() != null) {
1777            for (int i = 0; i < stmt.getAlterTableOptionList().size(); i++) {
1778                TAlterTableOption opt = stmt.getAlterTableOptionList().getAlterTableOption(i);
1779                trackAlterTableOptionColumns(opt);
1780            }
1781        }
1782    }
1783
1784    /**
1785     * Track column names in ALTER TABLE options as DDL targets.
1786     * These include: ALTER COLUMN, DROP COLUMN, CHANGE COLUMN, RENAME COLUMN, etc.
1787     */
1788    private void trackAlterTableOptionColumns(TAlterTableOption opt) {
1789        if (opt == null) return;
1790
1791        // Single column name (ALTER COLUMN, RENAME COLUMN, etc.)
1792        if (opt.getColumnName() != null) {
1793            ddlTargetNames.add(opt.getColumnName());
1794            if (DEBUG_SCOPE_BUILD) {
1795                System.out.println("[DEBUG] trackAlterTableOptionColumns: marked DDL target = " + opt.getColumnName());
1796            }
1797        }
1798
1799        // Column name list (DROP COLUMN, SET UNUSED, etc.)
1800        if (opt.getColumnNameList() != null) {
1801            for (int i = 0; i < opt.getColumnNameList().size(); i++) {
1802                TObjectName colName = opt.getColumnNameList().getObjectName(i);
1803                ddlTargetNames.add(colName);
1804                if (DEBUG_SCOPE_BUILD) {
1805                    System.out.println("[DEBUG] trackAlterTableOptionColumns: marked DDL target (list) = " + colName);
1806                }
1807            }
1808        }
1809
1810        // New column name in RENAME COLUMN (rename TO new_name)
1811        if (opt.getNewColumnName() != null) {
1812            ddlTargetNames.add(opt.getNewColumnName());
1813            if (DEBUG_SCOPE_BUILD) {
1814                System.out.println("[DEBUG] trackAlterTableOptionColumns: marked DDL target (new) = " + opt.getNewColumnName());
1815            }
1816        }
1817
1818        // Column definitions (ADD COLUMN, MODIFY COLUMN, etc.)
1819        if (opt.getColumnDefinitionList() != null) {
1820            for (int i = 0; i < opt.getColumnDefinitionList().size(); i++) {
1821                TColumnDefinition colDef = opt.getColumnDefinitionList().getColumn(i);
1822                if (colDef.getColumnName() != null) {
1823                    ddlTargetNames.add(colDef.getColumnName());
1824                    if (DEBUG_SCOPE_BUILD) {
1825                        System.out.println("[DEBUG] trackAlterTableOptionColumns: marked DDL target (def) = " + colDef.getColumnName());
1826                    }
1827                }
1828            }
1829        }
1830    }
1831
1832    // ========== SQL Server Trigger UPDATE(column) Function ==========
1833
1834    /**
1835     * Handle SQL Server trigger UPDATE(column) function.
1836     * The column inside UPDATE() should be resolved to the trigger target table.
1837     * Example: IF UPDATE(Zip) - Zip column belongs to the trigger's ON table.
1838     */
1839    @Override
1840    public void preVisit(TMssqlCreateTriggerUpdateColumn node) {
1841        if (currentTriggerTargetTable == null) {
1842            return; // Not inside a trigger context
1843        }
1844
1845        TObjectName columnName = node.getColumnName();
1846        if (columnName != null) {
1847            // Link the column to the trigger target table
1848            columnName.setSourceTable(currentTriggerTargetTable);
1849            traceDirectBinding(columnName,
1850                    gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.DML_TARGET,
1851                    currentTriggerTargetTable);
1852
1853            // Add to allColumnReferences if not already there
1854            if (!allColumnReferences.contains(columnName)) {
1855                allColumnReferences.add(columnName);
1856            }
1857
1858            // Map the column to the current scope
1859            IScope currentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
1860            columnToScopeMap.put(columnName, currentScope);
1861
1862            if (DEBUG_SCOPE_BUILD) {
1863                System.out.println("[DEBUG] preVisit(TMssqlCreateTriggerUpdateColumn): " +
1864                    columnName + " -> " + currentTriggerTargetTable.getFullName());
1865            }
1866        }
1867    }
1868
1869    // ========== PL/SQL Block Statement ==========
1870
1871    @Override
1872    public void preVisit(TCommonBlock stmt) {
1873        // TCommonBlock wraps TBlockSqlNode - we need to explicitly visit it
1874        TBlockSqlNode blockBody = stmt.getBlockBody();
1875        if (blockBody != null) {
1876            blockBody.accept(this);
1877        }
1878    }
1879
1880    // ========== Oracle Package Handling ==========
1881
1882    @Override
1883    public void preVisit(TPlsqlCreatePackage pkg) {
1884        // Only create scope for package body (kind_create_body)
1885        if (pkg.getKind() == TBaseType.kind_create_body) {
1886            String pkgName = pkg.getPackageName() != null
1887                ? pkg.getPackageName().getObjectString()
1888                : null;
1889            if (pkgName == null && pkg.getPackageName() != null) {
1890                pkgName = pkg.getPackageName().toString();
1891            }
1892
1893            if (pkgName != null && packageRegistry != null) {
1894                OraclePackageNamespace pkgNs = packageRegistry.getPackage(pkgName);
1895                if (pkgNs != null) {
1896                    // Determine parent scope
1897                    IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
1898
1899                    // Create and push package scope
1900                    OraclePackageScope pkgScope = new OraclePackageScope(parentScope, pkg, pkgNs);
1901                    scopeStack.push(pkgScope);
1902                    currentPackageScope = pkgScope;
1903                    packageScopeStack.push(pkgScope);
1904
1905                    if (DEBUG_SCOPE_BUILD) {
1906                        System.out.println("[DEBUG] preVisit(TPlsqlCreatePackage): Entered package body: " + pkgName +
1907                            ", members=" + pkgNs.getMembers().size());
1908                    }
1909                }
1910            }
1911        }
1912
1913        // Manually traverse child elements to avoid recursive preVisit call
1914        // (TPlsqlCreatePackage.acceptChildren calls preVisit again)
1915        if (pkg.getDeclareStatements() != null) {
1916            for (int i = 0; i < pkg.getDeclareStatements().size(); i++) {
1917                TCustomSqlStatement decl = pkg.getDeclareStatements().get(i);
1918                if (decl != null) {
1919                    decl.acceptChildren(this);
1920                }
1921            }
1922        }
1923        if (pkg.getBodyStatements() != null) {
1924            for (int i = 0; i < pkg.getBodyStatements().size(); i++) {
1925                TCustomSqlStatement bodyStmt = pkg.getBodyStatements().get(i);
1926                if (bodyStmt != null) {
1927                    bodyStmt.acceptChildren(this);
1928                }
1929            }
1930        }
1931    }
1932
1933    @Override
1934    public void postVisit(TPlsqlCreatePackage pkg) {
1935        if (pkg.getKind() == TBaseType.kind_create_body) {
1936            if (!packageScopeStack.isEmpty() &&
1937                !scopeStack.isEmpty() &&
1938                scopeStack.peek() == packageScopeStack.peek()) {
1939                scopeStack.pop();
1940                packageScopeStack.pop();
1941                currentPackageScope = packageScopeStack.isEmpty() ? null : packageScopeStack.peek();
1942
1943                if (DEBUG_SCOPE_BUILD) {
1944                    String pkgName = pkg.getPackageName() != null
1945                        ? pkg.getPackageName().getObjectString()
1946                        : "unknown";
1947                    if (pkgName == null && pkg.getPackageName() != null) {
1948                        pkgName = pkg.getPackageName().toString();
1949                    }
1950                    System.out.println("[DEBUG] postVisit(TPlsqlCreatePackage): Exited package body: " + pkgName);
1951                }
1952            }
1953        }
1954    }
1955
1956    @Override
1957    public void preVisit(TBlockSqlNode node) {
1958        // Save current PL/SQL block scope if nested
1959        if (currentPlsqlBlockScope != null) {
1960            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
1961        }
1962
1963        // Determine parent scope
1964        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
1965
1966        // Create PL/SQL block scope
1967        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, node);
1968        currentPlsqlBlockScope = blockScope;
1969
1970        // Push to scope stack
1971        scopeStack.push(blockScope);
1972
1973        if (DEBUG_SCOPE_BUILD) {
1974            String label = blockScope.getBlockLabel();
1975            System.out.println("[DEBUG] preVisit(TBlockSqlNode): label=" +
1976                (label != null ? label : "(anonymous)") +
1977                ", parent=" + parentScope);
1978        }
1979
1980        // Process declare statements to collect variables
1981        // Use acceptChildren to traverse into statements like cursor declarations
1982        // that have nested SELECT statements
1983        for (TCustomSqlStatement decl : node.getDeclareStatements()) {
1984            decl.acceptChildren(this);
1985        }
1986
1987        // Process body statements
1988        for (TCustomSqlStatement bodyStmt : node.getBodyStatements()) {
1989            // Use acceptChildren to traverse into the statement and collect column references
1990            bodyStmt.acceptChildren(this);
1991        }
1992    }
1993
1994    @Override
1995    public void postVisit(TBlockSqlNode node) {
1996        // Pop from scope stack
1997        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
1998            scopeStack.pop();
1999        }
2000
2001        // Restore previous PL/SQL block scope
2002        if (!plsqlBlockScopeStack.isEmpty()) {
2003            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2004        } else {
2005            currentPlsqlBlockScope = null;
2006        }
2007
2008        if (DEBUG_SCOPE_BUILD) {
2009            System.out.println("[DEBUG] postVisit(TBlockSqlNode): restored scope");
2010        }
2011    }
2012
2013    @Override
2014    public void preVisit(TVarDeclStmt stmt) {
2015        if (DEBUG_SCOPE_BUILD) {
2016            String varName = stmt.getElementName() != null ? stmt.getElementName().toString() : "(unnamed)";
2017            System.out.println("[DEBUG] preVisit(TVarDeclStmt): var=" + varName +
2018                ", currentPlsqlBlockScope=" + (currentPlsqlBlockScope != null ? "exists" : "null"));
2019        }
2020
2021        // Add variable to current PL/SQL block's variable namespace
2022        if (currentPlsqlBlockScope != null) {
2023            currentPlsqlBlockScope.getVariableNamespace().addVariable(stmt);
2024
2025            // Mark the element name TObjectName as a variable declaration (not a column reference)
2026            // This prevents it from being collected in allColumnReferences
2027            if (stmt.getElementName() != null) {
2028                variableDeclarationNames.add(stmt.getElementName());
2029            }
2030        }
2031    }
2032
2033    // ========== Oracle Cursor Variable Tracking ==========
2034
2035    @Override
2036    public void preVisit(TCursorDeclStmt cursorDecl) {
2037        // Track cursor variable name for filtering during column resolution
2038        TObjectName cursorName = cursorDecl.getCursorName();
2039        if (cursorName != null) {
2040            String name = cursorName.toString();
2041            if (name != null && !name.isEmpty()) {
2042                cursorVariableNames.add(name.toLowerCase(Locale.ROOT));
2043
2044                // Also add to current PlsqlBlockScope's namespace if available
2045                if (currentPlsqlBlockScope != null) {
2046                    PlsqlVariableNamespace varNs = currentPlsqlBlockScope.getVariableNamespace();
2047                    if (varNs != null) {
2048                        // Add cursor as a parameter-like entry (not a regular variable)
2049                        varNs.addParameter(name);
2050                    }
2051                }
2052
2053                // Mark the cursor name TObjectName as not a column reference
2054                variableDeclarationNames.add(cursorName);
2055
2056                if (DEBUG_SCOPE_BUILD) {
2057                    System.out.println("[DEBUG] preVisit(TCursorDeclStmt): Registered cursor variable: " + name);
2058                }
2059            }
2060        }
2061
2062        // Process nested SELECT statement in cursor declaration
2063        if (cursorDecl.getSubquery() != null) {
2064            cursorDecl.getSubquery().acceptChildren(this);
2065        }
2066    }
2067
2068    @Override
2069    public void preVisit(TOpenforStmt openFor) {
2070        // Track the cursor variable in OPEN...FOR
2071        TObjectName cursorVar = openFor.getCursorVariableName();
2072        if (cursorVar != null) {
2073            String name = cursorVar.toString();
2074            if (name != null && !name.isEmpty()) {
2075                cursorVariableNames.add(name.toLowerCase(Locale.ROOT));
2076                if (DEBUG_SCOPE_BUILD) {
2077                    System.out.println("[DEBUG] preVisit(TOpenforStmt): OPEN FOR cursor variable: " + name);
2078                }
2079            }
2080        }
2081
2082        // Process the FOR subquery
2083        if (openFor.getSubquery() != null) {
2084            openFor.getSubquery().acceptChildren(this);
2085        }
2086    }
2087
2088    // ========== MySQL/MSSQL DECLARE Statement ==========
2089
2090    @Override
2091    public void preVisit(TMssqlDeclare stmt) {
2092        // Handle DECLARE statements for MySQL/MSSQL
2093        // These are in bodyStatements (not declareStatements) for MySQL procedures
2094        if (currentPlsqlBlockScope != null && stmt.getVariables() != null) {
2095            for (int i = 0; i < stmt.getVariables().size(); i++) {
2096                TDeclareVariable declVar = stmt.getVariables().getDeclareVariable(i);
2097                if (declVar != null && declVar.getVariableName() != null) {
2098                    // Mark the variable name as a declaration so it won't be collected as a column reference
2099                    variableDeclarationNames.add(declVar.getVariableName());
2100                    // Also add the variable name to the namespace
2101                    currentPlsqlBlockScope.getVariableNamespace().addParameter(declVar.getVariableName().toString());
2102
2103                    if (DEBUG_SCOPE_BUILD) {
2104                        System.out.println("[DEBUG] preVisit(TMssqlDeclare): added var=" + declVar.getVariableName().toString());
2105                    }
2106                }
2107            }
2108        }
2109    }
2110
2111    // ========== DB2 DECLARE Statement ==========
2112
2113    @Override
2114    public void preVisit(TDb2SqlVariableDeclaration stmt) {
2115        // Handle DECLARE statements for DB2 procedures
2116        // These are in declareStatements for DB2 procedures
2117        if (currentPlsqlBlockScope != null && stmt.getVariables() != null) {
2118            for (int i = 0; i < stmt.getVariables().size(); i++) {
2119                TDeclareVariable declVar = stmt.getVariables().getDeclareVariable(i);
2120                if (declVar != null && declVar.getVariableName() != null) {
2121                    // Mark the variable name as a declaration so it won't be collected as a column reference
2122                    variableDeclarationNames.add(declVar.getVariableName());
2123                    // Also add the variable name to the namespace
2124                    currentPlsqlBlockScope.getVariableNamespace().addParameter(declVar.getVariableName().toString());
2125
2126                    if (DEBUG_SCOPE_BUILD) {
2127                        System.out.println("[DEBUG] preVisit(TDb2SqlVariableDeclaration): added var=" + declVar.getVariableName().toString());
2128                    }
2129                }
2130            }
2131        }
2132    }
2133
2134    // ========== DB2 DECLARE CURSOR Statement ==========
2135
2136    @Override
2137    public void preVisit(TDb2DeclareCursorStatement stmt) {
2138        // DB2 DECLARE CURSOR statements contain a SELECT subquery
2139        // The default acceptChildren only calls subquery.accept() which doesn't traverse the SELECT's children
2140        // We need to manually traverse the subquery's children to collect column references
2141        if (stmt.getSubquery() != null) {
2142            if (DEBUG_SCOPE_BUILD) {
2143                System.out.println("[DEBUG] preVisit(TDb2DeclareCursorStatement): traversing subquery");
2144            }
2145            stmt.getSubquery().acceptChildren(this);
2146        }
2147    }
2148
2149    // ========== DB2 CREATE FUNCTION Statement ==========
2150
2151    @Override
2152    public void preVisit(TDb2CreateFunction stmt) {
2153        // Save current PL/SQL block scope if nested
2154        if (currentPlsqlBlockScope != null) {
2155            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
2156        }
2157
2158        // Determine parent scope
2159        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2160
2161        // Get function name for the scope label
2162        String functionName = null;
2163        if (stmt.getFunctionName() != null) {
2164            functionName = stmt.getFunctionName().toString();
2165        }
2166
2167        // Create PL/SQL block scope using the function name as the label
2168        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, stmt, functionName);
2169        currentPlsqlBlockScope = blockScope;
2170
2171        // Push to scope stack
2172        scopeStack.push(blockScope);
2173
2174        // Add function parameters to the variable namespace
2175        if (stmt.getParameterDeclarations() != null) {
2176            for (int i = 0; i < stmt.getParameterDeclarations().size(); i++) {
2177                TParameterDeclaration param = stmt.getParameterDeclarations().getParameterDeclarationItem(i);
2178                if (param != null && param.getParameterName() != null) {
2179                    variableDeclarationNames.add(param.getParameterName());
2180                    blockScope.getVariableNamespace().addParameter(param.getParameterName().toString());
2181                }
2182            }
2183        }
2184
2185        if (DEBUG_SCOPE_BUILD) {
2186            System.out.println("[DEBUG] preVisit(TDb2CreateFunction): name=" +
2187                (functionName != null ? functionName : "anonymous"));
2188        }
2189
2190        // Note: We do NOT manually process declareStatements and bodyStatements here.
2191        // The natural visitor flow (acceptChildren) will visit them after preVisit returns.
2192        // When TDb2SqlVariableDeclaration nodes are visited, preVisit(TDb2SqlVariableDeclaration)
2193        // will add them to currentPlsqlBlockScope's namespace.
2194    }
2195
2196    @Override
2197    public void postVisit(TDb2CreateFunction stmt) {
2198        // Pop from scope stack
2199        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
2200            scopeStack.pop();
2201        }
2202
2203        // Restore parent PL/SQL block scope
2204        if (!plsqlBlockScopeStack.isEmpty()) {
2205            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2206        } else {
2207            currentPlsqlBlockScope = null;
2208        }
2209
2210        if (DEBUG_SCOPE_BUILD) {
2211            System.out.println("[DEBUG] postVisit(TDb2CreateFunction)");
2212        }
2213    }
2214
2215    // ========== Generic CREATE FUNCTION Statement ==========
2216
2217    @Override
2218    public void preVisit(TCreateFunctionStmt stmt) {
2219        // Save current PL/SQL block scope if nested
2220        if (currentPlsqlBlockScope != null) {
2221            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
2222        }
2223
2224        // Determine parent scope
2225        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2226
2227        // Get function name for the scope label
2228        String functionName = null;
2229        if (stmt.getFunctionName() != null) {
2230            functionName = stmt.getFunctionName().toString();
2231
2232            // Register the function in SQLEnv so it can be looked up later
2233            // This allows distinguishing schema.function() calls from column.method() calls
2234            if (sqlEnv != null) {
2235                sqlEnv.addFunction(stmt.getFunctionName(), true);
2236                if (DEBUG_SCOPE_BUILD) {
2237                    System.out.println("[DEBUG] preVisit(TCreateFunctionStmt): Registered function in SQLEnv: " + functionName);
2238                }
2239            }
2240        }
2241
2242        // Create PL/SQL block scope using the function name as the label
2243        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, stmt, functionName);
2244        currentPlsqlBlockScope = blockScope;
2245
2246        // Push to scope stack
2247        scopeStack.push(blockScope);
2248
2249        // Add function parameters to the variable namespace
2250        if (stmt.getParameterDeclarations() != null) {
2251            for (int i = 0; i < stmt.getParameterDeclarations().size(); i++) {
2252                TParameterDeclaration param = stmt.getParameterDeclarations().getParameterDeclarationItem(i);
2253                if (param != null && param.getParameterName() != null) {
2254                    variableDeclarationNames.add(param.getParameterName());
2255                    blockScope.getVariableNamespace().addParameter(param.getParameterName().toString());
2256                }
2257            }
2258        }
2259
2260        // Handle variable declarations in the function body
2261        // TCreateFunctionStmt.acceptChildren() doesn't visit declareStatements, so we need to do it manually
2262        if (stmt.getDeclareStatements() != null && stmt.getDeclareStatements().size() > 0) {
2263            for (int i = 0; i < stmt.getDeclareStatements().size(); i++) {
2264                TCustomSqlStatement decl = stmt.getDeclareStatements().get(i);
2265                if (decl instanceof TDb2SqlVariableDeclaration) {
2266                    TDb2SqlVariableDeclaration db2VarDecl = (TDb2SqlVariableDeclaration) decl;
2267                    if (db2VarDecl.getVariables() != null) {
2268                        for (int j = 0; j < db2VarDecl.getVariables().size(); j++) {
2269                            TDeclareVariable declVar = db2VarDecl.getVariables().getDeclareVariable(j);
2270                            if (declVar != null && declVar.getVariableName() != null) {
2271                                variableDeclarationNames.add(declVar.getVariableName());
2272                                blockScope.getVariableNamespace().addParameter(declVar.getVariableName().toString());
2273                                if (DEBUG_SCOPE_BUILD) {
2274                                    System.out.println("[DEBUG] preVisit(TCreateFunctionStmt): added var=" + declVar.getVariableName().toString());
2275                                }
2276                            }
2277                        }
2278                    }
2279                }
2280            }
2281        }
2282
2283        if (DEBUG_SCOPE_BUILD) {
2284            System.out.println("[DEBUG] preVisit(TCreateFunctionStmt): name=" +
2285                (functionName != null ? functionName : "anonymous") +
2286                ", bodyStatements=" + stmt.getBodyStatements().size() +
2287                ", blockBody=" + (stmt.getBlockBody() != null) +
2288                ", declareStatements=" + (stmt.getDeclareStatements() != null ? stmt.getDeclareStatements().size() : 0) +
2289                ", returnStmt=" + (stmt.getReturnStmt() != null));
2290        }
2291    }
2292
2293    @Override
2294    public void postVisit(TCreateFunctionStmt stmt) {
2295        // Pop from scope stack
2296        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
2297            scopeStack.pop();
2298        }
2299
2300        // Restore parent PL/SQL block scope
2301        if (!plsqlBlockScopeStack.isEmpty()) {
2302            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2303        } else {
2304            currentPlsqlBlockScope = null;
2305        }
2306
2307        if (DEBUG_SCOPE_BUILD) {
2308            System.out.println("[DEBUG] postVisit(TCreateFunctionStmt)");
2309        }
2310    }
2311
2312    // ========== DB2 RETURN Statement ==========
2313
2314    @Override
2315    public void preVisit(TDb2ReturnStmt stmt) {
2316        // DB2 RETURN statements can contain a SELECT subquery (for table-valued functions)
2317        // The default accept only calls subquery.accept() which doesn't traverse the SELECT's children
2318        // We need to manually traverse the subquery's children to collect column references
2319        if (stmt.getSubquery() != null) {
2320            if (DEBUG_SCOPE_BUILD) {
2321                System.out.println("[DEBUG] preVisit(TDb2ReturnStmt): traversing subquery");
2322            }
2323            stmt.getSubquery().acceptChildren(this);
2324        }
2325    }
2326
2327    // ========== MSSQL RETURN Statement (also used for DB2) ==========
2328
2329    @Override
2330    public void preVisit(TMssqlReturn stmt) {
2331        // TMssqlReturn can contain a SELECT subquery (used for DB2 table-valued functions too)
2332        // We need to traverse the subquery's children to collect column references
2333        if (stmt.getSubquery() != null) {
2334            if (DEBUG_SCOPE_BUILD) {
2335                System.out.println("[DEBUG] preVisit(TMssqlReturn): traversing subquery");
2336            }
2337            stmt.getSubquery().acceptChildren(this);
2338        }
2339    }
2340
2341    // ========== PL/SQL CREATE PROCEDURE Statement ==========
2342
2343    @Override
2344    public void preVisit(TPlsqlCreateProcedure stmt) {
2345        // Save current PL/SQL block scope if nested
2346        if (currentPlsqlBlockScope != null) {
2347            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
2348        }
2349
2350        // Determine parent scope
2351        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2352
2353        // Get procedure name for the scope label
2354        String procedureName = null;
2355        if (stmt.getProcedureName() != null) {
2356            procedureName = stmt.getProcedureName().toString();
2357        }
2358
2359        // Create PL/SQL block scope using the procedure name as the label
2360        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, stmt, procedureName);
2361        currentPlsqlBlockScope = blockScope;
2362
2363        // Push to scope stack
2364        scopeStack.push(blockScope);
2365
2366        // Register procedure parameters in the variable namespace
2367        // This allows ScopeBuilder to filter them out during column reference collection
2368        TParameterDeclarationList params = stmt.getParameterDeclarations();
2369        if (params != null) {
2370            for (int i = 0; i < params.size(); i++) {
2371                TParameterDeclaration param = params.getParameterDeclarationItem(i);
2372                if (param != null && param.getParameterName() != null) {
2373                    blockScope.getVariableNamespace().addParameter(param.getParameterName().toString());
2374                }
2375            }
2376        }
2377
2378        if (DEBUG_SCOPE_BUILD) {
2379            System.out.println("[DEBUG] preVisit(TPlsqlCreateProcedure): name=" +
2380                (procedureName != null ? procedureName : "(unnamed)") +
2381                ", parent=" + parentScope +
2382                ", params=" + (params != null ? params.size() : 0));
2383        }
2384
2385        // Note: We do NOT manually process declareStatements and bodyStatements here.
2386        // The natural visitor flow (acceptChildren) will visit them after preVisit returns.
2387        // When TVarDeclStmt nodes are visited, preVisit(TVarDeclStmt) will add them
2388        // to currentPlsqlBlockScope's namespace.
2389    }
2390
2391    @Override
2392    public void postVisit(TPlsqlCreateProcedure stmt) {
2393        // Pop from scope stack
2394        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
2395            scopeStack.pop();
2396        }
2397
2398        // Restore previous PL/SQL block scope
2399        if (!plsqlBlockScopeStack.isEmpty()) {
2400            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2401        } else {
2402            currentPlsqlBlockScope = null;
2403        }
2404
2405        if (DEBUG_SCOPE_BUILD) {
2406            System.out.println("[DEBUG] postVisit(TPlsqlCreateProcedure): restored scope");
2407        }
2408    }
2409
2410    // ========== PL/SQL CREATE FUNCTION Statement ==========
2411
2412    @Override
2413    public void preVisit(TPlsqlCreateFunction stmt) {
2414        // Save current PL/SQL block scope if nested
2415        if (currentPlsqlBlockScope != null) {
2416            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
2417        }
2418
2419        // Determine parent scope
2420        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2421
2422        // Get function name for the scope label
2423        String functionName = null;
2424        if (stmt.getFunctionName() != null) {
2425            functionName = stmt.getFunctionName().toString();
2426        }
2427
2428        // Create PL/SQL block scope using the function name as the label
2429        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, stmt, functionName);
2430        currentPlsqlBlockScope = blockScope;
2431
2432        // Push to scope stack
2433        scopeStack.push(blockScope);
2434
2435        // Register function parameters in the variable namespace
2436        // This allows ScopeBuilder to filter them out during column reference collection
2437        TParameterDeclarationList params = stmt.getParameterDeclarations();
2438        if (params != null) {
2439            for (int i = 0; i < params.size(); i++) {
2440                TParameterDeclaration param = params.getParameterDeclarationItem(i);
2441                if (param != null && param.getParameterName() != null) {
2442                    blockScope.getVariableNamespace().addParameter(param.getParameterName().toString());
2443                }
2444            }
2445        }
2446
2447        if (DEBUG_SCOPE_BUILD) {
2448            System.out.println("[DEBUG] preVisit(TPlsqlCreateFunction): name=" +
2449                (functionName != null ? functionName : "(unnamed)") +
2450                ", parent=" + parentScope +
2451                ", params=" + (params != null ? params.size() : 0));
2452        }
2453
2454        // Note: We do NOT manually process declareStatements and bodyStatements here.
2455        // The natural visitor flow (acceptChildren) will visit them after preVisit returns.
2456        // When TVarDeclStmt nodes are visited, preVisit(TVarDeclStmt) will add them
2457        // to currentPlsqlBlockScope's namespace.
2458    }
2459
2460    @Override
2461    public void postVisit(TPlsqlCreateFunction stmt) {
2462        // Pop from scope stack
2463        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
2464            scopeStack.pop();
2465        }
2466
2467        // Restore previous PL/SQL block scope
2468        if (!plsqlBlockScopeStack.isEmpty()) {
2469            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2470        } else {
2471            currentPlsqlBlockScope = null;
2472        }
2473
2474        if (DEBUG_SCOPE_BUILD) {
2475            System.out.println("[DEBUG] postVisit(TPlsqlCreateFunction): restored scope");
2476        }
2477    }
2478
2479    // ========== CREATE PROCEDURE Statement (generic, including MySQL) ==========
2480
2481    @Override
2482    public void preVisit(TCreateProcedureStmt stmt) {
2483        // Save current PL/SQL block scope if nested
2484        if (currentPlsqlBlockScope != null) {
2485            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
2486        }
2487
2488        // Determine parent scope
2489        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2490
2491        // Get procedure name for the scope label
2492        String procedureName = null;
2493        if (stmt.getProcedureName() != null) {
2494            procedureName = stmt.getProcedureName().toString();
2495        }
2496
2497        // Create PL/SQL block scope using the procedure name as the label
2498        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, stmt, procedureName);
2499        currentPlsqlBlockScope = blockScope;
2500
2501        // Push to scope stack
2502        scopeStack.push(blockScope);
2503
2504        // Register procedure parameters in the variable namespace
2505        // This allows ScopeBuilder to filter them out during column reference collection
2506        TParameterDeclarationList params = stmt.getParameterDeclarations();
2507        if (params != null) {
2508            for (int i = 0; i < params.size(); i++) {
2509                TParameterDeclaration param = params.getParameterDeclarationItem(i);
2510                if (param != null && param.getParameterName() != null) {
2511                    blockScope.getVariableNamespace().addParameter(param.getParameterName().toString());
2512                }
2513            }
2514        }
2515
2516        if (DEBUG_SCOPE_BUILD) {
2517            System.out.println("[DEBUG] preVisit(TCreateProcedureStmt): name=" +
2518                (procedureName != null ? procedureName : "(unnamed)") +
2519                ", parent=" + parentScope +
2520                ", params=" + (params != null ? params.size() : 0));
2521        }
2522
2523        // Process declareStatements manually since TCreateProcedureStmt.acceptChildren()
2524        // doesn't traverse them. This adds variable declarations to the namespace
2525        // and marks their element names so they won't be collected as column references.
2526        for (TCustomSqlStatement decl : stmt.getDeclareStatements()) {
2527            if (decl instanceof TVarDeclStmt) {
2528                TVarDeclStmt varDecl = (TVarDeclStmt) decl;
2529                blockScope.getVariableNamespace().addVariable(varDecl);
2530                if (varDecl.getElementName() != null) {
2531                    variableDeclarationNames.add(varDecl.getElementName());
2532                }
2533                if (DEBUG_SCOPE_BUILD) {
2534                    String varName = varDecl.getElementName() != null ? varDecl.getElementName().toString() : "(unnamed)";
2535                    System.out.println("[DEBUG] TCreateProcedureStmt: added declare var=" + varName);
2536                }
2537            } else if (decl instanceof TDb2SqlVariableDeclaration) {
2538                // Handle DB2 variable declarations (DECLARE var_name TYPE)
2539                TDb2SqlVariableDeclaration db2VarDecl = (TDb2SqlVariableDeclaration) decl;
2540                if (db2VarDecl.getVariables() != null) {
2541                    for (int i = 0; i < db2VarDecl.getVariables().size(); i++) {
2542                        TDeclareVariable declVar = db2VarDecl.getVariables().getDeclareVariable(i);
2543                        if (declVar != null && declVar.getVariableName() != null) {
2544                            variableDeclarationNames.add(declVar.getVariableName());
2545                            blockScope.getVariableNamespace().addParameter(declVar.getVariableName().toString());
2546                            if (DEBUG_SCOPE_BUILD) {
2547                                System.out.println("[DEBUG] TCreateProcedureStmt (DB2): added declare var=" + declVar.getVariableName().toString());
2548                            }
2549                        }
2550                    }
2551                }
2552            }
2553            // For any declaration that might contain embedded statements (like cursor declarations),
2554            // traverse them so that column references inside are collected
2555            decl.acceptChildren(this);
2556        }
2557    }
2558
2559    @Override
2560    public void postVisit(TCreateProcedureStmt stmt) {
2561        // Pop from scope stack
2562        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
2563            scopeStack.pop();
2564        }
2565
2566        // Restore previous PL/SQL block scope
2567        if (!plsqlBlockScopeStack.isEmpty()) {
2568            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2569        } else {
2570            currentPlsqlBlockScope = null;
2571        }
2572
2573        if (DEBUG_SCOPE_BUILD) {
2574            System.out.println("[DEBUG] postVisit(TCreateProcedureStmt): restored scope");
2575        }
2576    }
2577
2578    // ========== MySQL CREATE PROCEDURE Statement (deprecated, but still in use) ==========
2579
2580    @Override
2581    public void preVisit(TMySQLCreateProcedure stmt) {
2582        // Save current PL/SQL block scope if nested
2583        if (currentPlsqlBlockScope != null) {
2584            plsqlBlockScopeStack.push(currentPlsqlBlockScope);
2585        }
2586
2587        // Determine parent scope
2588        IScope parentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2589
2590        // Get procedure name for the scope label
2591        String procedureName = null;
2592        if (stmt.getProcedureName() != null) {
2593            procedureName = stmt.getProcedureName().toString();
2594        }
2595
2596        // Create PL/SQL block scope using the procedure name as the label
2597        PlsqlBlockScope blockScope = new PlsqlBlockScope(parentScope, stmt, procedureName);
2598        currentPlsqlBlockScope = blockScope;
2599
2600        // Push to scope stack
2601        scopeStack.push(blockScope);
2602
2603        // Register procedure parameters in the variable namespace
2604        TParameterDeclarationList params = stmt.getParameterDeclarations();
2605        if (params != null) {
2606            for (int i = 0; i < params.size(); i++) {
2607                TParameterDeclaration param = params.getParameterDeclarationItem(i);
2608                if (param != null && param.getParameterName() != null) {
2609                    blockScope.getVariableNamespace().addParameter(param.getParameterName().toString());
2610                }
2611            }
2612        }
2613
2614        if (DEBUG_SCOPE_BUILD) {
2615            System.out.println("[DEBUG] preVisit(TMySQLCreateProcedure): name=" +
2616                (procedureName != null ? procedureName : "(unnamed)") +
2617                ", parent=" + parentScope +
2618                ", params=" + (params != null ? params.size() : 0));
2619        }
2620    }
2621
2622    @Override
2623    public void postVisit(TMySQLCreateProcedure stmt) {
2624        // Pop from scope stack
2625        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof PlsqlBlockScope) {
2626            scopeStack.pop();
2627        }
2628
2629        // Restore previous PL/SQL block scope
2630        if (!plsqlBlockScopeStack.isEmpty()) {
2631            currentPlsqlBlockScope = plsqlBlockScopeStack.pop();
2632        } else {
2633            currentPlsqlBlockScope = null;
2634        }
2635
2636        if (DEBUG_SCOPE_BUILD) {
2637            System.out.println("[DEBUG] postVisit(TMySQLCreateProcedure): restored scope");
2638        }
2639    }
2640
2641    // ========== Nested BEGIN...END Block (TBlockSqlStatement) ==========
2642    // This handles nested BEGIN blocks within stored procedures.
2643    // The nested block inherits the parent's variable namespace.
2644
2645    @Override
2646    public void preVisit(TBlockSqlStatement stmt) {
2647        // For nested blocks, we don't create a new PlsqlBlockScope.
2648        // The nested block inherits the enclosing PlsqlBlockScope's variable namespace.
2649        // This ensures variables declared in the parent block are visible in nested blocks.
2650        if (DEBUG_SCOPE_BUILD) {
2651            System.out.println("[DEBUG] preVisit(TBlockSqlStatement): nested block, " +
2652                "currentPlsqlBlockScope=" + (currentPlsqlBlockScope != null ? "exists" : "null"));
2653        }
2654        // No new scope is created - we just inherit the parent's scope
2655    }
2656
2657    @Override
2658    public void postVisit(TBlockSqlStatement stmt) {
2659        // Nothing to pop since we didn't push a new scope
2660        if (DEBUG_SCOPE_BUILD) {
2661            System.out.println("[DEBUG] postVisit(TBlockSqlStatement): exiting nested block");
2662        }
2663    }
2664
2665    // ========== FOR Loop Statement (DB2, PL/SQL) ==========
2666    // FOR loop statements have a cursor subquery that needs explicit traversal.
2667    // The TForStmt.acceptChildren() calls subquery.accept() which only calls preVisit/postVisit
2668    // but doesn't traverse the SELECT statement's children. We need to explicitly traverse it.
2669
2670    @Override
2671    public void preVisit(TForStmt stmt) {
2672        // Explicitly traverse the FOR loop's cursor subquery
2673        // This is needed because TForStmt.acceptChildren() calls subquery.accept()
2674        // which doesn't traverse the SELECT's children (tables, columns, etc.)
2675        if (stmt.getSubquery() != null) {
2676            stmt.getSubquery().acceptChildren(this);
2677        }
2678
2679        if (DEBUG_SCOPE_BUILD) {
2680            System.out.println("[DEBUG] preVisit(TForStmt): traversed cursor subquery");
2681        }
2682    }
2683
2684    // ========== MERGE Statement ==========
2685
2686    @Override
2687    public void preVisit(TMergeSqlStatement stmt) {
2688        // Determine parent scope
2689        IScope parentScope = determineParentScopeForMerge(stmt);
2690
2691        // Create MergeScope
2692        MergeScope mergeScope = new MergeScope(parentScope, stmt);
2693        mergeScopeMap.put(stmt, mergeScope);
2694
2695        // Push to stack
2696        scopeStack.push(mergeScope);
2697        currentMergeScope = mergeScope;
2698
2699        // Set the current MERGE target table for UPDATE SET and INSERT column linking
2700        currentMergeTargetTable = stmt.getTargetTable();
2701
2702        if (DEBUG_SCOPE_BUILD) {
2703            String stmtPreview = stmt.toString().length() > 50
2704                ? stmt.toString().substring(0, 50) + "..."
2705                : stmt.toString();
2706            System.out.println("[DEBUG] preVisit(MERGE): " + stmtPreview.replace("\n", " ") +
2707                ", targetTable=" + (currentMergeTargetTable != null ? currentMergeTargetTable.getName() : "null"));
2708        }
2709
2710        // Create FromScope for MERGE's tables (target table + using table)
2711        if (stmt.tables != null && stmt.tables.size() > 0) {
2712            // Save current FROM scope if any
2713            if (currentFromScope != null) {
2714                fromScopeStack.push(currentFromScope);
2715            }
2716
2717            // Create FromScope for MERGE's tables
2718            FromScope fromScope = new FromScope(mergeScope, stmt.tables);
2719            mergeScope.setFromScope(fromScope);
2720            currentFromScope = fromScope;
2721
2722            // Tables will be processed when acceptChildren() visits TTable nodes
2723        }
2724
2725        // Process MERGE ON clause condition for Teradata only
2726        // In Teradata, unqualified columns on the left side of MERGE ON clause comparisons
2727        // should be linked to the target table (not the source subquery)
2728        if (dbVendor == EDbVendor.dbvteradata && stmt.getCondition() != null && currentMergeTargetTable != null) {
2729            processMergeOnCondition(stmt.getCondition());
2730        }
2731    }
2732
2733    /**
2734     * Process MERGE ON clause condition for Teradata.
2735     * For comparison expressions like "x1=10" or "x1=s.col", if the left operand
2736     * is an unqualified column (no table prefix), link it to the MERGE target table.
2737     *
2738     * This implements the Teradata-specific rule: in MERGE/USING/ON clause, unqualified columns
2739     * on the left side of comparisons should be linked to the target table.
2740     * This behavior is NOT applied to other databases as their column resolution rules differ.
2741     */
2742    private void processMergeOnCondition(TExpression condition) {
2743        if (condition == null) {
2744            return;
2745        }
2746
2747        // Use iterative DFS to avoid StackOverflowError for deeply nested AND/OR chains
2748        Deque<TExpression> stack = new ArrayDeque<>();
2749        stack.push(condition);
2750        while (!stack.isEmpty()) {
2751            TExpression current = stack.pop();
2752            if (current == null) continue;
2753
2754            if (DEBUG_SCOPE_BUILD) {
2755                System.out.println("[DEBUG] processMergeOnCondition: type=" + current.getExpressionType() +
2756                        ", expr=" + current.toString().replace("\n", " "));
2757            }
2758
2759            // Handle comparison expressions (e.g., x1=10, x1=s.col)
2760            if (current.getExpressionType() == EExpressionType.simple_comparison_t) {
2761                TExpression leftOperand = current.getLeftOperand();
2762                if (leftOperand != null &&
2763                    leftOperand.getExpressionType() == EExpressionType.simple_object_name_t &&
2764                    leftOperand.getObjectOperand() != null) {
2765
2766                    TObjectName leftColumn = leftOperand.getObjectOperand();
2767                    // Check if column is unqualified (no table prefix)
2768                    if (leftColumn.getTableToken() == null) {
2769                        // Link to MERGE target table
2770                        leftColumn.setSourceTable(currentMergeTargetTable);
2771                        traceDirectBinding(leftColumn,
2772                                gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.MERGE_TARGET,
2773                                currentMergeTargetTable);
2774                        // Add to target table's linked columns directly
2775                        currentMergeTargetTable.getLinkedColumns().addObjectName(leftColumn);
2776                        allColumnReferences.add(leftColumn);
2777                        // Mark as ON clause target column - should NOT be re-resolved through name resolution
2778                        // This prevents the column from being incorrectly linked to the USING subquery
2779                        setClauseTargetColumns.add(leftColumn);
2780                        // Add to columnToScopeMap with the current MergeScope
2781                        if (currentMergeScope != null) {
2782                            columnToScopeMap.put(leftColumn, currentMergeScope);
2783                        }
2784                        if (DEBUG_SCOPE_BUILD) {
2785                            System.out.println("[DEBUG] Linked MERGE ON clause left-side column: " +
2786                                    leftColumn.toString() + " -> " + currentMergeTargetTable.getFullName());
2787                        }
2788                    }
2789                }
2790            }
2791
2792            // Iteratively process compound conditions (AND, OR, parenthesis)
2793            if (current.getExpressionType() == EExpressionType.logical_and_t ||
2794                current.getExpressionType() == EExpressionType.logical_or_t ||
2795                current.getExpressionType() == EExpressionType.parenthesis_t) {
2796                if (current.getRightOperand() != null) stack.push(current.getRightOperand());
2797                if (current.getLeftOperand() != null) stack.push(current.getLeftOperand());
2798            }
2799        }
2800    }
2801
2802    @Override
2803    public void postVisit(TMergeSqlStatement stmt) {
2804        // Pop CTEScope if present (left on the stack by postVisit(TCTEList)
2805        // when MERGE has a WITH clause; the parent statement is responsible
2806        // for cleanup — mirrors postVisit(TSelectSqlStatement) lines 649-653).
2807        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof CTEScope) {
2808            scopeStack.pop();
2809            currentCTEScope = findEnclosingCTEScope();
2810        }
2811
2812        // Pop scope
2813        if (!scopeStack.isEmpty() && scopeStack.peek() instanceof MergeScope) {
2814            scopeStack.pop();
2815        }
2816
2817        // Restore current MergeScope
2818        currentMergeScope = findEnclosingMergeScope();
2819
2820        // Clear MERGE target table
2821        currentMergeTargetTable = null;
2822
2823        // Restore FROM scope
2824        if (!fromScopeStack.isEmpty()) {
2825            currentFromScope = fromScopeStack.pop();
2826        } else {
2827            currentFromScope = null;
2828        }
2829    }
2830
2831    /**
2832     * Determine the parent scope for a MERGE statement.
2833     */
2834    private IScope determineParentScopeForMerge(TMergeSqlStatement stmt) {
2835        // If we have a CTE scope on the stack, use it
2836        if (currentCTEScope != null) {
2837            return currentCTEScope;
2838        }
2839
2840        // Otherwise, find appropriate parent from stack
2841        for (int i = scopeStack.size() - 1; i >= 0; i--) {
2842            IScope scope = scopeStack.get(i);
2843            if (scope instanceof SelectScope || scope instanceof UpdateScope ||
2844                scope instanceof MergeScope || scope instanceof CTEScope ||
2845                scope instanceof PlsqlBlockScope ||
2846                scope instanceof GlobalScope) {
2847                return scope;
2848            }
2849        }
2850
2851        return scopeStack.isEmpty() ? globalScope : scopeStack.peek();
2852    }
2853
2854    /**
2855     * Find the enclosing MergeScope in the stack
2856     */
2857    private MergeScope findEnclosingMergeScope() {
2858        for (int i = scopeStack.size() - 1; i >= 0; i--) {
2859            IScope scope = scopeStack.get(i);
2860            if (scope instanceof MergeScope) {
2861                return (MergeScope) scope;
2862            }
2863        }
2864        return null;
2865    }
2866
2867    // ========== MERGE UPDATE Clause ==========
2868
2869    @Override
2870    public void preVisit(TMergeUpdateClause updateClause) {
2871        // Handle MERGE UPDATE SET clause columns
2872        // The left-hand side of SET assignments (e.g., SET col = expr)
2873        // should be linked to the MERGE target table
2874        if (currentMergeTargetTable != null && updateClause.getUpdateColumnList() != null) {
2875            TResultColumnList updateColumns = updateClause.getUpdateColumnList();
2876            for (int i = 0; i < updateColumns.size(); i++) {
2877                TResultColumn rc = updateColumns.getResultColumn(i);
2878                if (rc != null && rc.getExpr() != null) {
2879                    TExpression expr = rc.getExpr();
2880                    // SET clause uses assignment_t for "column = value" assignments
2881                    // or simple_comparison_t in some databases
2882                    if ((expr.getExpressionType() == EExpressionType.assignment_t ||
2883                         expr.getExpressionType() == EExpressionType.simple_comparison_t) &&
2884                        expr.getLeftOperand() != null &&
2885                        expr.getLeftOperand().getExpressionType() == EExpressionType.simple_object_name_t &&
2886                        expr.getLeftOperand().getObjectOperand() != null) {
2887                        TObjectName leftColumn = expr.getLeftOperand().getObjectOperand();
2888                        // Link the SET clause column to the MERGE target table
2889                        leftColumn.setSourceTable(currentMergeTargetTable);
2890                        traceDirectBinding(leftColumn,
2891                                gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.MERGE_TARGET,
2892                                currentMergeTargetTable);
2893                        allColumnReferences.add(leftColumn);
2894                        // Mark as SET clause target - should NOT be re-resolved through star column
2895                        setClauseTargetColumns.add(leftColumn);
2896                        // Add to columnToScopeMap with the current MergeScope
2897                        if (currentMergeScope != null) {
2898                            columnToScopeMap.put(leftColumn, currentMergeScope);
2899                        }
2900                        if (DEBUG_SCOPE_BUILD) {
2901                            System.out.println("[DEBUG] Linked MERGE UPDATE SET column: " +
2902                                    leftColumn.toString() + " -> " + currentMergeTargetTable.getFullName());
2903                        }
2904                    }
2905                }
2906            }
2907        }
2908    }
2909
2910    // ========== MERGE INSERT Clause ==========
2911
2912    @Override
2913    public void preVisit(TMergeInsertClause insertClause) {
2914        // Handle MERGE INSERT clause columns
2915        // The column list in INSERT (column_list) should be linked to the MERGE target table
2916        if (currentMergeTargetTable != null && insertClause.getColumnList() != null) {
2917            TObjectNameList columnList = insertClause.getColumnList();
2918            for (int i = 0; i < columnList.size(); i++) {
2919                TObjectName column = columnList.getObjectName(i);
2920                if (column != null) {
2921                    // Link the INSERT column to the MERGE target table
2922                    column.setSourceTable(currentMergeTargetTable);
2923                    traceDirectBinding(column,
2924                            gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.MERGE_TARGET,
2925                            currentMergeTargetTable);
2926                    allColumnReferences.add(column);
2927                    // Mark as target column - should NOT be re-resolved through name resolution
2928                    // (same as UPDATE SET clause left-side columns)
2929                    setClauseTargetColumns.add(column);
2930                    // Add to columnToScopeMap with the current MergeScope
2931                    if (currentMergeScope != null) {
2932                        columnToScopeMap.put(column, currentMergeScope);
2933                    }
2934                    if (DEBUG_SCOPE_BUILD) {
2935                        System.out.println("[DEBUG] Linked MERGE INSERT column: " +
2936                                column.toString() + " -> " + currentMergeTargetTable.getFullName());
2937                    }
2938                }
2939            }
2940        }
2941
2942        // Handle MERGE INSERT VALUES clause columns
2943        // The VALUES list columns (e.g., VALUES(product, quantity)) should be linked to the USING table (source table)
2944        // In MERGE semantics, WHEN NOT MATCHED means the row exists in the source but not in the target,
2945        // so unqualified column references in VALUES refer to the source (USING) table.
2946        if (currentMergeScope != null && insertClause.getValuelist() != null) {
2947            TTable usingTable = currentMergeScope.getMergeStatement().getUsingTable();
2948            if (usingTable != null) {
2949                TResultColumnList valueList = insertClause.getValuelist();
2950                for (int i = 0; i < valueList.size(); i++) {
2951                    TResultColumn rc = valueList.getResultColumn(i);
2952                    if (rc != null && rc.getExpr() != null) {
2953                        TExpression expr = rc.getExpr();
2954                        // Handle simple column references in VALUES clause
2955                        if (expr.getExpressionType() == EExpressionType.simple_object_name_t &&
2956                            expr.getObjectOperand() != null) {
2957                            TObjectName valueColumn = expr.getObjectOperand();
2958                            // An unqualified name that the USING source provably does not project
2959                            // is not a column of anything visible here: the target row does not
2960                            // exist yet in a WHEN NOT MATCHED branch, so the source is the only
2961                            // relation in scope. Leave it unresolved instead of fabricating a link
2962                            // to the USING table.
2963                            if (!valueColumn.isQualified() &&
2964                                usingSourceProvablyLacksColumn(usingTable, valueColumn)) {
2965                                mergeInsertValuesUnboundColumns.add(valueColumn);
2966                                if (DEBUG_SCOPE_BUILD) {
2967                                    System.out.println("[DEBUG] MERGE VALUES column not projected by USING source, " +
2968                                            "left unresolved: " + valueColumn.toString());
2969                                }
2970                                continue;
2971                            }
2972                            // Link the VALUES column to the USING table (source)
2973                            valueColumn.setSourceTable(usingTable);
2974                            traceDirectBinding(valueColumn,
2975                                    gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.MERGE_USING,
2976                                    usingTable);
2977                            allColumnReferences.add(valueColumn);
2978                            // Only track UNQUALIFIED columns for resolution restoration.
2979                            // Qualified columns (e.g., v.id, s.s_a) are correctly resolved by
2980                            // name resolution through their table prefix. Unqualified columns
2981                            // may get an AMBIGUOUS resolution when the column name exists in
2982                            // both target and source tables. For these, we need to clear the
2983                            // AMBIGUOUS resolution and force sourceTable to the USING table.
2984                            if (!valueColumn.isQualified()) {
2985                                mergeInsertValuesColumns.put(valueColumn, usingTable);
2986                            }
2987                            columnToScopeMap.put(valueColumn, currentMergeScope);
2988                            if (DEBUG_SCOPE_BUILD) {
2989                                System.out.println("[DEBUG] Linked MERGE VALUES column: " +
2990                                        valueColumn.toString() + " -> " + usingTable.getFullName());
2991                            }
2992                        }
2993                    }
2994                }
2995            }
2996        }
2997    }
2998
2999    /**
3000     * Answers whether the MERGE USING source <b>provably</b> does not expose a column named
3001     * <code>column</code>.
3002     *
3003     * <p>Absence can only be proven when EVERY name the source exposes is enumerable from the
3004     * AST. That holds for a plain derived table — {@code USING (SELECT a, b FROM t) SRC} — each
3005     * of whose select-list items is either aliased or a simple column reference. Every other
3006     * shape has names this method cannot enumerate, and each one makes it return {@code false}
3007     * so the caller keeps its existing binding and no provable link is ever dropped:</p>
3008     *
3009     * <ul>
3010     *   <li>a physical table, CTE reference or table function — no projection in the AST at all;</li>
3011     *   <li>a star, which hides both the names and the arity;</li>
3012     *   <li>a set operation, whose result schema depends on how branches are matched
3013     *       ({@code UNION ALL BY NAME} exposes names present only on the right);</li>
3014     *   <li>a computed item, which several vendors expose under an implicit name derived from
3015     *       the expression ({@code SELECT lower(name)} is referenceable as {@code lower});</li>
3016     *   <li>a tuple alias ({@code posexplode(x) AS (i, a)}), which contributes several names;</li>
3017     *   <li>a struct path ({@code s.nested.field}), whose exposed name is the last segment.</li>
3018     * </ul>
3019     */
3020    private boolean usingSourceProvablyLacksColumn(TTable usingTable, TObjectName column) {
3021        if (usingTable == null || column == null) {
3022            return false;
3023        }
3024        TSelectSqlStatement subquery = usingTable.getSubquery();
3025        if (subquery == null) {
3026            return false;
3027        }
3028        if (subquery.getSetOperatorType() != ESetOperatorType.none) {
3029            return false;
3030        }
3031        TResultColumnList projection = subquery.getResultColumnList();
3032        if (projection == null || projection.size() == 0) {
3033            return false;
3034        }
3035        String columnName = referenceableName(column);
3036        if (columnName == null) {
3037            return false;
3038        }
3039
3040        // A derived table may rename its projection — (SELECT ...) AS s(x, y) — and those names
3041        // replace the select list's position by position. A list shorter than the projection
3042        // leaves the remaining columns named by the select list itself.
3043        TObjectNameList renames = usingTable.getAliasClause() != null
3044                ? usingTable.getAliasClause().getColumns() : null;
3045        int renameCount = renames == null ? 0 : renames.size();
3046        if (renameCount > projection.size()) {
3047            return false;   // more names than columns: not a shape we can reason about
3048        }
3049
3050        for (int i = 0; i < projection.size(); i++) {
3051            TResultColumn rc = projection.getResultColumn(i);
3052            if (rc == null || rc.getExpr() == null) {
3053                return false;   // cannot describe this item -> projection is not fully known
3054            }
3055
3056            // Work out the item's own exposed name first. It is needed even for renamed
3057            // positions, because a shape whose name cannot be enumerated (a star, a tuple
3058            // alias, a computed item) also leaves the arity in doubt, and then the rename
3059            // list may cover only a prefix of the real columns.
3060            String ownName = null;
3061            if (rc.getAliasClause() != null) {
3062                TObjectNameList tupleColumns = rc.getAliasClause().getColumns();
3063                if (tupleColumns != null && tupleColumns.size() > 0) {
3064                    return false;   // one item, several names
3065                }
3066                if (rc.getAliasClause().getAliasName() != null) {
3067                    ownName = referenceableName(rc.getAliasClause().getAliasName());
3068                    if (ownName == null) {
3069                        // An alias replaces the item's own name, so an alias we cannot read
3070                        // leaves this position unknown — reading through to the expression
3071                        // underneath would name the column something it is not. A quoted
3072                        // alias containing a period, "a.b", lands here.
3073                        return false;
3074                    }
3075                }
3076            }
3077            if (ownName == null) {
3078                if (rc.getExpr().getExpressionType() != EExpressionType.simple_object_name_t ||
3079                    rc.getExpr().getObjectOperand() == null) {
3080                    return false;   // computed item: the implicit name is vendor-defined
3081                }
3082                TObjectName projectedName = rc.getExpr().getObjectOperand();
3083                // Detect the wildcard structurally: the printed form carries whatever trivia sat
3084                // around the dot, so "s . *" never matches a text test for a trailing ".*".
3085                if ("*".equals(projectedName.getColumnNameOnly())) {
3086                    return false;   // star: names and arity both unknown
3087                }
3088                String text = projectedName.toString();
3089                if (text != null && (text.equals("*") || text.endsWith(".*"))) {
3090                    return false;
3091                }
3092                if (projectedName.getPropertyToken() != null) {
3093                    return false;   // struct path: exposed under its last segment
3094                }
3095                ownName = referenceableName(projectedName);
3096                if (ownName == null) {
3097                    return false;
3098                }
3099            }
3100
3101            String projected = i < renameCount
3102                    ? referenceableName(renames.getObjectName(i))
3103                    : ownName;
3104            if (projected == null) {
3105                return false;   // renamed to something we cannot read -> not fully known
3106            }
3107            if (SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotColumn, projected, columnName)) {
3108                return false;
3109            }
3110        }
3111        return true;
3112    }
3113
3114    /**
3115     * The single-segment name an object can be referenced by, or {@code null} when it cannot be
3116     * determined. Alias names carry no column part token, so they fall back to their source text;
3117     * anything that still looks qualified is reported as undeterminable rather than guessed at.
3118     */
3119    private static String referenceableName(TObjectName name) {
3120        if (name == null) {
3121            return null;
3122        }
3123        String part = name.getColumnNameOnly();
3124        if (part != null && part.length() > 0) {
3125            return part;
3126        }
3127        String text = name.toString();
3128        if (text == null || text.length() == 0 || text.indexOf('.') >= 0) {
3129            return null;
3130        }
3131        return text;
3132    }
3133
3134    // ========== CTE (WITH Clause) ==========
3135
3136    @Override
3137    public void preVisit(TCTEList cteList) {
3138        // Create CTEScope
3139        IScope parentScope = scopeStack.peek();
3140        CTEScope cteScope = new CTEScope(parentScope, cteList);
3141
3142        // Push to stack
3143        scopeStack.push(cteScope);
3144        currentCTEScope = cteScope;
3145    }
3146
3147    @Override
3148    public void postVisit(TCTEList cteList) {
3149        // DON'T pop CTEScope here - leave it on stack so the main SELECT
3150        // can reference CTEs in its FROM clause. CTEScope will be popped
3151        // in postVisit(TSelectSqlStatement) after the entire SELECT is processed.
3152        //
3153        // Only clear currentCTEScope so new CTEs aren't added to it
3154        // (but it remains accessible via the stack for CTE lookups)
3155    }
3156
3157    @Override
3158    public void preVisit(TCTE cte) {
3159        // Track CTE definition depth for CTAS handling
3160        cteDefinitionDepth++;
3161
3162        if (currentCTEScope == null) {
3163            return;
3164        }
3165
3166        // Get CTE name
3167        String cteName = cte.getTableName() != null ? cte.getTableName().toString() : null;
3168        if (cteName == null) {
3169            return;
3170        }
3171
3172        // Mark CTE table name as a table reference (not column)
3173        if (cte.getTableName() != null) {
3174            tableNameReferences.add(cte.getTableName());
3175        }
3176
3177        // Create CTENamespace and add to CTEScope BEFORE processing subquery
3178        // This allows later CTEs to reference earlier ones
3179        TSelectSqlStatement subquery = cte.getSubquery();
3180        CTENamespace cteNamespace = new CTENamespace(cte, cteName, subquery, nameMatcher);
3181
3182        // Add to CTE scope immediately (enables forward references)
3183        currentCTEScope.addCTE(cteName, cteNamespace);
3184
3185        // Note: The subquery will be processed when acceptChildren traverses into it
3186        // At that point, preVisit(TSelectSqlStatement) will be called with currentCTEScope set
3187    }
3188
3189    @Override
3190    public void postVisit(TCTE cte) {
3191        // Track CTE definition depth for CTAS handling
3192        if (cteDefinitionDepth > 0) {
3193            cteDefinitionDepth--;
3194        }
3195
3196        if (currentCTEScope == null) {
3197            return;
3198        }
3199
3200        // Validate the CTENamespace after subquery processing is complete
3201        String cteName = cte.getTableName() != null ? cte.getTableName().toString() : null;
3202        if (cteName != null) {
3203            CTENamespace cteNamespace = currentCTEScope.getCTE(cteName);
3204            if (cteNamespace != null) {
3205                cteNamespace.validate();
3206            }
3207        }
3208    }
3209
3210    /**
3211     * Find the enclosing CTEScope in the stack
3212     */
3213    private CTEScope findEnclosingCTEScope() {
3214        for (int i = scopeStack.size() - 1; i >= 0; i--) {
3215            IScope scope = scopeStack.get(i);
3216            if (scope instanceof CTEScope) {
3217                return (CTEScope) scope;
3218            }
3219        }
3220        return null;
3221    }
3222
3223    // ========== FROM Clause ==========
3224
3225    @Override
3226    public void preVisit(TFromClause fromClause) {
3227        if (DEBUG_SCOPE_BUILD) {
3228            System.out.println("[DEBUG] preVisit(TFromClause): currentSelectScope=" +
3229                (currentSelectScope != null ? "exists" : "NULL"));
3230        }
3231
3232        if (currentSelectScope == null) {
3233            return;
3234        }
3235
3236        // Save current FROM scope for nested subqueries (e.g., in JOINs)
3237        // This is critical: when processing a JOIN, the left subquery's FROM clause
3238        // will be visited before the right subquery. We need to restore the outer
3239        // FROM scope after processing each inner subquery.
3240        if (currentFromScope != null) {
3241            fromScopeStack.push(currentFromScope);
3242        }
3243
3244        // Reset join chain tables for this FROM clause
3245        // This tracks ALL tables in chained JOINs for proper USING column resolution
3246        currentJoinChainTables.clear();
3247
3248        // Create FromScope
3249        FromScope fromScope = new FromScope(currentSelectScope, fromClause);
3250        currentSelectScope.setFromScope(fromScope);
3251
3252        // Track current FromScope
3253        currentFromScope = fromScope;
3254
3255        if (DEBUG_SCOPE_BUILD) {
3256            System.out.println("[DEBUG]   Created FromScope, linked to SelectScope");
3257        }
3258    }
3259
3260    @Override
3261    public void postVisit(TFromClause fromClause) {
3262        // Restore previous FROM scope (for nested subqueries in JOINs)
3263        // IMPORTANT: Do NOT clear currentFromScope if stack is empty!
3264        // We need to keep the FROM scope available for the SELECT list expressions
3265        // (e.g., function calls, column references) which are visited after FROM clause.
3266        // The FROM scope will be cleared when the SELECT statement ends.
3267        if (!fromScopeStack.isEmpty()) {
3268            currentFromScope = fromScopeStack.pop();
3269        }
3270        // Note: If stack is empty, we keep currentFromScope as is - it will be cleared
3271        // in postVisit(TSelectSqlStatement) or when the enclosing statement ends.
3272    }
3273
3274    // ========== Table ==========
3275
3276    @Override
3277    public void preVisit(TTable table) {
3278        // Mark table name as a table reference (not column)
3279        if (table.getTableName() != null) {
3280            tableNameReferences.add(table.getTableName());
3281        }
3282
3283        if (DEBUG_SCOPE_BUILD) {
3284            System.out.println("[DEBUG] preVisit(TTable): " + table.getDisplayName() +
3285                " type=" + table.getTableType() +
3286                " currentFromScope=" + (currentFromScope != null ? "exists" : "NULL"));
3287        }
3288
3289        // Only process if we have a FROM scope
3290        if (currentFromScope == null) {
3291            return;
3292        }
3293
3294        // Handle based on table type
3295        ETableSource tableType = table.getTableType();
3296
3297        switch (tableType) {
3298            case objectname:
3299                processPhysicalTable(table);
3300                break;
3301
3302            case subquery:
3303                processSubqueryTable(table);
3304                break;
3305
3306            case join:
3307                // JOIN is handled via TJoinExpr
3308                // Left and right tables will be visited separately
3309                break;
3310
3311            case function:
3312                // Table-valued function - treat similar to table
3313                processTableFunction(table);
3314                break;
3315
3316            case pivoted_table:
3317                // PIVOT table - creates new columns from IN clause
3318                processPivotTable(table);
3319                break;
3320
3321            case unnest:
3322                // UNNEST table - creates virtual table from array
3323                processUnnestTable(table);
3324                break;
3325
3326            case rowList:
3327                // VALUES table - inline data with optional column aliases
3328                // e.g., VALUES (1, 'a'), (2, 'b') AS t(id, name)
3329                processValuesTable(table);
3330                break;
3331
3332            case stageReference:
3333                // Snowflake stage file reference (e.g., @stage/path)
3334                // Treat similar to a regular table but without metadata
3335                processStageTable(table);
3336                break;
3337
3338            case td_unpivot:
3339                // Teradata TD_UNPIVOT table function - collect columns from parameters
3340                processTDUnpivotTable(table);
3341                break;
3342
3343            default:
3344                // Other types (CTE reference, etc.)
3345                processCTEReference(table);
3346                break;
3347        }
3348    }
3349
3350    @Override
3351    public void postVisit(TTable table) {
3352        // Decrement the PIVOT source processing depth when exiting a pivot table.
3353        // This must happen BEFORE SubqueryNamespace validation so the flag is correctly
3354        // restored for any subsequent tables.
3355        if (table.getTableType() == ETableSource.pivoted_table) {
3356            if (pivotSourceProcessingDepth > 0) {
3357                pivotSourceProcessingDepth--;
3358            }
3359        }
3360
3361        // Validate SubqueryNamespace after subquery content is fully processed
3362        // This is critical because SubqueryNamespace.doValidate() needs to read
3363        // the subquery's SELECT list, which is only complete after traversal
3364        SubqueryNamespace subNs = pendingSubqueryValidation.remove(table);
3365        if (subNs != null) {
3366            subNs.validate();
3367        }
3368    }
3369
3370    /**
3371     * Process a physical table reference
3372     */
3373    private void processPhysicalTable(TTable table) {
3374        // Check if this table references a CTE
3375        String tableName = table.getName();
3376        CTENamespace cteNamespace = findCTEByName(tableName);
3377
3378        // A bare-name match to an enclosing CTE is not always a CTE reference.
3379        // Skip the match when:
3380        //   1. The table has a schema/database/server prefix (e.g.
3381        //      "ZZZ_DEV.FOTC_BB_PRODUCT_INTER") — CTE names are unqualified.
3382        //   2. The table is inside the CTE's own definition and the CTE is
3383        //      non-recursive — non-recursive CTEs are not visible to themselves
3384        //      (Mantis #4412).
3385        if (cteNamespace != null && shouldTreatAsPhysicalTableNotCTE(table, cteNamespace)) {
3386            cteNamespace = null;
3387        }
3388
3389        if (cteNamespace != null) {
3390            // This is a CTE reference, use the existing CTENamespace
3391            String alias = getTableAlias(table);
3392
3393            // When the same CTE is referenced more than once in a FROM clause
3394            // with different aliases (e.g. FROM cte a, cte b), each reference
3395            // needs its own CTENamespace so getSourceTable() reports the right
3396            // alias. The single definition instance can only carry one
3397            // referencingTable, so reusing it makes every reference resolve to
3398            // the LAST reference's table (Mantis #4545).
3399            //
3400            // Reuse the definition instance for the FIRST outer reference (and
3401            // for self-references inside the still-unvalidated definition body,
3402            // to keep recursive-CTE behavior unchanged). Subsequent outer
3403            // references get an independent per-reference copy.
3404            CTENamespace refNamespace;
3405            if (!cteNamespace.isValidated() || !cteNamespace.isBoundToOuterReference()) {
3406                refNamespace = cteNamespace;
3407                if (cteNamespace.isValidated()) {
3408                    cteNamespace.setBoundToOuterReference(true);
3409                }
3410            } else {
3411                refNamespace = cteNamespace.createReference();
3412                refNamespace.validate();
3413            }
3414            refNamespace.setReferencingTable(table);  // Set the referencing TTable for getFinalTable() fallback
3415            currentFromScope.addChild(refNamespace, alias, false);
3416            lastProcessedFromTable = table;  // Track for JOIN...USING left table detection
3417            currentJoinChainTables.add(table);  // Track ALL tables in join chain for chained USING
3418            if (DEBUG_SCOPE_BUILD) {
3419                System.out.println("[DEBUG] Added CTE to FromScope: alias=" + alias);
3420            }
3421        } else {
3422            // Check if this is a SQL Server virtual table (deleted/inserted)
3423            // These can appear in:
3424            // 1. CREATE TRIGGER bodies - should reference the trigger's target table
3425            // 2. OUTPUT clauses of INSERT/UPDATE/DELETE - handled by preVisit(TOutputClause)
3426            //
3427            // IMPORTANT: Only substitute deleted/inserted when inside a TRIGGER context.
3428            // In DML (INSERT/UPDATE/DELETE) context, deleted/inserted in FROM clause
3429            // are regular table references (could be trigger pseudo-tables from an outer trigger).
3430            // The OUTPUT clause pseudo-columns are handled separately in preVisit(TOutputClause).
3431            TTable effectiveTable = table;
3432            if (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql) {
3433                String upperName = tableName != null ? tableName.toUpperCase() : "";
3434                if ("DELETED".equals(upperName) || "INSERTED".equals(upperName)) {
3435                    // Only substitute when inside a TRIGGER (not just any DML statement)
3436                    if (currentTriggerTargetTable != null) {
3437                        effectiveTable = currentTriggerTargetTable;
3438                        // Track this table as a virtual trigger table (to be skipped in table output)
3439                        virtualTriggerTables.add(table);
3440                        if (DEBUG_SCOPE_BUILD) {
3441                            System.out.println("[DEBUG] Substituting virtual table '" + tableName +
3442                                "' with trigger target table '" + currentTriggerTargetTable.getName() + "'");
3443                        }
3444                    }
3445                }
3446            }
3447
3448            // Regular physical table - pass TSQLEnv for metadata lookup
3449            TableNamespace tableNs = new TableNamespace(effectiveTable, nameMatcher, sqlEnv);
3450            tableNs.validate();
3451            String alias = getTableAlias(table);
3452
3453            // Add to FROM scope ONLY if not inside a PIVOT/UNPIVOT source processing context.
3454            // When a table is the source of a PIVOT/UNPIVOT, only the PIVOT/UNPIVOT table
3455            // should be visible in the outer query, not the source table itself.
3456            if (pivotSourceProcessingDepth == 0) {
3457                currentFromScope.addChild(tableNs, alias, false);
3458                if (DEBUG_SCOPE_BUILD) {
3459                    System.out.println("[DEBUG] Added table to FromScope: alias=" + alias + " tableName=" + tableName +
3460                        " hasMetadata=" + (tableNs.getResolvedTable() != null));
3461                }
3462            } else {
3463                if (DEBUG_SCOPE_BUILD) {
3464                    System.out.println("[DEBUG] Skipping table from FromScope (inside PIVOT source): alias=" + alias);
3465                }
3466            }
3467
3468            lastProcessedFromTable = table;  // Track for JOIN...USING left table detection
3469            currentJoinChainTables.add(table);  // Track ALL tables in join chain for chained USING
3470            tableToNamespaceMap.put(table, tableNs);  // Store for legacy compatibility
3471        }
3472    }
3473
3474    /**
3475     * Process a subquery in FROM clause
3476     */
3477    private void processSubqueryTable(TTable table) {
3478        TSelectSqlStatement subquery = table.getSubquery();
3479        if (subquery == null) {
3480            return;
3481        }
3482
3483        // Track column name definitions from the alias clause
3484        // These are column DEFINITIONS, not references - should NOT be collected as column refs
3485        // e.g., in "FROM (SELECT ...) AS t(id, name)", 'id' and 'name' are definitions
3486        TAliasClause aliasClause = table.getAliasClause();
3487        if (aliasClause != null) {
3488            TObjectNameList columns = aliasClause.getColumns();
3489            if (columns != null && columns.size() > 0) {
3490                for (int i = 0; i < columns.size(); i++) {
3491                    TObjectName colName = columns.getObjectName(i);
3492                    if (colName != null) {
3493                        valuesTableAliasColumns.add(colName);
3494                        if (DEBUG_SCOPE_BUILD) {
3495                            System.out.println("[DEBUG] Tracked subquery alias column definition (will skip): " + colName.toString());
3496                        }
3497                    }
3498                }
3499            }
3500        }
3501
3502        String alias = table.getAliasName();
3503        INamespace namespace;
3504
3505        // Check if this is a TABLE function (Oracle TABLE(SELECT ...) syntax)
3506        // When isTableKeyword() is true, the subquery is wrapped in a TABLE() function
3507        boolean isTableFunction = table.isTableKeyword();
3508
3509        // Check if this is a UNION/INTERSECT/EXCEPT query
3510        if (subquery.isCombinedQuery()) {
3511            // Create UnionNamespace for set operations
3512            UnionNamespace unionNs = new UnionNamespace(subquery, alias, nameMatcher);
3513            namespace = unionNs;
3514
3515            if (DEBUG_SCOPE_BUILD) {
3516                System.out.println("[DEBUG]   Detected UNION query with " +
3517                    unionNs.getBranchCount() + " branches");
3518            }
3519        } else {
3520            // Regular subquery - create SubqueryNamespace
3521            // Pass isTableFunction to mark TABLE function subqueries for expression alias filtering
3522            SubqueryNamespace subNs = new SubqueryNamespace(subquery, alias, nameMatcher, isTableFunction);
3523            // Pass guessColumnStrategy for config-based isolation (prevents test side effects)
3524            if (guessColumnStrategy >= 0) {
3525                subNs.setGuessColumnStrategy(guessColumnStrategy);
3526            }
3527            // Pass sqlEnv for metadata lookup during star column resolution
3528            if (sqlEnv != null) {
3529                subNs.setSqlEnv(sqlEnv);
3530            }
3531            // Set the owning TTable for legacy sync support
3532            subNs.setSourceTable(table);
3533            namespace = subNs;
3534
3535            // Register for deferred validation
3536            // SubqueryNamespace needs to be validated AFTER its subquery's SELECT list is processed
3537            // because the column names come from the SELECT list
3538            pendingSubqueryValidation.put(table, subNs);
3539        }
3540
3541        // Add to FROM scope ONLY if not inside a PIVOT/UNPIVOT source processing context.
3542        // When a subquery is the source of a PIVOT/UNPIVOT, only the PIVOT/UNPIVOT table
3543        // should be visible in the outer query, not the source subquery itself.
3544        // Example: FROM (SELECT ...) p UNPIVOT (...) AS unpvt
3545        //   - Only 'unpvt' should be visible, not 'p'
3546        //   - Column references in outer SELECT should resolve to 'unpvt', not ambiguously to both
3547        if (pivotSourceProcessingDepth == 0) {
3548            currentFromScope.addChild(namespace, alias != null ? alias : "<subquery>", false);
3549            if (DEBUG_SCOPE_BUILD) {
3550                System.out.println("[DEBUG] Added subquery to FromScope: alias=" + alias);
3551            }
3552        } else {
3553            if (DEBUG_SCOPE_BUILD) {
3554                System.out.println("[DEBUG] Skipping subquery from FromScope (inside PIVOT source): alias=" + alias);
3555            }
3556        }
3557        tableToNamespaceMap.put(table, namespace);  // Store for legacy compatibility
3558
3559        // Note: The subquery SELECT will be processed when acceptChildren traverses into it
3560        // For UnionNamespace, validation happens during construction
3561    }
3562
3563    /**
3564     * Process a table-valued function
3565     *
3566     * TABLE functions can contain subqueries as arguments, e.g.:
3567     * TABLE (SELECT AVG(E.SALARY) AS AVGSAL, COUNT(*) AS EMPCOUNT FROM EMP E) AS EMPINFO
3568     *
3569     * In this case, we create a SubqueryNamespace to expose the subquery's SELECT list
3570     * columns (AVGSAL, EMPCOUNT) to the outer query.
3571     */
3572    private void processTableFunction(TTable table) {
3573        // Track this function call as a table-valued function (not a column method call)
3574        if (table.getFuncCall() != null) {
3575            tableValuedFunctionCalls.add(table.getFuncCall());
3576        }
3577
3578        String alias = getTableAlias(table);
3579
3580        // Check if the TABLE function has a subquery argument
3581        TFunctionCall funcCall = table.getFuncCall();
3582        if (funcCall != null && funcCall.getArgs() != null && funcCall.getArgs().size() > 0) {
3583            TExpression firstArg = funcCall.getArgs().getExpression(0);
3584            if (firstArg != null && firstArg.getSubQuery() != null) {
3585                // TABLE function contains a subquery - create SubqueryNamespace
3586                TSelectSqlStatement subquery = firstArg.getSubQuery();
3587
3588                // Check if this is a UNION/INTERSECT/EXCEPT query
3589                INamespace namespace;
3590                if (subquery.isCombinedQuery()) {
3591                    // Create UnionNamespace for set operations
3592                    UnionNamespace unionNs = new UnionNamespace(subquery, alias, nameMatcher);
3593                    namespace = unionNs;
3594                } else {
3595                    // Create SubqueryNamespace for regular subquery, marked as from TABLE function
3596                    SubqueryNamespace subNs = new SubqueryNamespace(subquery, alias, nameMatcher, true);
3597                    // Pass guessColumnStrategy for config-based isolation (prevents test side effects)
3598                    if (guessColumnStrategy >= 0) {
3599                        subNs.setGuessColumnStrategy(guessColumnStrategy);
3600                    }
3601                    // Pass sqlEnv for metadata lookup during star column resolution
3602                    if (sqlEnv != null) {
3603                        subNs.setSqlEnv(sqlEnv);
3604                    }
3605                    // Set the owning TTable for legacy sync support
3606                    subNs.setSourceTable(table);
3607                    namespace = subNs;
3608                    // Defer validation until after children are visited
3609                    pendingSubqueryValidation.put(table, subNs);
3610                }
3611
3612                currentFromScope.addChild(namespace, alias, false);
3613                tableToNamespaceMap.put(table, namespace);  // Store for legacy compatibility
3614
3615                if (DEBUG_SCOPE_BUILD) {
3616                    System.out.println("[DEBUG] Added TABLE function with subquery to FromScope: alias=" + alias);
3617                }
3618                return;
3619            }
3620        }
3621
3622        // Fallback: treat as a regular table-valued function (e.g., UDF)
3623        TableNamespace tableNs = new TableNamespace(table, nameMatcher, sqlEnv);
3624        tableNs.validate();
3625        currentFromScope.addChild(tableNs, alias, false);
3626        tableToNamespaceMap.put(table, tableNs);  // Store for legacy compatibility
3627    }
3628
3629    /**
3630     * Process a PIVOT table
3631     *
3632     * PIVOT tables are created from a source table and a PIVOT clause:
3633     * <pre>
3634     * FROM source_table
3635     * PIVOT (aggregate_function(value) FOR column IN (val1, val2, ...)) AS alias
3636     * </pre>
3637     *
3638     * The PIVOT produces new columns (val1, val2, ...) while maintaining
3639     * some pass-through columns from the source table.
3640     */
3641    private void processPivotTable(TTable table) {
3642        // Get the source table of the pivot (the table being pivoted)
3643        // Note: For SQL Server, getSourceTableOfPivot() may return null, so we also check
3644        // getPivotedTable().getTableSource() which is set during parsing.
3645        // However, for BigQuery, using this fallback can break resolution because BigQuery's
3646        // AST structure adds the source table to FROM scope separately during traversal.
3647        // For SQL Server, we need the fallback to properly resolve pass-through columns.
3648        TTable sourceTable = table.getSourceTableOfPivot();
3649
3650        // Get PIVOT clause from the table's pivot table reference
3651        TPivotClause pivotClause = null;
3652        TPivotedTable pivotedTable = table.getPivotedTable();
3653
3654        // SQL Server UNPIVOT issue: When UNPIVOT is used on a subquery, the table from
3655        // statement.tables may have tableType=pivoted_table but getPivotedTable()=null.
3656        // The actual pivot clause is on a different TTable object from statement.joins.
3657        // We need to search for it there.
3658        if (pivotedTable == null && (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql)) {
3659            // Try to find the actual pivot table from joins
3660            TTable pivotTableFromJoins = findPivotTableInJoins(table);
3661            if (pivotTableFromJoins != null && pivotTableFromJoins.getPivotedTable() != null) {
3662                pivotedTable = pivotTableFromJoins.getPivotedTable();
3663            }
3664        }
3665
3666        if (pivotedTable != null) {
3667            pivotClause = pivotedTable.getPivotClause();
3668            // Also try to get source table from the pivoted table if not found yet
3669            if (sourceTable == null) {
3670                TTable potentialSourceTable = pivotedTable.getTableSource();
3671                // Use the fallback to get the source table for vendors that need it.
3672                // This is required for pass-through column resolution in PIVOT tables.
3673                // Note: For BigQuery, the source table (especially subqueries) needs this
3674                // fallback to properly resolve pass-through columns.
3675                if (potentialSourceTable != null) {
3676                    sourceTable = potentialSourceTable;
3677                }
3678            }
3679        }
3680
3681        // Get the alias - for PIVOT tables, the alias is in the PIVOT clause
3682        String alias = getTableAlias(table);
3683
3684        // If table alias is null/empty, try to get from pivot clause's alias clause
3685        if ((alias == null || alias.isEmpty() || alias.startsWith("null")) && pivotClause != null) {
3686            if (pivotClause.getAliasClause() != null &&
3687                pivotClause.getAliasClause().getAliasName() != null) {
3688                alias = pivotClause.getAliasClause().getAliasName().toString();
3689            }
3690        }
3691
3692        // Fallback to appropriate default alias if still no alias
3693        if (alias == null || alias.isEmpty() || alias.startsWith("null")) {
3694            // Use different default for PIVOT vs UNPIVOT to match TPivotClause.doParse()
3695            if (pivotClause != null && pivotClause.getType() == TPivotClause.unpivot) {
3696                alias = "unpivot_alias";
3697            } else {
3698                alias = "pivot_alias";
3699            }
3700        }
3701
3702        // IMPORTANT: The visitor traverses through getRelations() which may contain
3703        // different TTable objects than statement.tables. The formatter matches
3704        // sourceTable against statement.tables using object identity. We need to
3705        // find the matching pivot table in statement.tables to ensure proper matching.
3706        TTable pivotTableForNamespace = findMatchingPivotTableInStatement(table, alias);
3707
3708        // Create PivotNamespace with the table from statement.tables (if found)
3709        PivotNamespace pivotNs = new PivotNamespace(pivotTableForNamespace, pivotClause, sourceTable, alias, nameMatcher);
3710        pivotNs.validate();
3711
3712        // Wire source namespace for pass-through column resolution (Delta 2)
3713        if (sourceTable != null) {
3714            INamespace sourceNamespace = resolveSourceNamespaceForPivot(sourceTable);
3715            if (sourceNamespace != null) {
3716                pivotNs.setSourceNamespace(sourceNamespace);
3717            }
3718        }
3719
3720        // Add to FROM scope with the pivot table alias
3721        currentFromScope.addChild(pivotNs, alias, false);
3722        tableToNamespaceMap.put(table, pivotNs);  // Store for legacy compatibility
3723        if (pivotTableForNamespace != null && pivotTableForNamespace != table) {
3724            tableToNamespaceMap.put(pivotTableForNamespace, pivotNs);  // Also map the matched table
3725        }
3726
3727        // Add all PIVOT/UNPIVOT columns to allColumnReferences
3728        // Per CLAUDE.md, this resolution logic MUST be in ScopeBuilder, not in the formatter
3729        if (pivotClause != null) {
3730            if (pivotClause.getType() == TPivotClause.unpivot) {
3731                // UNPIVOT case: add generated columns and IN clause source columns
3732                addUnpivotColumns(pivotClause, pivotTableForNamespace, sourceTable);
3733            } else {
3734                // PIVOT case: Check if there's an alias column list (e.g., AS p (col1, col2, col3))
3735                // If so, use the alias columns as they REPLACE the IN clause column names
3736                boolean hasAliasColumnList = pivotClause.getAliasClause() != null &&
3737                    pivotClause.getAliasClause().getColumns() != null &&
3738                    pivotClause.getAliasClause().getColumns().size() > 0;
3739
3740                if (hasAliasColumnList) {
3741                    // Use alias column list - these replace the default pivot column names
3742                    addPivotAliasColumns(pivotClause.getAliasClause().getColumns(), pivotTableForNamespace);
3743                } else {
3744                    // No alias column list - use IN clause columns as pivot column names
3745                    TPivotInClause inClause = pivotClause.getPivotInClause();
3746                    if (inClause != null) {
3747                        addPivotInClauseColumns(inClause, pivotTableForNamespace);
3748                    }
3749                }
3750            }
3751        }
3752
3753        if (DEBUG_SCOPE_BUILD) {
3754            System.out.println("[DEBUG] Added PIVOT table to FromScope: alias=" + alias +
3755                " sourceTable=" + (sourceTable != null ? sourceTable.getName() : "null") +
3756                " pivotColumns=" + pivotNs.getPivotColumns().size() +
3757                " pivotTable=" + (pivotTableForNamespace == table ? "same" : "from-stmt-tables"));
3758        }
3759
3760        // Mark that we're now processing PIVOT/UNPIVOT source relations.
3761        // This prevents the source subquery from being added to FromScope
3762        // when the visitor traverses the TPivotedTable's children.
3763        // Will be decremented in postVisit(TTable) for pivot tables.
3764        pivotSourceProcessingDepth++;
3765    }
3766
3767    /**
3768     * Add all columns from PIVOT IN clause to allColumnReferences.
3769     * This ensures all pivot columns appear in the output, not just the ones referenced in SELECT.
3770     *
3771     * @param inClause The PIVOT IN clause
3772     * @param pivotTable The pivot table to set as sourceTable
3773     */
3774    private void addPivotInClauseColumns(TPivotInClause inClause, TTable pivotTable) {
3775        if (inClause == null || pivotTable == null) {
3776            return;
3777        }
3778
3779        // Case 1: IN clause has items (e.g., IN ([1], [2]) or IN ("SINGAPORE","LONDON","HOUSTON"))
3780        TResultColumnList items = inClause.getItems();
3781        if (items != null) {
3782            for (int i = 0; i < items.size(); i++) {
3783                TResultColumn resultColumn = items.getResultColumn(i);
3784                if (resultColumn == null || resultColumn.getExpr() == null) {
3785                    continue;
3786                }
3787                TExpression expr = resultColumn.getExpr();
3788
3789                // An explicit alias names the generated column outright, whatever the
3790                // IN-list value is: Oracle/Snowflake/BigQuery/Teradata all accept
3791                // IN ('direct' AS Store) and call the output column Store.
3792                String itemAlias = pivotInItemAlias(resultColumn);
3793
3794                if (expr.getExpressionType() == EExpressionType.simple_object_name_t) {
3795                    // Column reference (e.g., [Sammich], [Apple], "HOUSTON" in BigQuery)
3796                    // These are pivot column DEFINITIONS, not references to source table columns.
3797                    TObjectName objName = expr.getObjectOperand();
3798                    if (objName != null) {
3799                        objName.setSourceTable(pivotTable);
3800                        // Mark as pivot IN clause column so preVisit(TObjectName) won't re-process it
3801                        // and NameResolver won't overwrite the sourceTable with the source table
3802                        pivotInClauseColumns.add(objName);
3803                        // IMPORTANT: Do NOT add to columnToScopeMap - these are column DEFINITIONS,
3804                        // not references that need resolution. Adding to the map would cause the
3805                        // NameResolver to resolve them as source table columns, overwriting the
3806                        // sourceTable we set above.
3807                        if (itemAlias == null) {
3808                            allColumnReferences.add(objName);
3809                        } else {
3810                            // The alias, not the value, is the generated column. Publish it
3811                            // instead so exactly one column is exposed per IN-list item.
3812                            allColumnReferences.add(
3813                                newPivotOutputColumn(inClause.dbvendor, itemAlias, pivotTable));
3814                        }
3815                    }
3816                } else if (expr.getExpressionType() == EExpressionType.simple_constant_t) {
3817                    // Constant value (e.g., "HOUSTON", 'value')
3818                    // These are pivot column DEFINITIONS, not references that need resolution.
3819                    TConstant constant = expr.getConstantOperand();
3820                    if (constant != null && constant.getValueToken() != null) {
3821                        String columnName = itemAlias != null
3822                            ? itemAlias
3823                            : pivotOutputColumnName(inClause.dbvendor,
3824                                                    constant.getValueToken().toString());
3825                        allColumnReferences.add(
3826                            newPivotOutputColumn(inClause.dbvendor, columnName, pivotTable));
3827                        // IMPORTANT: Do NOT add to columnToScopeMap - these are column DEFINITIONS,
3828                        // not references that need resolution. Adding to the map would cause the
3829                        // NameResolver to resolve them and overwrite the sourceTable we set above.
3830                    }
3831                }
3832            }
3833        }
3834
3835        // Case 2: IN clause has a subquery (e.g., IN (SELECT DISTINCT col FROM table))
3836        if (inClause.getSubQuery() != null) {
3837            TResultColumnList subqueryColumns = inClause.getSubQuery().getResultColumnList();
3838            if (subqueryColumns != null) {
3839                for (int i = 0; i < subqueryColumns.size(); i++) {
3840                    TResultColumn resultColumn = subqueryColumns.getResultColumn(i);
3841                    if (resultColumn != null) {
3842                        TObjectName pivotColumn = TObjectName.createObjectName(
3843                            inClause.dbvendor,
3844                            EDbObjectType.column,
3845                            new TSourceToken(resultColumn.getDisplayName())
3846                        );
3847                        pivotColumn.setSourceTable(pivotTable);
3848                        allColumnReferences.add(pivotColumn);
3849                        // IMPORTANT: Do NOT add to columnToScopeMap.
3850                        // These are synthetic PIVOT output column DEFINITIONS (derived from IN-subquery result),
3851                        // not references that should be name-resolved. Adding them to the map would allow
3852                        // NameResolver to overwrite pivotColumn.sourceTable to some source table.
3853                    }
3854                }
3855            }
3856        }
3857    }
3858
3859    /**
3860     * Add PIVOT alias columns to allColumnReferences.
3861     * When PIVOT has an alias clause with column list (e.g., AS p (empid_renamed, Q1, Q2, Q3, Q4)),
3862     * the alias columns REPLACE the IN clause column names in the output.
3863     *
3864     * @param aliasColumns The column list from the alias clause
3865     * @param pivotTable The pivot table to set as sourceTable
3866     */
3867    private void addPivotAliasColumns(TObjectNameList aliasColumns, TTable pivotTable) {
3868        if (aliasColumns == null || pivotTable == null) {
3869            return;
3870        }
3871
3872        for (int i = 0; i < aliasColumns.size(); i++) {
3873            TObjectName aliasCol = aliasColumns.getObjectName(i);
3874            if (aliasCol != null) {
3875                aliasCol.setSourceTable(pivotTable);
3876                allColumnReferences.add(aliasCol);
3877                // IMPORTANT: Do NOT add to columnToScopeMap.
3878                // These are PIVOT output column DEFINITIONS from the alias list,
3879                // not references that should be name-resolved.
3880            }
3881        }
3882    }
3883
3884    /**
3885     * Add UNPIVOT source columns (IN clause) to allColumnReferences and mark definition columns.
3886     *
3887     * UNPIVOT (yearly_total FOR order_mode IN (store AS 'direct', internet AS 'online'))
3888     * - yearly_total is the value column (DEFINITION - creates a new column, NOT a reference)
3889     * - order_mode is the FOR column (DEFINITION - creates a new column, NOT a reference)
3890     * - store, internet are source columns (REFERENCES - belong to source table)
3891     *
3892     * IMPORTANT: Value and FOR columns are DEFINITIONS that create new columns in the UNPIVOT
3893     * output. They should NOT be added to allColumnReferences (which tracks references).
3894     * The PivotNamespace already tracks these generated columns for resolution purposes.
3895     *
3896     * @param pivotClause The UNPIVOT clause
3897     * @param unpivotTable The UNPIVOT table for generated columns
3898     * @param sourceTable The source table for IN clause columns
3899     */
3900    private void addUnpivotColumns(TPivotClause pivotClause, TTable unpivotTable, TTable sourceTable) {
3901        if (pivotClause == null || unpivotTable == null) {
3902            return;
3903        }
3904
3905        // Mark value columns as UNPIVOT definitions (NOT column references)
3906        // These define new output columns like "yearly_total" in UNPIVOT (yearly_total FOR ...)
3907        // We still set sourceTable for resolution purposes, but mark them so they're not
3908        // collected as column references by isColumnReference().
3909        // Note: For single value column UNPIVOT (like Oracle), use getValueColumn() (deprecated singular)
3910        TObjectNameList valueColumns = pivotClause.getValueColumnList();
3911        if (valueColumns != null && valueColumns.size() > 0) {
3912            for (int i = 0; i < valueColumns.size(); i++) {
3913                TObjectName valueCol = valueColumns.getObjectName(i);
3914                if (valueCol != null) {
3915                    valueCol.setSourceTable(unpivotTable);
3916                    unpivotDefinitionColumns.add(valueCol);
3917                    // Add to output completeness list as a DEFINITION (no name resolution)
3918                    allColumnReferences.add(valueCol);
3919                }
3920            }
3921        } else {
3922            // Fallback to deprecated singular method for Oracle compatibility
3923            @SuppressWarnings("deprecation")
3924            TObjectName valueCol = pivotClause.getValueColumn();
3925            if (valueCol != null) {
3926                valueCol.setSourceTable(unpivotTable);
3927                unpivotDefinitionColumns.add(valueCol);
3928                // Add to output completeness list as a DEFINITION (no name resolution)
3929                allColumnReferences.add(valueCol);
3930            }
3931        }
3932
3933        // Mark FOR columns as UNPIVOT definitions (NOT column references)
3934        // These define new output columns like "order_mode" in UNPIVOT (... FOR order_mode IN ...)
3935        // We still set sourceTable for resolution purposes, but mark them so they're not
3936        // collected as column references by isColumnReference().
3937        // Note: For single FOR column UNPIVOT (like Oracle), use getPivotColumn() (deprecated singular)
3938        TObjectNameList pivotColumnList = pivotClause.getPivotColumnList();
3939        if (pivotColumnList != null && pivotColumnList.size() > 0) {
3940            for (int i = 0; i < pivotColumnList.size(); i++) {
3941                TObjectName forCol = pivotColumnList.getObjectName(i);
3942                if (forCol != null) {
3943                    forCol.setSourceTable(unpivotTable);
3944                    unpivotDefinitionColumns.add(forCol);
3945                    // Add to output completeness list as a DEFINITION (no name resolution)
3946                    allColumnReferences.add(forCol);
3947                }
3948            }
3949        } else {
3950            // Fallback to deprecated singular method for Oracle compatibility
3951            @SuppressWarnings("deprecation")
3952            TObjectName forCol = pivotClause.getPivotColumn();
3953            if (forCol != null) {
3954                forCol.setSourceTable(unpivotTable);
3955                unpivotDefinitionColumns.add(forCol);
3956                // Add to output completeness list as a DEFINITION (no name resolution)
3957                allColumnReferences.add(forCol);
3958            }
3959        }
3960
3961        // IN clause columns (e.g., store, internet in "IN (store AS 'direct', internet AS 'online')")
3962        // are references to columns in the source table. They should be added to allColumnReferences
3963        // so they appear in the output with source table attribution (e.g., pivot_table.store).
3964        //
3965        // We set their sourceTable to the source table and add them to allColumnReferences.
3966        // We also mark them as unpivotDefinitionColumns so they're not collected again by isColumnReference().
3967        // S2: vendor-aware key set so quoted-vs-unquoted UNPIVOT consumed
3968        // columns are correctly distinguished on Oracle / Postgres quoted
3969        // and BigQuery (columns insensitive). Lookup site below uses the
3970        // same keyForColumn() helper.
3971        Set<String> consumedColumns = new HashSet<>();  // Track consumed column names
3972        TUnpivotInClause unpivotInClause = pivotClause.getUnpivotInClause();
3973        if (unpivotInClause != null && unpivotInClause.getItems() != null && sourceTable != null) {
3974            for (int i = 0; i < unpivotInClause.getItems().size(); i++) {
3975                TUnpivotInClauseItem item = unpivotInClause.getItems().getElement(i);
3976                if (item != null) {
3977                    // Single column case
3978                    if (item.getColumn() != null) {
3979                        TObjectName col = item.getColumn();
3980                        col.setSourceTable(sourceTable);
3981                        // Mark as definition so it's not collected again in isColumnReference()
3982                        unpivotDefinitionColumns.add(col);
3983                        // Add to allColumnReferences - this IS a reference to a source table column
3984                        allColumnReferences.add(col);
3985                        // Track consumed column name (strip table prefix if present)
3986                        String colName = col.getColumnNameOnly();
3987                        if (colName != null) consumedColumns.add(keyForColumn(colName));
3988                    }
3989                    // Multi-column case
3990                    if (item.getColumnList() != null) {
3991                        for (int j = 0; j < item.getColumnList().size(); j++) {
3992                            TObjectName col = item.getColumnList().getObjectName(j);
3993                            if (col != null) {
3994                                col.setSourceTable(sourceTable);
3995                                // Mark as definition so it's not collected again in isColumnReference()
3996                                unpivotDefinitionColumns.add(col);
3997                                // Add to allColumnReferences - this IS a reference to a source table column
3998                                allColumnReferences.add(col);
3999                                // Track consumed column name
4000                                String colName = col.getColumnNameOnly();
4001                                if (colName != null) consumedColumns.add(keyForColumn(colName));
4002                            }
4003                        }
4004                    }
4005                }
4006            }
4007        }
4008
4009        // Collect value and FOR column names for exclusion
4010        // (reusing valueColumns and pivotColumnList already defined above)
4011        if (valueColumns != null) {
4012            for (int i = 0; i < valueColumns.size(); i++) {
4013                TObjectName vc = valueColumns.getObjectName(i);
4014                if (vc != null && vc.getColumnNameOnly() != null) {
4015                    consumedColumns.add(keyForColumn(vc.getColumnNameOnly()));
4016                }
4017            }
4018        }
4019        if (pivotColumnList != null) {
4020            for (int i = 0; i < pivotColumnList.size(); i++) {
4021                TObjectName fc = pivotColumnList.getObjectName(i);
4022                if (fc != null && fc.getColumnNameOnly() != null) {
4023                    consumedColumns.add(keyForColumn(fc.getColumnNameOnly()));
4024                }
4025            }
4026        }
4027
4028        // Add pass-through columns as part of the UNPIVOT virtual table's output schema.
4029        // Pass-through columns are source columns that are NOT consumed by UNPIVOT:
4030        // - NOT in the IN clause (consumed columns)
4031        // - NOT the value column (generated by UNPIVOT)
4032        // - NOT the FOR column (generated by UNPIVOT)
4033        //
4034        // This ensures pass-through columns appear in the output even when not explicitly
4035        // referenced in the outer SELECT, similar to how value and FOR columns are added.
4036        //
4037        // NOTE: The source subquery's own columns will ALSO be collected during normal
4038        // traversal (with pivotSourceProcessingDepth reset), so they'll appear with
4039        // source table attribution (e.g., #sample.col1). This gives us dual attribution:
4040        // - #sample.col1 (from source subquery)
4041        // - (pivot-table:unpvt).col1 (as part of UNPIVOT output schema)
4042        if (sourceTable != null && sourceTable.getSubquery() != null && unpivotTable != null) {
4043            TSelectSqlStatement sourceSubquery = sourceTable.getSubquery();
4044            TResultColumnList resultCols = sourceSubquery.getResultColumnList();
4045            if (resultCols != null) {
4046                for (int i = 0; i < resultCols.size(); i++) {
4047                    TResultColumn rc = resultCols.getResultColumn(i);
4048                    if (rc == null) continue;
4049
4050                    // Get the column name from the result column
4051                    // Handle both simple columns and aliased expressions
4052                    String columnName = null;
4053                    if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) {
4054                        columnName = rc.getAliasClause().getAliasName().toString();
4055                    } else if (rc.getExpr() != null) {
4056                        TExpression expr = rc.getExpr();
4057                        if (expr.getExpressionType() == EExpressionType.simple_object_name_t &&
4058                            expr.getObjectOperand() != null) {
4059                            columnName = expr.getObjectOperand().getColumnNameOnly();
4060                        }
4061                    }
4062
4063                    if (columnName == null || columnName.isEmpty()) continue;
4064
4065                    // Check if this is a pass-through column (not consumed).
4066                    // S2: route through the same vendor-aware keyForColumn()
4067                    // used at the storage sites above. IdentifierService
4068                    // strips quotes per vendor (so the manual "[...]" / "\"...\""
4069                    // strip is no longer needed here).
4070                    String normalizedName = keyForColumn(columnName);
4071                    if (!consumedColumns.contains(normalizedName)) {
4072                        // This is a pass-through column - create a synthetic entry
4073                        // We use TObjectName.createObjectName() which properly initializes tokens
4074                        // so getColumnNameOnly() returns the correct value
4075                        TObjectName passthroughCol = TObjectName.createObjectName(
4076                            dbVendor,
4077                            EDbObjectType.column,
4078                            new TSourceToken(columnName)
4079                        );
4080                        passthroughCol.setSourceTable(unpivotTable);
4081
4082                        // Mark as definition column so it's not processed as a reference in isColumnReference()
4083                        unpivotDefinitionColumns.add(passthroughCol);
4084
4085                        // Add to output
4086                        allColumnReferences.add(passthroughCol);
4087
4088                        if (DEBUG_SCOPE_BUILD) {
4089                            System.out.println("[DEBUG] Added UNPIVOT pass-through column: " + columnName);
4090                        }
4091                    }
4092                }
4093            }
4094        }
4095    }
4096
4097    /**
4098     * Find the matching pivot table in the current statement's tables collection.
4099     *
4100     * The visitor traverses through getRelations() which may contain TTable objects
4101     * that are different from those in statement.tables. Since the formatter uses
4102     * object identity to match sourceTable against statement.tables, we need to
4103     * return the TTable from statement.tables.
4104     *
4105     * @param visitedTable The TTable from visitor traversal
4106     * @param alias The alias to match
4107     * @return The matching TTable from statement.tables, or visitedTable if not found
4108     */
4109    private TTable findMatchingPivotTableInStatement(TTable visitedTable, String alias) {
4110        // Get the current statement from the scope
4111        if (currentSelectScope == null || !(currentSelectScope.getNode() instanceof TSelectSqlStatement)) {
4112            return visitedTable;
4113        }
4114
4115        TSelectSqlStatement stmt = (TSelectSqlStatement) currentSelectScope.getNode();
4116        if (stmt.tables == null) {
4117            return visitedTable;
4118        }
4119
4120        // Search for a pivot table with matching alias in statement.tables
4121        for (int i = 0; i < stmt.tables.size(); i++) {
4122            TTable stmtTable = stmt.tables.getTable(i);
4123            if (stmtTable == null) continue;
4124
4125            // Check if this is a pivot table with matching alias
4126            if (stmtTable.getTableType() == ETableSource.pivoted_table) {
4127                String stmtAlias = getTableAlias(stmtTable);
4128                // Try to get alias from pivot clause if not found
4129                if ((stmtAlias == null || stmtAlias.isEmpty() || stmtAlias.startsWith("null"))
4130                    && stmtTable.getPivotedTable() != null
4131                    && stmtTable.getPivotedTable().getPivotClause() != null) {
4132                    TPivotClause pc = stmtTable.getPivotedTable().getPivotClause();
4133                    if (pc.getAliasClause() != null && pc.getAliasClause().getAliasName() != null) {
4134                        stmtAlias = pc.getAliasClause().getAliasName().toString();
4135                    }
4136                }
4137
4138                // Match by alias
4139                if (alias != null && stmtAlias != null && SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotTable, alias, stmtAlias)) {
4140                    return stmtTable;
4141                }
4142            }
4143        }
4144
4145        // Not found in statement.tables, return the original visited table
4146        return visitedTable;
4147    }
4148
4149    /**
4150     * Find the actual pivot table with PivotedTable info from the statement's joins.
4151     *
4152     * SQL Server UNPIVOT issue: When UNPIVOT is used on a subquery, the table from
4153     * statement.tables may have tableType=pivoted_table but getPivotedTable()=null.
4154     * The actual pivot clause is on a different TTable object from statement.joins.
4155     *
4156     * @param table The pivot table from statement.tables (may have getPivotedTable()=null)
4157     * @return The matching TTable from joins with getPivotedTable() populated, or null if not found
4158     */
4159    private TTable findPivotTableInJoins(TTable table) {
4160        // Get the current statement from the scope
4161        if (currentSelectScope == null || !(currentSelectScope.getNode() instanceof TSelectSqlStatement)) {
4162            return null;
4163        }
4164
4165        TSelectSqlStatement stmt = (TSelectSqlStatement) currentSelectScope.getNode();
4166
4167        // Search in joins for a pivot table with getPivotedTable() != null
4168        TJoinList joins = stmt.joins;
4169        if (joins != null) {
4170            for (int i = 0; i < joins.size(); i++) {
4171                TJoin join = joins.getJoin(i);
4172                if (join == null) continue;
4173
4174                TTable joinTable = join.getTable();
4175                if (joinTable != null &&
4176                    joinTable.getTableType() == ETableSource.pivoted_table &&
4177                    joinTable.getPivotedTable() != null) {
4178                    // Found a pivot table with the actual PivotedTable info
4179                    // Match by alias if available
4180                    String tableAlias = getTableAlias(table);
4181                    String joinTableAlias = getTableAlias(joinTable);
4182
4183                    // If the table from statement.tables has an alias, try to match
4184                    if (tableAlias != null && !tableAlias.isEmpty()) {
4185                        // Try to get alias from pivot clause if joinTableAlias is null or has the
4186                        // placeholder pattern "null(piviot_table)" or similar
4187                        if ((joinTableAlias == null || joinTableAlias.isEmpty() ||
4188                             joinTableAlias.startsWith("null")) &&
4189                            joinTable.getPivotedTable().getPivotClause() != null) {
4190                            TPivotClause pc = joinTable.getPivotedTable().getPivotClause();
4191                            if (pc.getAliasClause() != null && pc.getAliasClause().getAliasName() != null) {
4192                                joinTableAlias = pc.getAliasClause().getAliasName().toString();
4193                            }
4194                        }
4195                        if (SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotTable, tableAlias, joinTableAlias)) {
4196                            return joinTable;
4197                        }
4198                    } else {
4199                        // No alias to match, return the first pivot table found
4200                        return joinTable;
4201                    }
4202                }
4203            }
4204        }
4205
4206        return null;
4207    }
4208
4209    /**
4210     * Find a table by alias or name in the current SELECT statement's FROM clause.
4211     * Used for linking EXCEPT columns to the star column's source table.
4212     *
4213     * @param aliasOrName The table alias or name to find
4214     * @return The matching TTable, or null if not found
4215     */
4216    private TTable findTableByAliasInCurrentScope(String aliasOrName) {
4217        if (aliasOrName == null || aliasOrName.isEmpty()) {
4218            return null;
4219        }
4220
4221        // Get the current statement from the scope
4222        if (currentSelectScope == null || !(currentSelectScope.getNode() instanceof TSelectSqlStatement)) {
4223            return null;
4224        }
4225
4226        TSelectSqlStatement stmt = (TSelectSqlStatement) currentSelectScope.getNode();
4227        if (stmt.tables == null) {
4228            return null;
4229        }
4230
4231        // Search for a table with matching alias or name
4232        for (int i = 0; i < stmt.tables.size(); i++) {
4233            TTable table = stmt.tables.getTable(i);
4234            if (table == null) continue;
4235
4236            // Check alias first
4237            String tableAlias = getTableAlias(table);
4238            if (tableAlias != null && !tableAlias.isEmpty() &&
4239                SQLUtil.sameName(dbVendor, ESQLDataObjectType.dotTable, aliasOrName, tableAlias)) {
4240                return table;
4241            }
4242
4243            // Check table name
4244            String tableName = table.getTableName() != null ? table.getTableName().toString() : null;
4245            if (tableName != null && SQLUtil.compareIdentifier(dbVendor, ESQLDataObjectType.dotTable, aliasOrName, tableName)) {
4246                return table;
4247            }
4248
4249            // For subqueries without explicit alias, check the full name
4250            String fullName = table.getFullName();
4251            if (fullName != null && SQLUtil.compareIdentifier(dbVendor, ESQLDataObjectType.dotTable, aliasOrName, fullName)) {
4252                return table;
4253            }
4254        }
4255
4256        return null;
4257    }
4258
4259    /**
4260     * Resolve the source namespace for a PIVOT table (Delta 2 - pass-through column resolution).
4261     *
4262     * The source namespace is used to resolve pass-through columns that are not
4263     * part of the PIVOT IN clause (e.g., ADDRESS_ID in "SELECT p.ADDRESS_ID, p.[1] FROM CTE PIVOT(...) p").
4264     *
4265     * @param sourceTable The source table of the PIVOT
4266     * @return The namespace for the source table, or null if not found
4267     */
4268    private INamespace resolveSourceNamespaceForPivot(TTable sourceTable) {
4269        if (sourceTable == null) {
4270            return null;
4271        }
4272
4273        String sourceName = sourceTable.getName();
4274
4275        // Case 1: Source is a CTE reference. Apply the same Mantis #4412
4276        // guard so a schema-qualified PIVOT source, or a self-reference
4277        // inside its own non-recursive CTE definition, falls through to
4278        // the physical-table branch below.
4279        CTENamespace cteNamespace = findCTEByName(sourceName);
4280        if (cteNamespace != null && !shouldTreatAsPhysicalTableNotCTE(sourceTable, cteNamespace)) {
4281            return cteNamespace;
4282        }
4283
4284        // Case 2: Source is a subquery
4285        if (sourceTable.getSubquery() != null) {
4286            // Create a SubqueryNamespace for the subquery
4287            SubqueryNamespace subqueryNs = new SubqueryNamespace(
4288                sourceTable.getSubquery(),
4289                getTableAlias(sourceTable),
4290                nameMatcher
4291            );
4292            subqueryNs.validate();
4293            return subqueryNs;
4294        }
4295
4296        // Case 3: Source is a physical table - create TableNamespace
4297        TableNamespace tableNs = new TableNamespace(sourceTable, nameMatcher, sqlEnv);
4298        tableNs.validate();
4299        return tableNs;
4300    }
4301
4302    /**
4303     * Process an UNNEST table expression.
4304     *
4305     * UNNEST flattens an array into rows, creating a virtual table:
4306     * <pre>
4307     * SELECT value FROM UNNEST(array_column)
4308     * SELECT element FROM UNNEST(['a', 'b', 'c']) AS element
4309     * SELECT * FROM UNNEST(array_column) WITH OFFSET
4310     * </pre>
4311     *
4312     * The UNNEST table provides:
4313     * 1. An implicit column for the unnested elements (named by alias or 'value')
4314     * 2. Optional WITH OFFSET column for array indices
4315     * 3. STRUCT field columns when unnesting ARRAY<STRUCT<...>>
4316     */
4317    private void processUnnestTable(TTable table) {
4318        String alias = getTableAlias(table);
4319
4320        // Create UnnestNamespace
4321        UnnestNamespace unnestNs = new UnnestNamespace(table, alias, nameMatcher);
4322        unnestNs.validate();
4323
4324        // Add to FROM scope
4325        currentFromScope.addChild(unnestNs, alias, false);
4326        tableToNamespaceMap.put(table, unnestNs);  // Store for legacy compatibility
4327
4328        // Process the array expression inside UNNEST to capture correlated references
4329        // e.g., UNNEST(nested_attribute) - nested_attribute should link to outer table
4330        TUnnestClause unnestClause = table.getUnnestClause();
4331        if (unnestClause != null && unnestClause.getArrayExpr() != null) {
4332            // Visit the array expression to collect column references
4333            // This will be handled by the expression visitor
4334            traverseExpressionForColumns(unnestClause.getArrayExpr());
4335        }
4336
4337        if (DEBUG_SCOPE_BUILD) {
4338            System.out.println("[DEBUG] Added UNNEST table to FromScope: alias=" + alias +
4339                " implicitColumn=" + unnestNs.getImplicitColumnName());
4340        }
4341    }
4342
4343    /**
4344     * Process a Snowflake stage table reference.
4345     * Stage tables reference files in storage (e.g., @stage/path/file.parquet).
4346     * They're treated as tables but without schema metadata - columns are accessed
4347     * via positional references ($1, $2) or JSON path access ($1:field).
4348     */
4349    private void processStageTable(TTable table) {
4350        String alias = getTableAlias(table);
4351        String tableName = table.getFullName();
4352
4353        // Create a TableNamespace for the stage table
4354        // Stage tables don't have metadata, so we use null for sqlEnv
4355        TableNamespace stageNs = new TableNamespace(table, nameMatcher, null);
4356        stageNs.validate();
4357
4358        // Add to FROM scope
4359        currentFromScope.addChild(stageNs, alias, false);
4360        lastProcessedFromTable = table;
4361        currentJoinChainTables.add(table);  // Track ALL tables in join chain for chained USING
4362        tableToNamespaceMap.put(table, stageNs);
4363
4364        if (DEBUG_SCOPE_BUILD) {
4365            System.out.println("[DEBUG] Added stage table to FromScope: alias=" + alias +
4366                " tableName=" + tableName);
4367        }
4368    }
4369
4370    /**
4371     * Process a Teradata TD_UNPIVOT table function.
4372     * TD_UNPIVOT transforms columns into rows. The columns are created during parsing
4373     * in TTDUnpivot.doParse() and linked to either the output table (VALUE_COLUMNS,
4374     * UNPIVOT_COLUMN) or the source table (COLUMN_LIST).
4375     *
4376     * This method collects those columns into allColumnReferences as definition columns
4377     * (they don't need name resolution - their sourceTable is already set).
4378     *
4379     * @see gudusoft.gsqlparser.nodes.teradata.TTDUnpivot
4380     */
4381    private void processTDUnpivotTable(TTable table) {
4382        TTDUnpivot tdUnpivot = table.getTdUnpivot();
4383        if (tdUnpivot == null) {
4384            return;
4385        }
4386
4387        // VALUE_COLUMNS: Output value columns of TD_UNPIVOT
4388        // These columns are created from string literals and linked to the output table
4389        TObjectNameList valueColumns = tdUnpivot.getValueColumns();
4390        if (valueColumns != null) {
4391            for (int i = 0; i < valueColumns.size(); i++) {
4392                TObjectName col = valueColumns.getObjectName(i);
4393                if (col != null) {
4394                    // Mark as definition column (already has sourceTable set during parsing)
4395                    unpivotDefinitionColumns.add(col);
4396                    // Add to output list
4397                    allColumnReferences.add(col);
4398                    if (DEBUG_SCOPE_BUILD) {
4399                        System.out.println("[DEBUG] TD_UNPIVOT valueColumn: " + col.getColumnNameOnly() +
4400                            " -> " + (col.getSourceTable() != null ? col.getSourceTable().getTableName() : "null"));
4401                    }
4402                }
4403            }
4404        }
4405
4406        // UNPIVOT_COLUMN: Output label column of TD_UNPIVOT (contains original column names)
4407        TObjectNameList unpivotColumns = tdUnpivot.getUnpivotColumns();
4408        if (unpivotColumns != null) {
4409            for (int i = 0; i < unpivotColumns.size(); i++) {
4410                TObjectName col = unpivotColumns.getObjectName(i);
4411                if (col != null) {
4412                    // Mark as definition column
4413                    unpivotDefinitionColumns.add(col);
4414                    // Add to output list
4415                    allColumnReferences.add(col);
4416                    if (DEBUG_SCOPE_BUILD) {
4417                        System.out.println("[DEBUG] TD_UNPIVOT unpivotColumn: " + col.getColumnNameOnly() +
4418                            " -> " + (col.getSourceTable() != null ? col.getSourceTable().getTableName() : "null"));
4419                    }
4420                }
4421            }
4422        }
4423
4424        // COLUMN_LIST: Source columns being unpivoted
4425        // These columns are linked to the source table (the table in the ON clause)
4426        TObjectNameList columnList = tdUnpivot.getColumnList();
4427        if (columnList != null) {
4428            for (int i = 0; i < columnList.size(); i++) {
4429                TObjectName col = columnList.getObjectName(i);
4430                if (col != null) {
4431                    // Mark as definition column
4432                    unpivotDefinitionColumns.add(col);
4433                    // Add to output list
4434                    allColumnReferences.add(col);
4435                    if (DEBUG_SCOPE_BUILD) {
4436                        System.out.println("[DEBUG] TD_UNPIVOT columnList: " + col.getColumnNameOnly() +
4437                            " -> " + (col.getSourceTable() != null ? col.getSourceTable().getTableName() : "null"));
4438                    }
4439                }
4440            }
4441        }
4442    }
4443
4444    /**
4445     * Process a VALUES table (inline data with column aliases).
4446     * Example: VALUES (1, 'a'), (2, 'b') AS t(id, name)
4447     * Used in Teradata MERGE: USING VALUES (:empno, :name, :salary) AS s(empno, name, salary)
4448     */
4449    private void processValuesTable(TTable table) {
4450        String alias = getTableAlias(table);
4451
4452        // Track column name definitions from the alias clause
4453        // These are column DEFINITIONS, not references - should NOT be collected as column refs
4454        // e.g., in "VALUES (1, 'a') AS t(id, name)", 'id' and 'name' are definitions
4455        TAliasClause aliasClause = table.getAliasClause();
4456        if (aliasClause != null) {
4457            TObjectNameList columns = aliasClause.getColumns();
4458            if (columns != null && columns.size() > 0) {
4459                for (int i = 0; i < columns.size(); i++) {
4460                    TObjectName colName = columns.getObjectName(i);
4461                    if (colName != null) {
4462                        valuesTableAliasColumns.add(colName);
4463                        if (DEBUG_SCOPE_BUILD) {
4464                            System.out.println("[DEBUG] Tracked VALUES alias column definition (will skip): " + colName.toString());
4465                        }
4466                    }
4467                }
4468            }
4469        }
4470
4471        // Create ValuesNamespace - columns are extracted from alias clause
4472        ValuesNamespace valuesNs = new ValuesNamespace(table, alias, nameMatcher);
4473        valuesNs.validate();
4474
4475        // Add to FROM scope with the table alias
4476        currentFromScope.addChild(valuesNs, alias, false);
4477        tableToNamespaceMap.put(table, valuesNs);  // Store for legacy compatibility
4478        lastProcessedFromTable = table;
4479        currentJoinChainTables.add(table);  // Track ALL tables in join chain for chained USING
4480
4481        if (DEBUG_SCOPE_BUILD) {
4482            System.out.println("[DEBUG] Added VALUES table to FromScope: alias=" + alias +
4483                ", columns=" + valuesNs.getAllColumnSources().keySet());
4484        }
4485    }
4486
4487    /**
4488     * Process a potential CTE reference
4489     */
4490    private void processCTEReference(TTable table) {
4491        String tableName = table.getName();
4492        CTENamespace cteNamespace = findCTEByName(tableName);
4493
4494        if (cteNamespace != null) {
4495            String alias = getTableAlias(table);
4496            currentFromScope.addChild(cteNamespace, alias, false);
4497        }
4498    }
4499
4500    /**
4501     * Decide whether a table that name-matches an enclosing CTE should still be
4502     * treated as a physical table rather than as a CTE reference.
4503     *
4504     * <p>Two situations trigger this guard:
4505     *
4506     * <ol>
4507     *   <li>The table reference carries a schema, database, or server prefix
4508     *       (e.g. {@code ZZZ_DEV.FOTC_BB_PRODUCT_INTER}). CTE names are
4509     *       unqualified across every supported dialect, so a qualified name
4510     *       cannot be a CTE reference.</li>
4511     *   <li>The table appears inside the CTE's own definition and we can be
4512     *       <em>certain</em> the CTE is non-recursive. Non-recursive CTEs are
4513     *       not visible to themselves, so a same-named table inside the body
4514     *       is the underlying physical table, not a self-reference. See
4515     *       Mantis #4412.</li>
4516     * </ol>
4517     *
4518     * <p>The non-recursive determination is intentionally conservative because
4519     * {@code TCTE.isRecursive()} is unreliable across dialects:
4520     * <ul>
4521     *   <li>BigQuery's grammar accepts {@code WITH RECURSIVE} but never sets
4522     *       the flag.</li>
4523     *   <li>PostgreSQL/Snowflake/Redshift/Hive/Teradata only mark
4524     *       {@code getCTE(0)} as recursive even when {@code WITH RECURSIVE}
4525     *       applies to the whole list.</li>
4526     *   <li>SQL Server allows recursive CTEs without any keyword.</li>
4527     * </ul>
4528     * To stay safe we treat a CTE as "potentially recursive" — and therefore
4529     * do not reroute its self-references — whenever any sibling in the same
4530     * {@code TCTEList} is marked recursive, or whenever the CTE body is a
4531     * combined query (the typical recursive shape).
4532     */
4533    private boolean shouldTreatAsPhysicalTableNotCTE(TTable table, CTENamespace cteNamespace) {
4534        if (table == null || cteNamespace == null) {
4535            return false;
4536        }
4537
4538        // Case 1: schema/database/server-qualified name cannot match a CTE.
4539        if (hasSchemaOrDatabaseQualifier(table)) {
4540            return true;
4541        }
4542
4543        // Case 2: definitely-non-recursive CTE self-reference inside its body.
4544        return isNonRecursiveSelfReference(table, cteNamespace);
4545    }
4546
4547    private boolean isNonRecursiveSelfReference(TTable table, CTENamespace cteNamespace) {
4548        TCTE cte = cteNamespace.getCTE();
4549        if (cte == null || cte.getSubquery() == null) {
4550            return false;
4551        }
4552
4553        // Don't reroute when there's any sign the CTE could be recursive.
4554        if (isPotentiallyRecursive(cte)) {
4555            return false;
4556        }
4557
4558        TSelectSqlStatement cteSubquery = cte.getSubquery();
4559        return gudusoft.gsqlparser.nodes.TParseTreeNode.subNodeInNode(table, cteSubquery);
4560    }
4561
4562    /**
4563     * A CTE is treated as potentially recursive whenever
4564     * {@link TCTE#isRecursive()} is true on this CTE, on any sibling in the
4565     * same {@link TCTEList} (covers the
4566     * "{@code WITH RECURSIVE a AS (...), b AS (...)}" case where dialect
4567     * grammars mark only the first CTE), or whenever the CTE body is a
4568     * combined query — the standard recursive shape used by SQL Server,
4569     * which has no {@code RECURSIVE} keyword.
4570     */
4571    private boolean isPotentiallyRecursive(TCTE cte) {
4572        if (cte == null) {
4573            return false;
4574        }
4575        if (cte.isRecursive()) {
4576            return true;
4577        }
4578
4579        TCTEList list = findEnclosingCTEList(cte);
4580        if (list != null) {
4581            for (int i = 0; i < list.size(); i++) {
4582                TCTE sibling = list.getCTE(i);
4583                if (sibling != null && sibling.isRecursive()) {
4584                    return true;
4585                }
4586            }
4587        }
4588
4589        TSelectSqlStatement body = cte.getSubquery();
4590        if (body != null && body.isCombinedQuery()) {
4591            return true;
4592        }
4593
4594        return false;
4595    }
4596
4597    /**
4598     * Walk the live scope stack to find the {@link TCTEList} that contains
4599     * {@code cte}. We avoid using {@link gudusoft.gsqlparser.nodes.TParseTreeNode#getParent()}
4600     * because TCTE does not always carry a parent pointer back to its list.
4601     */
4602    private TCTEList findEnclosingCTEList(TCTE cte) {
4603        if (cte == null) {
4604            return null;
4605        }
4606        for (int i = scopeStack.size() - 1; i >= 0; i--) {
4607            IScope scope = scopeStack.get(i);
4608            if (scope instanceof CTEScope) {
4609                TCTEList list = ((CTEScope) scope).getCTEList();
4610                if (list == null) {
4611                    continue;
4612                }
4613                for (int j = 0; j < list.size(); j++) {
4614                    if (list.getCTE(j) == cte) {
4615                        return list;
4616                    }
4617                }
4618            }
4619        }
4620        return null;
4621    }
4622
4623    /**
4624     * Returns true when the table reference has any non-empty
4625     * server/database/schema prefix, e.g. {@code db.schema.t} or
4626     * {@code schema.t}.
4627     */
4628    private boolean hasSchemaOrDatabaseQualifier(TTable table) {
4629        if (table == null) {
4630            return false;
4631        }
4632        String schema = table.getPrefixSchema();
4633        if (schema != null && !schema.isEmpty()) {
4634            return true;
4635        }
4636        String database = table.getPrefixDatabase();
4637        if (database != null && !database.isEmpty()) {
4638            return true;
4639        }
4640        String server = table.getPrefixServer();
4641        if (server != null && !server.isEmpty()) {
4642            return true;
4643        }
4644        return false;
4645    }
4646
4647    /**
4648     * Find a CTE by name in all enclosing CTE scopes
4649     */
4650    private CTENamespace findCTEByName(String name) {
4651        if (name == null) {
4652            return null;
4653        }
4654
4655        // Normalize name by stripping SQL Server bracket delimiters [name] -> name
4656        String normalizedName = stripBrackets(name);
4657
4658        // Search through scope stack for CTE scopes
4659        for (int i = scopeStack.size() - 1; i >= 0; i--) {
4660            IScope scope = scopeStack.get(i);
4661            if (scope instanceof CTEScope) {
4662                CTEScope cteScope = (CTEScope) scope;
4663                CTENamespace cte = cteScope.getCTE(normalizedName);
4664                if (cte != null) {
4665                    return cte;
4666                }
4667            }
4668        }
4669
4670        return null;
4671    }
4672
4673    /**
4674     * Strip SQL Server bracket delimiters from a name.
4675     * Converts "[name]" to "name", leaves unbracketed names unchanged.
4676     */
4677    private String stripBrackets(String name) {
4678        if (name == null) {
4679            return null;
4680        }
4681        if (name.startsWith("[") && name.endsWith("]") && name.length() > 2) {
4682            return name.substring(1, name.length() - 1);
4683        }
4684        return name;
4685    }
4686
4687    /**
4688     * Strip string delimiters (double quotes and single quotes) from a constant value.
4689     * Used for PIVOT IN clause constant values like "HOUSTON" or 'value'.
4690     * SQL Server brackets are preserved as they're often needed for special names.
4691     */
4692    /**
4693     * The explicit alias of one PIVOT IN-list item, or null when it has none.
4694     *
4695     * <p>Delegates to {@link PivotNamespace#inListItemAlias} deliberately: the name
4696     * a pivot column is published under here and the name
4697     * {@code PivotNamespace} lets it be resolved by must come from the same code.
4698     * When they drifted apart, a reference to the real column reported NOT_FOUND
4699     * while a stale value-derived name still matched.</p>
4700     */
4701    private String pivotInItemAlias(TResultColumn resultColumn) {
4702        return PivotNamespace.inListItemAlias(resultColumn);
4703    }
4704
4705    /** Build a pivot-generated output column already bound to the pivot table. */
4706    private TObjectName newPivotOutputColumn(EDbVendor vendor, String name, TTable pivotTable) {
4707        TObjectName column = TObjectName.createObjectName(
4708            vendor, EDbObjectType.column, new TSourceToken(name));
4709        column.setSourceTable(pivotTable);
4710        return column;
4711    }
4712
4713    /**
4714     * The name a PIVOT gives to the output column generated by one IN-list value,
4715     * when the item carries no explicit alias.
4716     *
4717     * <p>This is vendor-specific and the difference is not cosmetic. Oracle and
4718     * Snowflake name the column after the literal <em>as written</em>, quotes
4719     * included, so {@code FOR metrics IN ('Revenue')} produces a column whose name
4720     * really is {@code 'Revenue'} and which must later be referenced as
4721     * {@code "'Revenue'"}. Stripping the quotes there yields a name that matches
4722     * nothing in the query (Mantis 4662). BigQuery does the opposite — its IN-list
4723     * values have to form valid identifiers and the generated column is named
4724     * {@code Revenue} — so the quotes must still be stripped for it, and for every
4725     * other vendor the previous behaviour is retained.</p>
4726     *
4727     * <p>Phase 1 ({@code TPivotInClause.linkColumnToTable}) builds the name from the
4728     * literal token, so keeping the quotes here makes resolver2 agree with it
4729     * instead of overwriting it with a different spelling. Delegates to
4730     * {@link PivotNamespace#inListValueColumnName} so the published name and the
4731     * resolvable name are produced by one rule.</p>
4732     */
4733    private String pivotOutputColumnName(EDbVendor vendor, String rawValue) {
4734        return PivotNamespace.inListValueColumnName(vendor, rawValue);
4735    }
4736
4737    private String stripStringDelimiters(String value) {
4738        if (value == null || value.isEmpty()) {
4739            return value;
4740        }
4741        // Strip double quotes (BigQuery style identifier)
4742        if (value.startsWith("\"") && value.endsWith("\"") && value.length() > 2) {
4743            return value.substring(1, value.length() - 1);
4744        }
4745        // Strip single quotes (string literal)
4746        if (value.startsWith("'") && value.endsWith("'") && value.length() > 2) {
4747            return value.substring(1, value.length() - 1);
4748        }
4749        // SQL Server brackets [] are preserved as they're part of the column identity
4750        return value;
4751    }
4752
4753    /**
4754     * Get the alias for a table, or the table name if no alias
4755     */
4756    private String getTableAlias(TTable table) {
4757        if (table.getAliasName() != null && !table.getAliasName().isEmpty()) {
4758            return table.getAliasName();
4759        }
4760        return table.getName();
4761    }
4762
4763    // ========== JOIN ==========
4764
4765    @Override
4766    public void preVisit(TJoinExpr joinExpr) {
4767        // JOIN expressions are traversed automatically
4768        // Left and right tables will trigger preVisit(TTable)
4769        // ON condition columns will trigger preVisit(TObjectName)
4770        if (DEBUG_SCOPE_BUILD) {
4771            System.out.println("[DEBUG] preVisit(TJoinExpr): " + joinExpr +
4772                               ", usingColumns=" + (joinExpr.getUsingColumns() != null ? joinExpr.getUsingColumns().size() : "null") +
4773                               ", leftTable=" + joinExpr.getLeftTable() +
4774                               ", rightTable=" + joinExpr.getRightTable() +
4775                               ", joinChainTables=" + currentJoinChainTables.size());
4776        }
4777
4778        // Handle USING columns in TJoinExpr (used when USE_JOINEXPR_INSTEAD_OF_JOIN is true)
4779        if (joinExpr.getUsingColumns() != null && joinExpr.getUsingColumns().size() > 0) {
4780            TTable rightTable = joinExpr.getRightTable();
4781
4782            // For TJoinExpr, the left side may be another TJoinExpr (nested joins) or a single table.
4783            // We need to collect ALL tables on the left side recursively.
4784            List<TTable> leftSideTables = collectTablesFromJoinExpr(joinExpr.getLeftTable());
4785
4786            if (DEBUG_SCOPE_BUILD) {
4787                System.out.println("[DEBUG] preVisit(TJoinExpr) USING: leftSideTables=" + leftSideTables.size());
4788                for (TTable t : leftSideTables) {
4789                    System.out.println("[DEBUG]   - " + t.getFullName());
4790                }
4791            }
4792
4793            if (rightTable != null) {
4794                currentUsingJoinRightTable = rightTable;
4795                // USING clause semantic: For chained joins like "t1 JOIN t2 USING (c1) JOIN t3 USING (c2)",
4796                // the USING column c2 should be linked to ALL tables on the left side (t1 and t2), not just one.
4797                // This is because the left side of the second join is the result of (t1 JOIN t2).
4798                for (int i = 0; i < joinExpr.getUsingColumns().size(); i++) {
4799                    TObjectName usingCol = joinExpr.getUsingColumns().getObjectName(i);
4800                    if (usingCol != null) {
4801                        // Create synthetic columns for ALL tables on the left side
4802                        // The first table gets the original USING column, the rest get clones
4803                        boolean isFirstTable = true;
4804                        for (TTable leftTable : leftSideTables) {
4805                            if (isFirstTable) {
4806                                // Original USING column -> first left table
4807                                usingColumnToLeftTable.put(usingCol, leftTable);
4808                                isFirstTable = false;
4809                            } else {
4810                                // Create synthetic column for additional left tables
4811                                TObjectName chainTableCol = usingCol.clone();
4812                                chainTableCol.setSourceTable(leftTable);
4813
4814                                // Add the synthetic chain table column to references
4815                                if (currentSelectScope != null) {
4816                                    columnToScopeMap.put(chainTableCol, currentSelectScope);
4817                                }
4818                                allColumnReferences.add(chainTableCol);
4819
4820                                // Track in USING map for resolution
4821                                usingColumnToLeftTable.put(chainTableCol, leftTable);
4822
4823                                if (DEBUG_SCOPE_BUILD) {
4824                                    System.out.println("[DEBUG] Created synthetic USING column for left table: " +
4825                                                       usingCol.getColumnNameOnly() + " -> " + leftTable.getFullName());
4826                                }
4827                            }
4828                        }
4829
4830                        // Create a synthetic column reference for the right table
4831                        // Clone it and set the clone's sourceTable to right table
4832                        TObjectName rightTableCol = usingCol.clone();
4833                        rightTableCol.setSourceTable(rightTable);
4834
4835                        // Add the synthetic right table column to references
4836                        if (currentSelectScope != null) {
4837                            columnToScopeMap.put(rightTableCol, currentSelectScope);
4838                        }
4839                        allColumnReferences.add(rightTableCol);
4840
4841                        // ONLY track the synthetic column in USING map for right table resolution
4842                        usingColumnToRightTable.put(rightTableCol, rightTable);
4843                    }
4844                }
4845            }
4846        }
4847    }
4848
4849    /**
4850     * Collect all tables from a TTable that might be a join structure.
4851     * If the table is a join (type=join), recursively collect tables from the join tree.
4852     * If it's a simple table (objectname), return just that table.
4853     */
4854    private List<TTable> collectTablesFromJoinExpr(TTable table) {
4855        List<TTable> tables = new ArrayList<>();
4856        if (table == null) {
4857            return tables;
4858        }
4859
4860        if (table.getTableType() == ETableSource.join && table.getJoinExpr() != null) {
4861            // This is a join structure - recursively collect from both sides
4862            TJoinExpr joinExpr = table.getJoinExpr();
4863            tables.addAll(collectTablesFromJoinExpr(joinExpr.getLeftTable()));
4864            tables.addAll(collectTablesFromJoinExpr(joinExpr.getRightTable()));
4865        } else {
4866            // Simple table - add it
4867            tables.add(table);
4868        }
4869
4870        return tables;
4871    }
4872
4873    @Override
4874    public void preVisit(TJoinItem joinItem) {
4875        // Track the right-side table for JOIN...USING column resolution priority
4876        // In "a JOIN table2 USING (id)", joinItem.getTable() is table2 (the right side)
4877        if (DEBUG_SCOPE_BUILD) {
4878            System.out.println("[DEBUG] preVisit(TJoinItem): " + joinItem +
4879                               ", usingColumns=" + (joinItem.getUsingColumns() != null ? joinItem.getUsingColumns().size() : "null") +
4880                               ", lastProcessedFromTable=" + lastProcessedFromTable +
4881                               ", joinChainTables=" + currentJoinChainTables.size());
4882        }
4883        if (joinItem.getUsingColumns() != null && joinItem.getUsingColumns().size() > 0) {
4884            TTable rightTable = joinItem.getTable();
4885            TTable leftTable = lastProcessedFromTable;  // The table processed before this JOIN
4886            if (rightTable != null) {
4887                currentUsingJoinRightTable = rightTable;
4888                // USING clause semantic: For chained joins like "t1 JOIN t2 USING (c1) JOIN t3 USING (c2)",
4889                // the USING column c2 should be linked to ALL tables on the left side (t1 and t2), not just t2.
4890                // This is because the left side of the second join is the result of (t1 JOIN t2).
4891                for (int i = 0; i < joinItem.getUsingColumns().size(); i++) {
4892                    TObjectName usingCol = joinItem.getUsingColumns().getObjectName(i);
4893                    if (usingCol != null) {
4894                        // Track original USING column -> immediate left table (for reference only)
4895                        // This is the primary left table association
4896                        if (leftTable != null) {
4897                            usingColumnToLeftTable.put(usingCol, leftTable);
4898                        }
4899
4900                        // Create synthetic columns for ALL tables in the join chain (left side of this join)
4901                        // This ensures that in "t1 JOIN t2 USING (c1) JOIN t3 USING (c2)", column c2
4902                        // is linked to both t1 and t2, not just t2.
4903                        for (TTable chainTable : currentJoinChainTables) {
4904                            if (chainTable != leftTable) {  // Skip leftTable - handled by original usingCol
4905                                TObjectName chainTableCol = usingCol.clone();
4906                                chainTableCol.setSourceTable(chainTable);
4907
4908                                // Add the synthetic chain table column to references
4909                                if (currentSelectScope != null) {
4910                                    columnToScopeMap.put(chainTableCol, currentSelectScope);
4911                                }
4912                                allColumnReferences.add(chainTableCol);
4913
4914                                // Track in USING map for resolution
4915                                usingColumnToLeftTable.put(chainTableCol, chainTable);
4916
4917                                if (DEBUG_SCOPE_BUILD) {
4918                                    System.out.println("[DEBUG] Created synthetic USING column for chain table: " +
4919                                                       usingCol.getColumnNameOnly() + " -> " + chainTable.getFullName());
4920                                }
4921                            }
4922                        }
4923
4924                        // Create a synthetic column reference for the right table
4925                        // The original usingCol has sourceTable = left table (set by parser)
4926                        // Clone it and set the clone's sourceTable to right table
4927                        TObjectName rightTableCol = usingCol.clone();
4928                        rightTableCol.setSourceTable(rightTable);
4929
4930                        // Add the synthetic right table column to references
4931                        if (currentSelectScope != null) {
4932                            columnToScopeMap.put(rightTableCol, currentSelectScope);
4933                        }
4934                        allColumnReferences.add(rightTableCol);
4935
4936                        // ONLY track the synthetic column in USING map for right table resolution
4937                        // Do NOT add the original usingCol - it should keep its left table resolution
4938                        usingColumnToRightTable.put(rightTableCol, rightTable);
4939                    }
4940                }
4941            }
4942        }
4943    }
4944
4945    @Override
4946    public void postVisit(TJoinItem joinItem) {
4947        // Clear the current USING join right table after processing
4948        if (joinItem.getUsingColumns() != null && joinItem.getUsingColumns().size() > 0) {
4949            currentUsingJoinRightTable = null;
4950        }
4951    }
4952
4953    // ========== Expressions ==========
4954
4955    @Override
4956    public void preVisit(TExpression expression) {
4957        // Handle function expressions (type function_t) - the visitor may not automatically
4958        // traverse to getFunctionCall() and its arguments
4959        if (expression.getExpressionType() == EExpressionType.function_t &&
4960            expression.getFunctionCall() != null) {
4961            TFunctionCall func = expression.getFunctionCall();
4962
4963            // Handle STRUCT and similar functions that store field values in getFieldValues()
4964            if (func.getFieldValues() != null && func.getFieldValues().size() > 0) {
4965                for (int i = 0; i < func.getFieldValues().size(); i++) {
4966                    TResultColumn fieldValue = func.getFieldValues().getResultColumn(i);
4967                    if (fieldValue != null && fieldValue.getExpr() != null) {
4968                        traverseExpressionForColumns(fieldValue.getExpr());
4969                    }
4970                }
4971            }
4972
4973            // Handle regular functions with getArgs() (e.g., TO_JSON_STRING, ARRAY_LENGTH, etc.)
4974            if (func.getArgs() != null && func.getArgs().size() > 0) {
4975                for (int i = 0; i < func.getArgs().size(); i++) {
4976                    TExpression argExpr = func.getArgs().getExpression(i);
4977                    if (argExpr != null) {
4978                        traverseExpressionForColumns(argExpr);
4979                    }
4980                }
4981            }
4982
4983            // Handle special function expressions (CAST, CONVERT, EXTRACT, etc.)
4984            // These functions store their arguments in expr1/expr2/expr3 instead of args
4985            if (func.getExpr1() != null) {
4986                traverseExpressionForColumns(func.getExpr1());
4987            }
4988            if (func.getExpr2() != null) {
4989                traverseExpressionForColumns(func.getExpr2());
4990            }
4991            if (func.getExpr3() != null) {
4992                traverseExpressionForColumns(func.getExpr3());
4993            }
4994        }
4995
4996        // Handle array expressions - objectOperand contains the column reference
4997        if (expression.getExpressionType() == EExpressionType.array_t &&
4998            expression.getObjectOperand() != null) {
4999            preVisit(expression.getObjectOperand());
5000        }
5001
5002        // Handle array access expressions (e.g., str2['ptype'] in SparkSQL/Hive)
5003        // The column reference is in the LeftOperand
5004        if (expression.getExpressionType() == EExpressionType.array_access_expr_t) {
5005            if (expression.getLeftOperand() != null) {
5006                traverseExpressionForColumns(expression.getLeftOperand());
5007            }
5008        }
5009
5010        // Handle CASE expressions - traverse all sub-expressions to collect column references
5011        if (expression.getExpressionType() == EExpressionType.case_t &&
5012            expression.getCaseExpression() != null) {
5013            if (DEBUG_SCOPE_BUILD) {
5014                System.out.println("[DEBUG] preVisit(TExpression): Found CASE expression, currentSelectScope=" +
5015                    (currentSelectScope != null ? "set" : "null"));
5016            }
5017            traverseExpressionForColumns(expression);
5018        }
5019
5020        // Handle lambda expressions - mark parameters as NOT column references
5021        // Lambda parameters are local function parameters, not table column references
5022        // e.g., in "aggregate(array, 0, (acc, x) -> acc + x)", acc and x are lambda parameters
5023        if (expression.getExpressionType() == EExpressionType.lambda_t) {
5024            TExpression paramExpr = expression.getLeftOperand();
5025            TExpression bodyExpr = expression.getRightOperand();
5026            if (paramExpr != null) {
5027                // Collect parameter names first
5028                Set<String> paramNames = new HashSet<>();
5029                collectLambdaParameterNames(paramExpr, paramNames);
5030
5031                // Push parameter names onto the stack for use in preVisit(TObjectName)
5032                lambdaParameterStack.push(paramNames);
5033
5034                // Mark the parameter definition TObjectNames
5035                collectLambdaParameterObjects(paramExpr);
5036
5037                // Mark all usages in the body that match parameter names
5038                if (bodyExpr != null && !paramNames.isEmpty()) {
5039                    markLambdaParameterUsages(bodyExpr, paramNames);
5040                }
5041            }
5042        }
5043
5044        // Handle typecast expressions - the visitor may not automatically traverse the left operand
5045        // e.g., for "$1:apMac::string", we need to traverse "$1:apMac" to collect the column reference
5046        if (expression.getExpressionType() == EExpressionType.typecast_t) {
5047            if (expression.getLeftOperand() != null) {
5048                traverseExpressionForColumns(expression.getLeftOperand());
5049            }
5050        }
5051
5052        // Handle named argument expressions (e.g., "INPUT => value" in Snowflake FLATTEN)
5053        // The left operand is the parameter name, NOT a column reference.
5054        // Mark it with ttobjNamedArgParameter objectType so all downstream consumers skip it.
5055        if (expression.getExpressionType() == EExpressionType.assignment_t) {
5056            TExpression leftOp = expression.getLeftOperand();
5057            if (leftOp != null &&
5058                leftOp.getExpressionType() == EExpressionType.simple_object_name_t &&
5059                leftOp.getObjectOperand() != null) {
5060                TObjectName paramName = leftOp.getObjectOperand();
5061                // Set the objectType to mark this as a named argument parameter
5062                // This marking persists on the AST node and will be respected by
5063                // all downstream consumers (resolver, data lineage analyzer, etc.)
5064                paramName.setObjectType(TObjectName.ttobjNamedArgParameter);
5065                namedArgumentParameters.add(paramName);
5066                if (DEBUG_SCOPE_BUILD) {
5067                    System.out.println("[DEBUG] Marked named argument parameter: " +
5068                        paramName.toString() + " with objectType=" + TObjectName.ttobjNamedArgParameter);
5069                }
5070            }
5071        }
5072    }
5073
5074    @Override
5075    public void postVisit(TExpression expression) {
5076        // Pop lambda parameter context when exiting a lambda expression
5077        if (expression.getExpressionType() == EExpressionType.lambda_t) {
5078            TExpression paramExpr = expression.getLeftOperand();
5079            if (paramExpr != null && !lambdaParameterStack.isEmpty()) {
5080                lambdaParameterStack.pop();
5081            }
5082        }
5083    }
5084
5085    /**
5086     * Recursively collect lambda parameter NAMES as strings.
5087     */
5088    private void collectLambdaParameterNames(TExpression paramExpr, Set<String> paramNames) {
5089        if (paramExpr == null) return;
5090
5091        // Single parameter: expression has objectOperand
5092        if (paramExpr.getObjectOperand() != null) {
5093            String name = paramExpr.getObjectOperand().toString();
5094            if (name != null && !name.isEmpty()) {
5095                paramNames.add(name.toLowerCase());
5096            }
5097            return;
5098        }
5099
5100        // Multiple parameters: expression has exprList
5101        if (paramExpr.getExprList() != null) {
5102            for (int i = 0; i < paramExpr.getExprList().size(); i++) {
5103                TExpression e = paramExpr.getExprList().getExpression(i);
5104                collectLambdaParameterNames(e, paramNames);
5105            }
5106        }
5107    }
5108
5109    /**
5110     * Recursively collect all TObjectName nodes from lambda parameter definitions.
5111     */
5112    private void collectLambdaParameterObjects(TExpression paramExpr) {
5113        if (paramExpr == null) return;
5114
5115        if (paramExpr.getObjectOperand() != null) {
5116            lambdaParameters.add(paramExpr.getObjectOperand());
5117            return;
5118        }
5119
5120        if (paramExpr.getExprList() != null) {
5121            for (int i = 0; i < paramExpr.getExprList().size(); i++) {
5122                TExpression e = paramExpr.getExprList().getExpression(i);
5123                collectLambdaParameterObjects(e);
5124            }
5125        }
5126    }
5127
5128    /**
5129     * Recursively find and mark all TObjectName usages in a lambda body that match parameter names.
5130     */
5131    private void markLambdaParameterUsages(TExpression bodyExpr, Set<String> paramNames) {
5132        if (bodyExpr == null) return;
5133
5134        // Use iterative DFS to avoid StackOverflowError for deeply nested expression chains
5135        Deque<TExpression> stack = new ArrayDeque<>();
5136        stack.push(bodyExpr);
5137        while (!stack.isEmpty()) {
5138            TExpression current = stack.pop();
5139            if (current == null) continue;
5140
5141            // Check if this expression is a simple column reference matching a parameter name
5142            if (current.getObjectOperand() != null) {
5143                TObjectName objName = current.getObjectOperand();
5144                String name = objName.toString();
5145                if (name != null && paramNames.contains(name.toLowerCase())) {
5146                    lambdaParameters.add(objName);
5147                }
5148            }
5149
5150            // Push sub-expressions onto stack (right first so left is processed first)
5151            if (current.getRightOperand() != null) {
5152                stack.push(current.getRightOperand());
5153            }
5154            if (current.getLeftOperand() != null) {
5155                stack.push(current.getLeftOperand());
5156            }
5157            if (current.getExprList() != null) {
5158                for (int i = current.getExprList().size() - 1; i >= 0; i--) {
5159                    stack.push(current.getExprList().getExpression(i));
5160                }
5161            }
5162
5163            // Handle CASE expressions
5164            if (current.getCaseExpression() != null) {
5165                TCaseExpression caseExpr = current.getCaseExpression();
5166                if (caseExpr.getElse_expr() != null) {
5167                    stack.push(caseExpr.getElse_expr());
5168                }
5169                if (caseExpr.getWhenClauseItemList() != null) {
5170                    for (int i = caseExpr.getWhenClauseItemList().size() - 1; i >= 0; i--) {
5171                        TWhenClauseItem item = caseExpr.getWhenClauseItemList().getWhenClauseItem(i);
5172                        if (item.getReturn_expr() != null) {
5173                            stack.push(item.getReturn_expr());
5174                        }
5175                        if (item.getComparison_expr() != null) {
5176                            stack.push(item.getComparison_expr());
5177                        }
5178                    }
5179                }
5180                if (caseExpr.getInput_expr() != null) {
5181                    stack.push(caseExpr.getInput_expr());
5182                }
5183            }
5184
5185            // Handle function calls
5186            if (current.getFunctionCall() != null) {
5187                TFunctionCall func = current.getFunctionCall();
5188                if (func.getArgs() != null) {
5189                    for (int i = func.getArgs().size() - 1; i >= 0; i--) {
5190                        stack.push(func.getArgs().getExpression(i));
5191                    }
5192                }
5193            }
5194        }
5195    }
5196
5197    // ========== Function Calls ==========
5198
5199    @Override
5200    public void preVisit(TFunctionCall functionCall) {
5201        // Handle SQL Server UPDATE() function in trigger context
5202        // UPDATE(column_name) is used in triggers to check if a column was updated
5203        // The column argument should be resolved to the trigger target table
5204        if ((dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql) &&
5205            currentTriggerTargetTable != null &&
5206            functionCall.getFunctionName() != null) {
5207            String funcName = functionCall.getFunctionName().toString();
5208            if ("update".equalsIgnoreCase(funcName) || "columns_updated".equalsIgnoreCase(funcName)) {
5209                // UPDATE(column) - link the column argument to trigger target table
5210                if (functionCall.getArgs() != null && functionCall.getArgs().size() == 1) {
5211                    TExpression argExpr = functionCall.getArgs().getExpression(0);
5212                    if (argExpr != null && argExpr.getObjectOperand() != null) {
5213                        TObjectName columnName = argExpr.getObjectOperand();
5214                        columnName.setSourceTable(currentTriggerTargetTable);
5215
5216                        // Add to allColumnReferences if not already there
5217                        if (!allColumnReferences.contains(columnName)) {
5218                            allColumnReferences.add(columnName);
5219                        }
5220
5221                        // Map the column to the current scope
5222                        IScope currentScope = scopeStack.isEmpty() ? globalScope : scopeStack.peek();
5223                        columnToScopeMap.put(columnName, currentScope);
5224
5225                        if (DEBUG_SCOPE_BUILD) {
5226                            System.out.println("[DEBUG] preVisit(TFunctionCall/update): " +
5227                                columnName + " -> " + currentTriggerTargetTable.getFullName());
5228                        }
5229                    }
5230                }
5231            }
5232        }
5233
5234        // Mark keyword arguments in built-in functions so they are not treated as column references.
5235        // For example, SECOND in TIMESTAMP_DIFF(ts1, ts2, SECOND) should not be collected as a column.
5236        // We do this in preVisit because TObjectName nodes are visited after TFunctionCall.
5237        markFunctionKeywordArguments(functionCall);
5238
5239        // Handle special functions like STRUCT that store field values in getFieldValues()
5240        // instead of getArgs(). The visitor pattern may not automatically traverse these.
5241        if (functionCall.getFieldValues() != null && functionCall.getFieldValues().size() > 0) {
5242            // STRUCT and similar functions - traverse the field values to collect column references
5243            for (int i = 0; i < functionCall.getFieldValues().size(); i++) {
5244                TResultColumn fieldValue = functionCall.getFieldValues().getResultColumn(i);
5245                if (fieldValue != null && fieldValue.getExpr() != null) {
5246                    // Manually traverse the field value expression
5247                    traverseExpressionForColumns(fieldValue.getExpr());
5248                }
5249            }
5250        }
5251
5252        // Handle special function expressions (CAST, CONVERT, EXTRACT, etc.)
5253        // These functions store their arguments in expr1/expr2/expr3 instead of args
5254        // The visitor pattern may not automatically traverse these expressions.
5255        if (functionCall.getExpr1() != null) {
5256            traverseExpressionForColumns(functionCall.getExpr1());
5257        }
5258        if (functionCall.getExpr2() != null) {
5259            traverseExpressionForColumns(functionCall.getExpr2());
5260        }
5261        if (functionCall.getExpr3() != null) {
5262            traverseExpressionForColumns(functionCall.getExpr3());
5263        }
5264
5265        // Handle XML functions that store their arguments in special properties
5266        // These functions don't use getArgs() but have dedicated value expression lists
5267        // XMLELEMENT: getXMLElementValueExprList() contains the value expressions
5268        // XMLFOREST: getXMLForestValueList() contains the value expressions
5269        // XMLQUERY/XMLEXISTS: getXmlPassingClause().getPassingList() contains column refs
5270        // XMLAttributes: getXMLAttributesClause().getValueExprList() contains attributes
5271        if (functionCall.getXMLElementValueExprList() != null) {
5272            TResultColumnList xmlValueList = functionCall.getXMLElementValueExprList();
5273            for (int i = 0; i < xmlValueList.size(); i++) {
5274                TResultColumn rc = xmlValueList.getResultColumn(i);
5275                if (rc != null && rc.getExpr() != null) {
5276                    traverseExpressionForColumns(rc.getExpr());
5277                }
5278            }
5279        }
5280        if (functionCall.getXMLForestValueList() != null) {
5281            TResultColumnList xmlForestList = functionCall.getXMLForestValueList();
5282            for (int i = 0; i < xmlForestList.size(); i++) {
5283                TResultColumn rc = xmlForestList.getResultColumn(i);
5284                if (rc != null && rc.getExpr() != null) {
5285                    traverseExpressionForColumns(rc.getExpr());
5286                }
5287            }
5288        }
5289        if (functionCall.getXmlPassingClause() != null &&
5290            functionCall.getXmlPassingClause().getPassingList() != null) {
5291            TResultColumnList passingList = functionCall.getXmlPassingClause().getPassingList();
5292            for (int i = 0; i < passingList.size(); i++) {
5293                TResultColumn rc = passingList.getResultColumn(i);
5294                if (rc != null && rc.getExpr() != null) {
5295                    traverseExpressionForColumns(rc.getExpr());
5296                }
5297            }
5298        }
5299        if (functionCall.getXMLAttributesClause() != null &&
5300            functionCall.getXMLAttributesClause().getValueExprList() != null) {
5301            TResultColumnList attrList = functionCall.getXMLAttributesClause().getValueExprList();
5302            for (int i = 0; i < attrList.size(); i++) {
5303                TResultColumn rc = attrList.getResultColumn(i);
5304                if (rc != null && rc.getExpr() != null) {
5305                    traverseExpressionForColumns(rc.getExpr());
5306                }
5307            }
5308        }
5309        // Handle XMLCAST/XMLQUERY typeExpression - stores the inner expression to cast
5310        // For XMLCAST(expr AS type), the expr is stored in typeExpression
5311        if (functionCall.getTypeExpression() != null) {
5312            traverseExpressionForColumns(functionCall.getTypeExpression());
5313        }
5314
5315        // Skip if this is a table-valued function (from FROM clause)
5316        // Table functions like [exce].[sampleTable]() should not be treated as column.method()
5317        if (tableValuedFunctionCalls.contains(functionCall)) {
5318            return;
5319        }
5320
5321        // Handle OGC/spatial/CLR method calls on columns (SQL Server specific)
5322        // These can be in two forms:
5323        // 1. table.column.method() - 3-part name where:
5324        //    - databaseToken = table alias (e.g., "ad")
5325        //    - schemaToken = column name (e.g., "SpatialLocation")
5326        //    - objectToken = method name (e.g., "STDistance")
5327        //
5328        // 2. column.method() - 2-part name where:
5329        //    - schemaToken = column name (e.g., "SpatialLocation")
5330        //    - objectToken = method name (e.g., "STDistance")
5331        //    - databaseToken = null
5332        //    In this case, if there's only one table in FROM, infer the column belongs to it
5333        //
5334        // NOTE: This is SQL Server specific because Oracle uses schema.function() syntax
5335        // for package calls (e.g., DBMS_OUTPUT.PUT_LINE, ERRLOG.LOAD_ERR_DTL).
5336        //
5337        // NOTE: We must skip static type methods like "geography::STGeomFromText()"
5338        // These use the :: syntax and the "schemaToken" is actually a type name, not a column.
5339        //
5340        // We extract the column reference and link it to the source table
5341        // Only apply column method handling for SQL Server (not Oracle, etc.)
5342        if (dbVendor == EDbVendor.dbvmssql || dbVendor == EDbVendor.dbvazuresql) {
5343            TObjectName funcName = functionCall.getFunctionName();
5344
5345            if (DEBUG_SCOPE_BUILD) {
5346                System.out.println("[DEBUG] preVisit(TFunctionCall): " + functionCall);
5347                System.out.println("[DEBUG]   funcName: " + funcName);
5348                if (funcName != null) {
5349                    System.out.println("[DEBUG]   schemaToken: " + funcName.getSchemaToken());
5350                    System.out.println("[DEBUG]   databaseToken: " + funcName.getDatabaseToken());
5351                    System.out.println("[DEBUG]   objectToken: " + funcName.getObjectToken());
5352                    System.out.println("[DEBUG]   currentFromScope: " + (currentFromScope != null ? "exists" : "null"));
5353                }
5354            }
5355
5356            if (funcName != null) {
5357                String funcNameStr = funcName.toString();
5358
5359                // Skip static type methods like "geography::STGeomFromText()"
5360                // These are identified by the "::" in the function name string
5361                if (funcNameStr != null && funcNameStr.contains("::")) {
5362                    if (DEBUG_SCOPE_BUILD) {
5363                        System.out.println("[DEBUG]   Skipping static type method: " + funcNameStr);
5364                    }
5365                    return; // Don't process static type methods as column references
5366                }
5367
5368                if (funcName.getSchemaToken() != null) {
5369                    // schemaToken might be a column name (column.method()) or a schema name (schema.function())
5370                    // We distinguish by:
5371                    // 1. First checking SQLEnv if the full function name exists as a registered function
5372                    // 2. Then checking if the first part is a known SQL Server system schema name
5373                    String possibleColumnOrSchema = funcName.getSchemaToken().toString();
5374
5375                    // Check SQLEnv first - if the function is registered, this is schema.function()
5376                    // This handles user-defined functions with custom schema names (e.g., dbo1.ufnGetInventoryStock)
5377                    if (sqlEnv != null && sqlEnv.searchFunction(funcNameStr) != null) {
5378                        if (DEBUG_SCOPE_BUILD) {
5379                            System.out.println("[DEBUG]   Skipping schema.function() call (found in SQLEnv): " + funcNameStr);
5380                        }
5381                        return; // Don't treat as column.method()
5382                    }
5383
5384                    // Also check for known SQL Server system schema names as a fallback
5385                    // (for functions not explicitly created in this batch but are system/built-in)
5386                    if (isSqlServerSchemaName(possibleColumnOrSchema)) {
5387                        if (DEBUG_SCOPE_BUILD) {
5388                            System.out.println("[DEBUG]   Skipping schema.function() call (system schema): " + funcNameStr);
5389                        }
5390                        return; // Don't treat as column.method()
5391                    }
5392
5393                    if (funcName.getDatabaseToken() != null) {
5394                        // Case 1: 3-part name (table.column.method)
5395                        String tableAlias = funcName.getDatabaseToken().toString();
5396                        if (DEBUG_SCOPE_BUILD) {
5397                            System.out.println("[DEBUG]   Detected 3-part name: " + tableAlias + "." + possibleColumnOrSchema + ".method");
5398                        }
5399                        handleMethodCallOnColumn(tableAlias, possibleColumnOrSchema);
5400                    } else if (currentFromScope != null) {
5401                        // Case 2: 2-part name (column.method) with schemaToken set
5402                        if (DEBUG_SCOPE_BUILD) {
5403                            System.out.println("[DEBUG]   Detected 2-part name (schemaToken): " + possibleColumnOrSchema + ".method");
5404                        }
5405                        handleUnqualifiedMethodCall(possibleColumnOrSchema);
5406                    }
5407                } else if (funcNameStr != null && funcNameStr.contains(".") && currentFromScope != null) {
5408                    // Alternative case: function name contains dots but schemaToken is null
5409                    // This happens in UPDATE SET clause: Location.SetXY(...)
5410                    // Or in SELECT with 3-part names: p.Demographics.value(...)
5411                    //
5412                    // We need to distinguish between:
5413                    // - 2-part: column.method() - first part is a column name
5414                    // - 3-part: table.column.method() - first part is a table alias
5415                    //
5416                    // We check if the first part matches a table alias in the current FROM scope
5417                    int firstDotPos = funcNameStr.indexOf('.');
5418                    if (firstDotPos > 0) {
5419                        String firstPart = funcNameStr.substring(0, firstDotPos);
5420                        String remainder = funcNameStr.substring(firstDotPos + 1);
5421
5422                        // Check if first part is a table alias
5423                        boolean firstPartIsTable = false;
5424                        for (ScopeChild child : currentFromScope.getChildren()) {
5425                            if (nameMatcher.matches(child.getAlias(), firstPart)) {
5426                                firstPartIsTable = true;
5427                                break;
5428                            }
5429                        }
5430
5431                        if (firstPartIsTable) {
5432                            // 3-part name: table.column.method()
5433                            // Extract column name from remainder (before the next dot, if any)
5434                            int secondDotPos = remainder.indexOf('.');
5435                            if (secondDotPos > 0) {
5436                                String columnName = remainder.substring(0, secondDotPos);
5437                                if (DEBUG_SCOPE_BUILD) {
5438                                    System.out.println("[DEBUG]   Detected 3-part name (parsed): " + firstPart + "." + columnName + ".method");
5439                                }
5440                                handleMethodCallOnColumn(firstPart, columnName);
5441                            }
5442                            // If no second dot, the structure is ambiguous (table.method?) - skip it
5443                        } else {
5444                            // 2-part name: column.method()
5445                            if (DEBUG_SCOPE_BUILD) {
5446                                System.out.println("[DEBUG]   Detected 2-part name (parsed): " + firstPart + ".method");
5447                            }
5448                            handleUnqualifiedMethodCall(firstPart);
5449                        }
5450                    }
5451                }
5452            }
5453        }
5454    }
5455
5456    /**
5457     * Mark keyword arguments in a function call so they are not treated as column references.
5458     * Uses TBuiltFunctionUtil to check which argument positions contain keywords.
5459     */
5460    private void markFunctionKeywordArguments(TFunctionCall functionCall) {
5461        if (functionCall.getArgs() == null || functionCall.getArgs().size() == 0) {
5462            return;
5463        }
5464        if (functionCall.getFunctionName() == null) {
5465            return;
5466        }
5467
5468        String functionName = functionCall.getFunctionName().toString();
5469        Set<Integer> keywordPositions = TBuiltFunctionUtil.argumentsIncludeKeyword(dbVendor, functionName);
5470
5471        if (keywordPositions == null || keywordPositions.isEmpty()) {
5472            return;
5473        }
5474
5475        TExpressionList args = functionCall.getArgs();
5476        for (Integer pos : keywordPositions) {
5477            int index = pos - 1; // TBuiltFunctionUtil uses 1-based positions
5478            if (index >= 0 && index < args.size()) {
5479                TExpression argExpr = args.getExpression(index);
5480                // Mark the objectOperand if it's a simple object name or constant
5481                if (argExpr != null) {
5482                    TObjectName objectName = argExpr.getObjectOperand();
5483                    if (objectName != null) {
5484                        functionKeywordArguments.add(objectName);
5485                        if (DEBUG_SCOPE_BUILD) {
5486                            System.out.println("[DEBUG] Marked function keyword argument: " +
5487                                objectName.toString() + " at position " + pos +
5488                                " in function " + functionName);
5489                        }
5490                    }
5491                }
5492            }
5493        }
5494    }
5495
5496    /**
5497     * Handle a qualified method call on a column: table.column.method()
5498     * Creates a synthetic column reference and links it to the source table.
5499     *
5500     * @param tableAlias the table alias or name
5501     * @param columnName the column name
5502     */
5503    private void handleMethodCallOnColumn(String tableAlias, String columnName) {
5504        if (currentFromScope == null) {
5505            return;
5506        }
5507
5508        for (ScopeChild child : currentFromScope.getChildren()) {
5509            if (nameMatcher.matches(child.getAlias(), tableAlias)) {
5510                // Found matching table - create column reference
5511                createAndRegisterColumnReference(tableAlias, columnName, child);
5512                break;
5513            }
5514        }
5515    }
5516
5517    /**
5518     * Handle an unqualified method call on a column: column.method()
5519     * Attempts to infer the table when there's only one table in FROM,
5520     * or when the column name uniquely identifies the source table.
5521     *
5522     * @param columnName the column name
5523     */
5524    private void handleUnqualifiedMethodCall(String columnName) {
5525        if (currentFromScope == null) {
5526            return;
5527        }
5528
5529        List<ScopeChild> children = currentFromScope.getChildren();
5530
5531        // Case 1: Only one table in FROM - column must belong to it
5532        if (children.size() == 1) {
5533            ScopeChild child = children.get(0);
5534            createAndRegisterColumnReference(child.getAlias(), columnName, child);
5535            return;
5536        }
5537
5538        // Case 2: Multiple tables - try to find which table has this column
5539        // This requires metadata from TableNamespace or SQLEnv
5540        ScopeChild matchedChild = null;
5541        int matchCount = 0;
5542
5543        for (ScopeChild child : children) {
5544            INamespace ns = child.getNamespace();
5545            if (ns != null) {
5546                // Check if this namespace has the column
5547                ColumnLevel level = ns.hasColumn(columnName);
5548                if (level == ColumnLevel.EXISTS) {
5549                    matchedChild = child;
5550                    matchCount++;
5551                } else if (level == ColumnLevel.MAYBE && matchedChild == null) {
5552                    // MAYBE means the table has no metadata, so column might exist
5553                    // Only use as fallback if no definite match found
5554                    matchedChild = child;
5555                }
5556            }
5557        }
5558
5559        // If exactly one table definitely has the column, use it
5560        // If no definite match but one MAYBE, use that as fallback
5561        if (matchedChild != null && (matchCount <= 1)) {
5562            createAndRegisterColumnReference(matchedChild.getAlias(), columnName, matchedChild);
5563        }
5564    }
5565
5566    /**
5567     * Create and register a synthetic column reference for a method call on a column.
5568     *
5569     * @param tableAlias the table alias or name
5570     * @param columnName the column name
5571     * @param scopeChild the ScopeChild containing the namespace
5572     */
5573    private void createAndRegisterColumnReference(String tableAlias, String columnName, ScopeChild scopeChild) {
5574        // Create TObjectName with proper tokens for table.column
5575        // Use TObjectName.createObjectName() which properly initializes objectToken and partToken
5576        TSourceToken tableToken = new TSourceToken(tableAlias);
5577        TSourceToken columnToken = new TSourceToken(columnName);
5578        TObjectName columnRef = TObjectName.createObjectName(
5579            dbVendor,
5580            EDbObjectType.column,
5581            tableToken,
5582            columnToken
5583        );
5584
5585        // Get the TTable from the namespace
5586        INamespace ns = scopeChild.getNamespace();
5587        TTable sourceTable = null;
5588        if (ns instanceof TableNamespace) {
5589            sourceTable = ((TableNamespace) ns).getTable();
5590        } else if (ns instanceof CTENamespace) {
5591            sourceTable = ((CTENamespace) ns).getReferencingTable();
5592        } else if (ns != null) {
5593            // For other namespace types (SubqueryNamespace, etc.), try getFinalTable()
5594            sourceTable = ns.getFinalTable();
5595        }
5596
5597        if (sourceTable != null) {
5598            columnRef.setSourceTable(sourceTable);
5599        }
5600
5601        // Determine the scope for this column
5602        IScope columnScope = determineColumnScope(columnRef);
5603        if (columnScope != null) {
5604            columnToScopeMap.put(columnRef, columnScope);
5605        }
5606        allColumnReferences.add(columnRef);
5607
5608        if (DEBUG_SCOPE_BUILD) {
5609            System.out.println("[DEBUG] Created synthetic column reference for method call: " +
5610                    tableAlias + "." + columnName + " -> " +
5611                    (sourceTable != null ? sourceTable.getFullName() : "unknown"));
5612        }
5613    }
5614
5615    /**
5616     * Traverse an expression to collect column references.
5617     * Uses iterative left-chain descent for pure binary expression chains
5618     * to avoid StackOverflowError for deeply nested AND/OR/arithmetic chains.
5619     */
5620    private void traverseExpressionForColumns(TExpression expr) {
5621        if (expr == null) return;
5622
5623        // For pure binary expression chains (AND/OR/arithmetic), use iterative descent
5624        // to avoid StackOverflowError. Binary expressions don't have objectOperand,
5625        // functionCall, exprList, subQuery, or caseExpression - only left/right operands.
5626        if (TExpression.isPureBinaryForDoParse(expr.getExpressionType())) {
5627            Deque<TExpression> rightChildren = new ArrayDeque<>();
5628            TExpression current = expr;
5629            while (current != null && TExpression.isPureBinaryForDoParse(current.getExpressionType())) {
5630                if (current.getRightOperand() != null) {
5631                    rightChildren.push(current.getRightOperand());
5632                }
5633                current = current.getLeftOperand();
5634            }
5635            // Process leftmost leaf (not a pure binary type, safe to recurse)
5636            if (current != null) {
5637                traverseExpressionForColumns(current);
5638            }
5639            // Process right children bottom-up (preserves left-to-right order)
5640            while (!rightChildren.isEmpty()) {
5641                traverseExpressionForColumns(rightChildren.pop());
5642            }
5643            return;
5644        }
5645
5646        // Handle lambda expressions - push parameter names before traversing body
5647        boolean isLambda = (expr.getExpressionType() == EExpressionType.lambda_t);
5648        if (isLambda && expr.getLeftOperand() != null) {
5649            Set<String> paramNames = new HashSet<>();
5650            collectLambdaParameterNames(expr.getLeftOperand(), paramNames);
5651            lambdaParameterStack.push(paramNames);
5652            // Also collect the parameter objects
5653            collectLambdaParameterObjects(expr.getLeftOperand());
5654        }
5655
5656        // Check for column reference in objectOperand (common in array access, simple names)
5657        // Skip constants (e.g., DAY in DATEDIFF(DAY, ...) is parsed as simple_constant_t)
5658        if (expr.getObjectOperand() != null &&
5659            expr.getExpressionType() != EExpressionType.simple_constant_t) {
5660            if (DEBUG_SCOPE_BUILD) {
5661                System.out.println("[DEBUG] traverseExpressionForColumns: Found objectOperand=" +
5662                    expr.getObjectOperand().toString() + " in expr type=" + expr.getExpressionType());
5663            }
5664            // This will trigger preVisit(TObjectName) if not a table reference
5665            preVisit(expr.getObjectOperand());
5666        }
5667
5668        // Traverse sub-expressions
5669        // For assignment_t expressions (named arguments like "INPUT => value" in Snowflake FLATTEN),
5670        // the left operand is the parameter name, NOT a column reference.
5671        // Only traverse the right operand (the value) for assignment_t expressions.
5672        boolean isNamedArgument = (expr.getExpressionType() == EExpressionType.assignment_t);
5673        if (expr.getLeftOperand() != null && !isNamedArgument) {
5674            traverseExpressionForColumns(expr.getLeftOperand());
5675        }
5676        if (expr.getRightOperand() != null) {
5677            traverseExpressionForColumns(expr.getRightOperand());
5678        }
5679
5680        // Pop lambda parameter context after traversing
5681        if (isLambda && expr.getLeftOperand() != null && !lambdaParameterStack.isEmpty()) {
5682            lambdaParameterStack.pop();
5683        }
5684
5685        // Traverse function call arguments
5686        if (expr.getFunctionCall() != null) {
5687            TFunctionCall func = expr.getFunctionCall();
5688            if (func.getArgs() != null) {
5689                for (int i = 0; i < func.getArgs().size(); i++) {
5690                    traverseExpressionForColumns(func.getArgs().getExpression(i));
5691                }
5692            }
5693            // Handle STRUCT field values recursively
5694            if (func.getFieldValues() != null) {
5695                for (int i = 0; i < func.getFieldValues().size(); i++) {
5696                    TResultColumn rc = func.getFieldValues().getResultColumn(i);
5697                    if (rc != null && rc.getExpr() != null) {
5698                        traverseExpressionForColumns(rc.getExpr());
5699                    }
5700                }
5701            }
5702            // Handle special function expressions (CAST, CONVERT, EXTRACT, etc.)
5703            // These functions store their arguments in expr1/expr2/expr3 instead of args
5704            if (func.getExpr1() != null) {
5705                traverseExpressionForColumns(func.getExpr1());
5706            }
5707            if (func.getExpr2() != null) {
5708                traverseExpressionForColumns(func.getExpr2());
5709            }
5710            if (func.getExpr3() != null) {
5711                traverseExpressionForColumns(func.getExpr3());
5712            }
5713            // Handle XML functions that store their arguments in special properties
5714            if (func.getXMLElementValueExprList() != null) {
5715                TResultColumnList xmlValueList = func.getXMLElementValueExprList();
5716                for (int j = 0; j < xmlValueList.size(); j++) {
5717                    TResultColumn rc = xmlValueList.getResultColumn(j);
5718                    if (rc != null && rc.getExpr() != null) {
5719                        traverseExpressionForColumns(rc.getExpr());
5720                    }
5721                }
5722            }
5723            if (func.getXMLForestValueList() != null) {
5724                TResultColumnList xmlForestList = func.getXMLForestValueList();
5725                for (int j = 0; j < xmlForestList.size(); j++) {
5726                    TResultColumn rc = xmlForestList.getResultColumn(j);
5727                    if (rc != null && rc.getExpr() != null) {
5728                        traverseExpressionForColumns(rc.getExpr());
5729                    }
5730                }
5731            }
5732            if (func.getXmlPassingClause() != null &&
5733                func.getXmlPassingClause().getPassingList() != null) {
5734                TResultColumnList passingList = func.getXmlPassingClause().getPassingList();
5735                for (int j = 0; j < passingList.size(); j++) {
5736                    TResultColumn rc = passingList.getResultColumn(j);
5737                    if (rc != null && rc.getExpr() != null) {
5738                        traverseExpressionForColumns(rc.getExpr());
5739                    }
5740                }
5741            }
5742            if (func.getXMLAttributesClause() != null &&
5743                func.getXMLAttributesClause().getValueExprList() != null) {
5744                TResultColumnList attrList = func.getXMLAttributesClause().getValueExprList();
5745                for (int j = 0; j < attrList.size(); j++) {
5746                    TResultColumn rc = attrList.getResultColumn(j);
5747                    if (rc != null && rc.getExpr() != null) {
5748                        traverseExpressionForColumns(rc.getExpr());
5749                    }
5750                }
5751            }
5752            // Handle XMLCAST typeExpression
5753            if (func.getTypeExpression() != null) {
5754                traverseExpressionForColumns(func.getTypeExpression());
5755            }
5756        }
5757
5758        // Traverse expression list (e.g., IN clause)
5759        if (expr.getExprList() != null) {
5760            for (int i = 0; i < expr.getExprList().size(); i++) {
5761                traverseExpressionForColumns(expr.getExprList().getExpression(i));
5762            }
5763        }
5764
5765        // Traverse subquery if present
5766        if (expr.getSubQuery() != null) {
5767            expr.getSubQuery().acceptChildren(this);
5768        }
5769
5770        // Traverse CASE expression
5771        if (expr.getExpressionType() == EExpressionType.case_t && expr.getCaseExpression() != null) {
5772            TCaseExpression caseExpr = expr.getCaseExpression();
5773
5774            // Traverse input expression (for simple CASE: CASE input_expr WHEN ...)
5775            if (caseExpr.getInput_expr() != null) {
5776                traverseExpressionForColumns(caseExpr.getInput_expr());
5777            }
5778
5779            // Traverse each WHEN...THEN clause
5780            if (caseExpr.getWhenClauseItemList() != null) {
5781                for (int i = 0; i < caseExpr.getWhenClauseItemList().size(); i++) {
5782                    TWhenClauseItem whenItem = caseExpr.getWhenClauseItemList().getWhenClauseItem(i);
5783                    if (whenItem != null) {
5784                        // Traverse WHEN condition
5785                        if (whenItem.getComparison_expr() != null) {
5786                            traverseExpressionForColumns(whenItem.getComparison_expr());
5787                        }
5788                        // Traverse PostgreSQL condition list
5789                        if (whenItem.getConditionList() != null) {
5790                            for (int j = 0; j < whenItem.getConditionList().size(); j++) {
5791                                traverseExpressionForColumns(whenItem.getConditionList().getExpression(j));
5792                            }
5793                        }
5794                        // Traverse THEN result
5795                        if (whenItem.getReturn_expr() != null) {
5796                            traverseExpressionForColumns(whenItem.getReturn_expr());
5797                        }
5798                    }
5799                }
5800            }
5801
5802            // Traverse ELSE expression
5803            if (caseExpr.getElse_expr() != null) {
5804                traverseExpressionForColumns(caseExpr.getElse_expr());
5805            }
5806        }
5807    }
5808
5809    // ========== Result Columns ==========
5810
5811    @Override
5812    public void preVisit(TResultColumn resultColumn) {
5813        // Mark that we're inside a result column context.
5814        // Lateral alias matching should ONLY apply inside result columns.
5815        inResultColumnContext = true;
5816
5817        // Track the current result column's alias to exclude from lateral alias matching.
5818        // A column reference inside the expression that DEFINES an alias cannot be
5819        // a reference TO that alias - it must be a reference to the source table column.
5820        // S2: must use the same vendor-aware key as the lateral alias set so the
5821        // exclusion check at isLateralColumnAlias() compares like-with-like (e.g.
5822        // Snowflake folds unquoted identifiers to UPPER; if this stayed
5823        // lowercase, the equals() check below would fail and a column reference
5824        // inside its own alias-defining expression would be wrongly treated as a
5825        // lateral alias reference, dropping it from the column-reference set).
5826        if (resultColumn.getAliasClause() != null && resultColumn.getAliasClause().getAliasName() != null) {
5827            currentResultColumnAlias = keyForColumn(normalizeAliasName(
5828                resultColumn.getAliasClause().getAliasName().toString()));
5829        } else {
5830            currentResultColumnAlias = null;
5831        }
5832
5833        // Collect SQL Server proprietary column aliases (column_alias = expression)
5834        // These should not be treated as column references
5835        if (resultColumn.getExpr() != null &&
5836            resultColumn.getExpr().getExpressionType() == EExpressionType.sqlserver_proprietary_column_alias_t) {
5837            TExpression leftOperand = resultColumn.getExpr().getLeftOperand();
5838            if (leftOperand != null &&
5839                leftOperand.getExpressionType() == EExpressionType.simple_object_name_t &&
5840                leftOperand.getObjectOperand() != null) {
5841                sqlServerProprietaryAliases.add(leftOperand.getObjectOperand());
5842                if (DEBUG_SCOPE_BUILD) {
5843                    System.out.println("[DEBUG] Collected SQL Server proprietary alias: " +
5844                            leftOperand.getObjectOperand().toString());
5845                }
5846            }
5847        }
5848
5849        // Handle BigQuery EXCEPT columns: SELECT * EXCEPT (column1, column2)
5850        // EXCEPT columns are added to allColumnReferences for DDL verification and lineage tracking,
5851        // but WITHOUT scope mapping to avoid triggering auto-inference in inner namespaces.
5852        // Star column expansion in TSQLResolver2 handles EXCEPT filtering directly.
5853        TObjectNameList exceptColumns = resultColumn.getExceptColumnList();
5854        if (exceptColumns != null && exceptColumns.size() > 0) {
5855            // Get the star column's source table from the expression
5856            TTable starSourceTable = null;
5857            if (resultColumn.getExpr() != null &&
5858                resultColumn.getExpr().getExpressionType() == EExpressionType.simple_object_name_t &&
5859                resultColumn.getExpr().getObjectOperand() != null) {
5860                TObjectName starColumn = resultColumn.getExpr().getObjectOperand();
5861                String starStr = starColumn.toString();
5862                if (starStr != null && starStr.endsWith("*")) {
5863                    // Get the table qualifier (e.g., "COMMON" from "COMMON.*")
5864                    String tableQualifier = starColumn.getTableString();
5865                    if (tableQualifier != null && !tableQualifier.isEmpty()) {
5866                        // Find the table by alias in the current scope
5867                        starSourceTable = findTableByAliasInCurrentScope(tableQualifier);
5868                    }
5869                }
5870            }
5871
5872            for (int i = 0; i < exceptColumns.size(); i++) {
5873                TObjectName exceptCol = exceptColumns.getObjectName(i);
5874                if (exceptCol != null) {
5875                    if (starSourceTable != null) {
5876                        // Qualified star (e.g., COMMON.*): Link EXCEPT column directly to source table
5877                        exceptCol.setSourceTable(starSourceTable);
5878                    }
5879                    // IMPORTANT: Add to allColumnReferences for tracking, but do NOT add to
5880                    // columnToScopeMap - this prevents triggering auto-inference in inner namespaces
5881                    allColumnReferences.add(exceptCol);
5882                    if (DEBUG_SCOPE_BUILD) {
5883                        System.out.println("[DEBUG] EXCEPT column added for tracking (no scope mapping): " +
5884                            exceptCol.toString() + (starSourceTable != null ? " -> " + starSourceTable.getFullName() : ""));
5885                    }
5886                }
5887            }
5888        }
5889
5890        // Handle BigQuery REPLACE columns: SELECT * REPLACE (expr AS identifier)
5891        // REPLACE columns create new columns that replace existing ones in star expansion.
5892        // Link them directly to the star's source table.
5893        java.util.ArrayList<gudusoft.gsqlparser.nodes.TReplaceExprAsIdentifier> replaceColumns =
5894            resultColumn.getReplaceExprAsIdentifiers();
5895        if (replaceColumns != null && replaceColumns.size() > 0) {
5896            // Get the star column's source table (similar to EXCEPT handling)
5897            TTable starSourceTable = null;
5898            if (resultColumn.getExpr() != null &&
5899                resultColumn.getExpr().getExpressionType() == EExpressionType.simple_object_name_t &&
5900                resultColumn.getExpr().getObjectOperand() != null) {
5901                TObjectName starColumn = resultColumn.getExpr().getObjectOperand();
5902                String starStr = starColumn.toString();
5903                if (starStr != null && starStr.endsWith("*")) {
5904                    String tableQualifier = starColumn.getTableString();
5905                    if (tableQualifier != null && !tableQualifier.isEmpty()) {
5906                        starSourceTable = findTableByAliasInCurrentScope(tableQualifier);
5907                    }
5908                }
5909            }
5910
5911            for (int i = 0; i < replaceColumns.size(); i++) {
5912                gudusoft.gsqlparser.nodes.TReplaceExprAsIdentifier replaceCol = replaceColumns.get(i);
5913                if (replaceCol != null && replaceCol.getIdentifier() != null) {
5914                    TObjectName replaceId = replaceCol.getIdentifier();
5915                    if (starSourceTable != null) {
5916                        // Qualified star: Link REPLACE identifier to the star's source table
5917                        replaceId.setSourceTable(starSourceTable);
5918                        allColumnReferences.add(replaceId);
5919                        if (DEBUG_SCOPE_BUILD) {
5920                            System.out.println("[DEBUG] REPLACE column linked to star source table: " +
5921                                replaceId.toString() + " -> " + starSourceTable.getFullName());
5922                        }
5923                    } else {
5924                        // Unqualified star: Add with scope mapping for normal resolution
5925                        if (currentSelectScope != null) {
5926                            columnToScopeMap.put(replaceId, currentSelectScope);
5927                        }
5928                        allColumnReferences.add(replaceId);
5929                        if (DEBUG_SCOPE_BUILD) {
5930                            System.out.println("[DEBUG] REPLACE column added for resolution (unqualified star): " +
5931                                replaceId.toString());
5932                        }
5933                    }
5934                }
5935            }
5936        }
5937
5938        // Handle CTAS target columns (CREATE TABLE AS SELECT)
5939        // In CTAS context, result columns become target table column definitions.
5940        // These are registered as "definition columns" (not reference columns):
5941        // - Added to allColumnReferences (for output)
5942        // - Added to ctasTargetColumns/tupleAliasColumns (definition set - prevents re-resolution)
5943        // - NOT added to columnToScopeMap (prevents NameResolver from overwriting sourceTable)
5944        //
5945        // IMPORTANT: Only handle CTAS target columns for the main SELECT of CTAS,
5946        // not for result columns inside CTEs or subqueries within the CTAS.
5947        // When inside a CTE definition (cteDefinitionDepth > 0), skip CTAS handling because
5948        // those result columns define CTE output, not CTAS target table columns.
5949        // Also check currentSelectScope == ctasMainSelectScope to exclude subqueries.
5950        if (currentCTASTargetTable != null && cteDefinitionDepth == 0 && currentSelectScope == ctasMainSelectScope) {
5951            boolean ctasColumnHandled = false;
5952
5953            if (resultColumn.getAliasClause() != null) {
5954                TObjectNameList tupleColumns = resultColumn.getAliasClause().getColumns();
5955
5956                // Case 1: Tuple aliases (e.g., AS (b1, b2, b3) in SparkSQL/Hive)
5957                if (tupleColumns != null && tupleColumns.size() > 0) {
5958                    for (int i = 0; i < tupleColumns.size(); i++) {
5959                        TObjectName tupleCol = tupleColumns.getObjectName(i);
5960                        if (tupleCol != null) {
5961                            // Mark as tuple alias column to skip in preVisit(TObjectName)
5962                            tupleAliasColumns.add(tupleCol);
5963                            // Set the source table to the CTAS target table
5964                            tupleCol.setSourceTable(currentCTASTargetTable);
5965                            // Add to all column references so it appears in output
5966                            allColumnReferences.add(tupleCol);
5967                            if (DEBUG_SCOPE_BUILD) {
5968                                System.out.println("[DEBUG] Registered CTAS tuple alias column: " +
5969                                        tupleCol.toString() + " -> " + currentCTASTargetTable.getFullName());
5970                            }
5971                        }
5972                    }
5973                    ctasColumnHandled = true;
5974                }
5975                // Case 2: Standard alias (AS alias / Teradata NAMED alias)
5976                else {
5977                    TObjectName aliasName = resultColumn.getAliasClause().getAliasName();
5978                    if (aliasName != null) {
5979                        // For alias names, partToken may be null but startToken has the actual text
5980                        // We need to set partToken so getColumnNameOnly() works correctly in the formatter
5981                        // This is done on a clone to avoid modifying the original AST node
5982                        if (aliasName.getPartToken() == null && aliasName.getStartToken() != null) {
5983                            // Clone the aliasName to avoid modifying the original AST
5984                            TObjectName ctasCol = aliasName.clone();
5985                            ctasCol.setPartToken(ctasCol.getStartToken());
5986                            ctasCol.setSourceTable(currentCTASTargetTable);
5987                            ctasTargetColumns.add(ctasCol);
5988                            allColumnReferences.add(ctasCol);
5989                            if (DEBUG_SCOPE_BUILD) {
5990                                System.out.println("[DEBUG] Registered CTAS target column (standard alias clone): " +
5991                                        ctasCol.getColumnNameOnly() + " -> " + currentCTASTargetTable.getFullName());
5992                            }
5993                            ctasColumnHandled = true;
5994                        } else {
5995                            String colName = aliasName.getColumnNameOnly();
5996                            if (colName != null && !colName.isEmpty()) {
5997                                // Register aliasName as CTAS target column DEFINITION
5998                                aliasName.setSourceTable(currentCTASTargetTable);
5999                                ctasTargetColumns.add(aliasName);
6000                                allColumnReferences.add(aliasName);
6001                                if (DEBUG_SCOPE_BUILD) {
6002                                    System.out.println("[DEBUG] Registered CTAS target column (standard alias): " +
6003                                            colName + " -> " + currentCTASTargetTable.getFullName());
6004                                }
6005                                ctasColumnHandled = true;
6006                            }
6007                        }
6008                    }
6009                }
6010            }
6011
6012            // Case 3: No alias, simple column reference or star (e.g., SELECT a FROM s, SELECT * FROM s)
6013            // The target column inherits the source column name.
6014            // Use clone pattern (like JOIN...USING) to avoid polluting source column's sourceTable.
6015            if (!ctasColumnHandled && resultColumn.getExpr() != null &&
6016                resultColumn.getExpr().getExpressionType() == EExpressionType.simple_object_name_t &&
6017                resultColumn.getExpr().getObjectOperand() != null) {
6018                TObjectName sourceCol = resultColumn.getExpr().getObjectOperand();
6019                // Get column name - use getColumnNameOnly() first, fall back to toString()
6020                String colName = sourceCol.getColumnNameOnly();
6021                if (colName == null || colName.isEmpty()) {
6022                    colName = sourceCol.toString();
6023                }
6024                if (colName != null && !colName.isEmpty()) {
6025                    // Clone the source column to create a synthetic CTAS target column
6026                    // The clone refers to original start/end tokens for proper name extraction
6027                    // This includes star columns (SELECT * FROM s) which should create t.*
6028                    TObjectName ctasCol = sourceCol.clone();
6029                    ctasCol.setSourceTable(currentCTASTargetTable);
6030                    ctasTargetColumns.add(ctasCol);
6031                    allColumnReferences.add(ctasCol);
6032                    if (DEBUG_SCOPE_BUILD) {
6033                        System.out.println("[DEBUG] Registered CTAS target column (simple ref clone): " +
6034                                colName + " -> " + currentCTASTargetTable.getFullName());
6035                    }
6036                }
6037            }
6038        } else {
6039            // NOT in CTAS context - track result column alias names to skip them
6040            // These are NOT column references - they're alias names given to expressions.
6041            // This includes standard "AS alias" and Teradata "NAMED alias" syntax.
6042            if (resultColumn.getAliasClause() != null) {
6043                TObjectName aliasName = resultColumn.getAliasClause().getAliasName();
6044                if (aliasName != null) {
6045                    resultColumnAliasNames.add(aliasName);
6046                    if (DEBUG_SCOPE_BUILD) {
6047                        System.out.println("[DEBUG] Tracked result column alias name (will skip): " + aliasName.toString());
6048                    }
6049                }
6050            }
6051        }
6052
6053        // Handle UPDATE SET clause columns
6054        // When inside an UPDATE statement, the left-hand side of SET assignments
6055        // (e.g., SET col = expr) should be linked to the target table
6056        if (currentUpdateTargetTable != null && resultColumn.getExpr() != null) {
6057            TExpression expr = resultColumn.getExpr();
6058            // SET clause uses assignment_t for "column = value" assignments in Teradata
6059            // or simple_comparison_t in some other databases
6060            if ((expr.getExpressionType() == EExpressionType.assignment_t ||
6061                 expr.getExpressionType() == EExpressionType.simple_comparison_t) &&
6062                expr.getLeftOperand() != null &&
6063                expr.getLeftOperand().getExpressionType() == EExpressionType.simple_object_name_t &&
6064                expr.getLeftOperand().getObjectOperand() != null) {
6065                TObjectName leftColumn = expr.getLeftOperand().getObjectOperand();
6066                // Link the SET clause column to the UPDATE target table
6067                leftColumn.setSourceTable(currentUpdateTargetTable);
6068                // Certify ONLY an unqualified SET target. A qualified LHS may name
6069                // a different table than the UPDATE target (valid multi-table
6070                // UPDATE: "UPDATE t1 JOIN t2 ON ... SET t2.a = 1"), and the
6071                // sourceTable assignment above would then be pointing at t1 —
6072                // whatever that legacy behavior is worth, it must never become a
6073                // COMPLETE proof trace.
6074                if (leftColumn.getTableToken() == null && currentUpdateHasSingleTable) {
6075                    traceDirectBinding(leftColumn,
6076                            gudusoft.gsqlparser.resolver2.binding.BindingTrace.BinderKind.DML_TARGET,
6077                            currentUpdateTargetTable);
6078                }
6079                allColumnReferences.add(leftColumn);
6080                // Mark as SET clause target - should NOT be re-resolved through star column
6081                setClauseTargetColumns.add(leftColumn);
6082                // Also add to columnToScopeMap with the current UpdateScope
6083                if (currentUpdateScope != null) {
6084                    columnToScopeMap.put(leftColumn, currentUpdateScope);
6085                }
6086                if (DEBUG_SCOPE_BUILD) {
6087                    System.out.println("[DEBUG] Linked UPDATE SET column: " +
6088                            leftColumn.toString() + " -> " + currentUpdateTargetTable.getFullName());
6089                }
6090            }
6091        }
6092
6093        // Explicitly traverse typecast expressions in result columns
6094        // The visitor pattern may not automatically traverse nested expressions in typecast
6095        // This is important for Snowflake stage file columns like "$1:apMac::string"
6096        if (resultColumn.getExpr() != null &&
6097            resultColumn.getExpr().getExpressionType() == EExpressionType.typecast_t) {
6098            TExpression typecastExpr = resultColumn.getExpr();
6099            if (typecastExpr.getLeftOperand() != null) {
6100                traverseExpressionForColumns(typecastExpr.getLeftOperand());
6101            }
6102        }
6103    }
6104
6105    @Override
6106    public void postVisit(TResultColumn resultColumn) {
6107        // Clear the current result column alias when we leave the result column
6108        currentResultColumnAlias = null;
6109        // Mark that we're leaving the result column context
6110        inResultColumnContext = false;
6111    }
6112
6113    // ========== Column References ==========
6114
6115    @Override
6116    public void preVisit(TObjectName objectName) {
6117        // Skip if this is a table name reference
6118        if (tableNameReferences.contains(objectName)) {
6119            return;
6120        }
6121
6122        // Skip if this is a tuple alias column (already handled in preVisit(TResultColumn))
6123        if (tupleAliasColumns.contains(objectName)) {
6124            return;
6125        }
6126
6127        // Skip if this is a CTAS target column (already handled in preVisit(TResultColumn))
6128        // These are definition columns for the CTAS target table, not column references
6129        if (ctasTargetColumns.contains(objectName)) {
6130            return;
6131        }
6132
6133        // Skip if this is a VALUES table alias column definition (already handled in processValuesTable)
6134        // These are column NAME definitions like 'id', 'name' in "VALUES (...) AS t(id, name)"
6135        if (valuesTableAliasColumns.contains(objectName)) {
6136            return;
6137        }
6138
6139        // Skip if this is a PIVOT IN clause column (already handled in addPivotInClauseColumns)
6140        // These are pivot column DEFINITIONS, not references to source table columns.
6141        if (pivotInClauseColumns.contains(objectName)) {
6142            return;
6143        }
6144
6145        // Skip if this is a result column alias name (tracked in preVisit(TResultColumn))
6146        // These are NOT column references - they're alias names for expressions
6147        // e.g., "COUNT(1) AS cnt" or Teradata "COUNT(1)(NAMED cnt)"
6148        if (resultColumnAliasNames.contains(objectName)) {
6149            return;
6150        }
6151
6152        // Skip if this is a lambda parameter
6153        // Lambda parameters are local function parameters, not table column references
6154        // e.g., in "transform(array, x -> x + 1)", x is a lambda parameter
6155        if (lambdaParameters.contains(objectName) || isLambdaParameter(objectName)) {
6156            return;
6157        }
6158
6159        // Skip if this is a DDL target object name (file format, stage, pipe, etc.)
6160        // These are object names in DDL statements, not column references
6161        if (ddlTargetNames.contains(objectName)) {
6162            if (DEBUG_SCOPE_BUILD) {
6163                System.out.println("[DEBUG] Skipping DDL target name: " + objectName);
6164            }
6165            return;
6166        }
6167        if (DEBUG_SCOPE_BUILD && !ddlTargetNames.isEmpty()) {
6168            System.out.println("[DEBUG] DDL target check for " + objectName +
6169                " (hashCode=" + System.identityHashCode(objectName) +
6170                "): ddlTargetNames has " + ddlTargetNames.size() + " entries");
6171            for (TObjectName t : ddlTargetNames) {
6172                System.out.println("[DEBUG]   DDL target: " + t + " (hashCode=" + System.identityHashCode(t) + ")");
6173            }
6174        }
6175
6176        // Skip if this is clearly not a column reference
6177        if (!isColumnReference(objectName)) {
6178            return;
6179        }
6180
6181        // MERGE INSERT VALUES names that the USING source provably does not project stay in the
6182        // resolver's own tracking — consumers still need to see them, as unresolved — but get no
6183        // scope, which is what keeps every later resolution pass from binding them. Same shape as
6184        // the CTAS target columns below.
6185        if (mergeInsertValuesUnboundColumns.contains(objectName)) {
6186            allColumnReferences.add(objectName);
6187            return;
6188        }
6189
6190        // Special handling: Link Snowflake stage positional columns to the stage table
6191        // These columns ($1, $2, $1:path) need to be linked to the stage table in the FROM clause
6192        if (dbVendor == EDbVendor.dbvsnowflake &&
6193            objectName.getSourceTable() == null &&
6194            isSnowflakeStagePositionalColumn(objectName)) {
6195            TTable stageTable = findSnowflakeStageTableInScope();
6196            if (DEBUG_SCOPE_BUILD) {
6197                System.out.println("[DEBUG] findSnowflakeStageTableInScope returned: " +
6198                    (stageTable != null ? stageTable.getTableName() : "null") +
6199                    " for column " + objectName);
6200            }
6201            if (stageTable != null) {
6202                objectName.setSourceTable(stageTable);
6203                if (DEBUG_SCOPE_BUILD) {
6204                    System.out.println("[DEBUG] Linked Snowflake stage column " + objectName +
6205                        " to stage table " + stageTable.getTableName());
6206                }
6207            }
6208        }
6209
6210        // Determine the scope for this column
6211        IScope scope = determineColumnScope(objectName);
6212
6213        if (DEBUG_SCOPE_BUILD) {
6214            System.out.println("[DEBUG] preVisit(TObjectName): " + objectName.toString() +
6215                " scope=" + (scope != null ? scope.getScopeType() : "null") +
6216                " currentSelectScope=" + (currentSelectScope != null ? "exists" : "null") +
6217                " currentUpdateScope=" + (currentUpdateScope != null ? "exists" : "null"));
6218        }
6219
6220        // Record the mapping
6221        if (scope != null) {
6222            columnToScopeMap.put(objectName, scope);
6223            allColumnReferences.add(objectName);
6224        }
6225    }
6226
6227    /**
6228     * Check if a TObjectName is a column reference (not a table/schema/etc.)
6229     */
6230    private boolean isColumnReference(TObjectName objectName) {
6231        if (objectName == null) {
6232            return false;
6233        }
6234
6235        // reuse result from TCustomsqlstatement.isValidColumnName(EDbVendor pDBVendor)
6236        if (objectName.getObjectType() == TObjectName.ttobjNotAObject) return false;
6237        if (objectName.getDbObjectType() == EDbObjectType.hint) return false;
6238
6239        // Numeric literals (e.g., "5" in LIMIT 5, "10" in TOP 10) are not column references.
6240        // Some grammars wrap Number tokens in createObjectName(), making them look like TObjectName
6241        // with dbObjectType=column. Check the token text to filter these out.
6242        if (objectName.getPartToken() != null) {
6243            TSourceToken pt = objectName.getPartToken();
6244            if (pt.tokentype == ETokenType.ttnumber) return false;
6245            // Some lexers (e.g., Hive) assign ttkeyword to Number tokens
6246            String tokenText = pt.toString();
6247            if (tokenText.length() > 0 && isNumericLiteral(tokenText)) return false;
6248        }
6249
6250        // Skip if this is marked as a variable (e.g., DECLARE statement element name)
6251        if (objectName.getObjectType() == TObjectName.ttobjVariable) {
6252            return false;
6253        }
6254
6255        // Skip if it's already marked as a table reference
6256        if (tableNameReferences.contains(objectName)) {
6257            return false;
6258        }
6259
6260        // Skip if this is a variable declaration name (from DECLARE statement)
6261        if (variableDeclarationNames.contains(objectName)) {
6262            return false;
6263        }
6264
6265        // Skip if this is an UNPIVOT definition column (value or FOR column)
6266        // These are column DEFINITIONS that create new output columns, not references
6267        if (unpivotDefinitionColumns.contains(objectName)) {
6268            return false;
6269        }
6270
6271        // Skip if we're inside EXECUTE IMMEDIATE dynamic string expression
6272        // The variable name used for dynamic SQL is not a column reference
6273        if (insideExecuteImmediateDynamicExpr) {
6274            if (DEBUG_SCOPE_BUILD) {
6275                System.out.println("[DEBUG] Skipping identifier inside EXECUTE IMMEDIATE: " + objectName.toString());
6276            }
6277            return false;
6278        }
6279
6280        // Skip if column name is empty or null - this indicates a table alias or similar
6281        String columnName = objectName.getColumnNameOnly();
6282        if (columnName == null || columnName.isEmpty()) {
6283            return false;
6284        }
6285
6286        // Check object type
6287        // Column references typically have objectType that indicates column usage
6288        // or appear in contexts where columns are expected
6289
6290        // Skip if this is part of a CREATE TABLE column definition
6291        // Note: getParentObjectName() returns TObjectName (for qualified names), not the AST parent
6292        TParseTreeNode parent = objectName.getParentObjectName();
6293        if (parent instanceof TColumnDefinition) {
6294            return false;
6295        }
6296
6297        // Skip if this looks like a table name in FROM clause
6298        if (parent instanceof TTable) {
6299            return false;
6300        }
6301
6302        // Skip if this is a table alias (parent is TAliasClause)
6303        if (parent instanceof gudusoft.gsqlparser.nodes.TAliasClause) {
6304            return false;
6305        }
6306
6307        // Skip if this is a cursor name in cursor-related statements (DECLARE CURSOR, OPEN, FETCH, CLOSE, DEALLOCATE)
6308        if (isCursorName(objectName)) {
6309            return false;
6310        }
6311
6312        // Skip if this is a datepart keyword in a date function (e.g., DAY in DATEDIFF)
6313        // These have null parent but are known date/time keywords used in function arguments
6314        // We need to verify it's actually in a date function context by checking surrounding tokens
6315        if (parent == null && objectName.getTableToken() == null &&
6316            isSqlServerDatepartKeyword(columnName) &&
6317            isInDateFunctionContext(objectName)) {
6318            return false;
6319        }
6320
6321        // Skip if this was pre-marked as a function keyword argument in preVisit(TFunctionCall)
6322        // This handles keywords like SECOND in TIMESTAMP_DIFF(ts1, ts2, SECOND) for BigQuery/Snowflake
6323        if (functionKeywordArguments.contains(objectName)) {
6324            if (DEBUG_SCOPE_BUILD) {
6325                System.out.println("[DEBUG] Skipping pre-marked function keyword: " + objectName.toString());
6326            }
6327            return false;
6328        }
6329
6330        // Skip if this is a named argument parameter name (e.g., INPUT in "INPUT => value")
6331        // These are parameter names in named argument syntax, NOT column references.
6332        // Examples: Snowflake FLATTEN(INPUT => parse_json(col), outer => TRUE)
6333        // Check both the Set (for current ScopeBuilder run) and the objectType (for AST-level marking)
6334        if (namedArgumentParameters.contains(objectName) ||
6335            objectName.getObjectType() == TObjectName.ttobjNamedArgParameter) {
6336            if (DEBUG_SCOPE_BUILD) {
6337                System.out.println("[DEBUG] Skipping named argument parameter: " + objectName.toString());
6338            }
6339            return false;
6340        }
6341
6342        // Skip if this is a keyword argument in a built-in function (e.g., DAY in DATEDIFF)
6343        // This is a fallback using parent traversal (may not work if parent is null)
6344        if (isFunctionKeywordArgument(objectName)) {
6345            return false;
6346        }
6347
6348        // Skip if this is a variable or function parameter (e.g., p_date in CREATE FUNCTION)
6349        // EXCEPTION: In PL/SQL blocks, we need to collect variable references so TSQLResolver2
6350        // can distinguish between table columns and block variables during name resolution
6351        if (objectName.getLinkedVariable() != null) {
6352            if (currentPlsqlBlockScope == null) {  // Not in PL/SQL block
6353                return false;
6354            }
6355        }
6356        EDbObjectType dbObjType = objectName.getDbObjectType();
6357        if (dbObjType == EDbObjectType.variable || dbObjType == EDbObjectType.parameter) {
6358            // Special case: Snowflake stage file positional columns ($1, $2, $1:path, etc.)
6359            // These are parsed as "parameter" but are actually column references to stage files
6360            boolean isStagePositional = (dbVendor == EDbVendor.dbvsnowflake && isSnowflakeStagePositionalColumn(objectName));
6361            if (DEBUG_SCOPE_BUILD) {
6362                System.out.println("[DEBUG] dbObjType check: dbObjType=" + dbObjType +
6363                    " colNameOnly=" + objectName.getColumnNameOnly() +
6364                    " objStr=" + objectName.getObjectString() +
6365                    " isSnowflakeStagePositional=" + isStagePositional);
6366            }
6367            if (isStagePositional) {
6368                // Allow collection - will be linked to stage table during name resolution
6369                // Fall through to return true
6370            } else if (currentPlsqlBlockScope == null) {  // Not in PL/SQL block
6371                return false;
6372            } else {
6373                return false;
6374                // In PL/SQL block - allow collection so name resolution can distinguish
6375                // between table columns and block variables
6376            }
6377        }
6378
6379        // Skip if this name matches a registered parameter in the current PL/SQL block scope
6380        // BUT ONLY if we're NOT in a DML statement context (SELECT/INSERT/UPDATE/DELETE/MERGE)
6381        // This handles cases where the parser doesn't link the reference to the parameter declaration
6382        // (e.g., EXECUTE IMMEDIATE ddl_in where ddl_in is a procedure parameter)
6383        //
6384        // When inside a DML statement, we MUST allow collection so name resolution can distinguish
6385        // between table columns and block variables (e.g., "DELETE FROM emp WHERE ename = main.ename")
6386        if (currentPlsqlBlockScope != null) {
6387            // Check if we're inside any DML statement context
6388            // Note: currentInsertTargetTable is set when inside an INSERT statement
6389            boolean inDmlContext = (currentSelectScope != null || currentUpdateScope != null ||
6390                                    currentDeleteScope != null || currentMergeScope != null ||
6391                                    currentInsertTargetTable != null);
6392
6393            if (!inDmlContext) {
6394                // Not in DML context - this is likely a standalone expression like EXECUTE IMMEDIATE param
6395                // or a WHILE condition, etc.
6396                // Check if the name is a variable in the current scope OR any parent PL/SQL block scope.
6397                String nameOnly = objectName.getColumnNameOnly();
6398                if (nameOnly != null && isVariableInPlsqlScopeChain(nameOnly)) {
6399                    if (DEBUG_SCOPE_BUILD) {
6400                        System.out.println("[DEBUG] Skipping registered PL/SQL parameter/variable (not in DML): " + nameOnly);
6401                    }
6402                    return false;
6403                }
6404
6405                // Also skip qualified references (like TEMP1.M1) when not in DML context
6406                // BUT only if the prefix is NOT a trigger correlation variable (:new, :old, new, old)
6407                // These are likely PL/SQL object/record field accesses, not table columns
6408                if (objectName.getTableToken() != null) {
6409                    String prefix = objectName.getTableToken().toString().toLowerCase();
6410                    // Skip filtering for trigger correlation variables
6411                    if (!":new".equals(prefix) && !":old".equals(prefix) &&
6412                        !"new".equals(prefix) && !"old".equals(prefix)) {
6413                        if (DEBUG_SCOPE_BUILD) {
6414                            System.out.println("[DEBUG] Skipping qualified reference in non-DML PL/SQL context: " + objectName.toString());
6415                        }
6416                        return false;
6417                    }
6418                }
6419            }
6420        }
6421
6422        // Skip if this is a bind variable (e.g., :project_id in Oracle, @param in SQL Server)
6423        // BUT skip this check for Snowflake stage positional columns with JSON paths like $1:apMac
6424        // which are tokenized as bind variables due to the colon syntax
6425        if (objectName.getPartToken() != null && objectName.getPartToken().tokentype == ETokenType.ttbindvar) {
6426            // Check if this is a Snowflake stage JSON path column - these are NOT bind variables
6427            if (!(dbVendor == EDbVendor.dbvsnowflake && isSnowflakeStagePositionalColumn(objectName))) {
6428                if (DEBUG_SCOPE_BUILD) {
6429                    System.out.println("[DEBUG] partToken bindvar check: " + objectName.toString() +
6430                        " tokentype=" + objectName.getPartToken().tokentype);
6431                }
6432                return false;
6433            }
6434        }
6435        // Also check by column name pattern for bind variables
6436        // BUT skip this check for Snowflake stage positional columns with JSON paths like $1:apMac
6437        // where getColumnNameOnly() returns ":apMac" - this is NOT a bind variable
6438        String colName = objectName.getColumnNameOnly();
6439        if (colName != null && (colName.startsWith(":") || colName.startsWith("@"))) {
6440            // Check if this is a Snowflake stage JSON path column (e.g., $1:apMac)
6441            // In this case, colName is ":apMac" but it's a JSON path, not a bind variable
6442            boolean isStageCol = (dbVendor == EDbVendor.dbvsnowflake && isSnowflakeStagePositionalColumn(objectName));
6443            if (DEBUG_SCOPE_BUILD) {
6444                System.out.println("[DEBUG] bind variable check: colName=" + colName +
6445                    " objStr=" + objectName.getObjectString() +
6446                    " isSnowflakeStageCol=" + isStageCol);
6447            }
6448            if (!isStageCol) {
6449                return false;
6450            }
6451        }
6452
6453        // Skip built-in functions without parentheses (niladic functions)
6454        // These look like column references but are actually function calls
6455        // Examples: CURRENT_USER (SQL Server), CURRENT_DATETIME (BigQuery), etc.
6456        // Only check unqualified names - qualified names like "table.column" are real column references
6457        if (objectName.getTableToken() == null && isBuiltInFunctionName(colName)) {
6458            return false;
6459        }
6460
6461        // Skip Snowflake procedure system variables (e.g., sqlrowcount, sqlerrm, sqlstate)
6462        // These are special variables available in Snowflake stored procedures
6463        if (dbVendor == EDbVendor.dbvsnowflake && objectName.getTableToken() == null &&
6464            isSnowflakeProcedureSystemVariable(colName)) {
6465            if (DEBUG_SCOPE_BUILD) {
6466                System.out.println("[DEBUG] Skipping Snowflake procedure system variable: " + colName);
6467            }
6468            return false;
6469        }
6470
6471        // Skip if this is a double-quoted string literal in MySQL
6472        // In MySQL, "Z" is a string literal by default (unless ANSI_QUOTES mode)
6473        if (dbVendor == EDbVendor.dbvmysql && objectName.getPartToken() != null) {
6474            if (objectName.getPartToken().tokentype == ETokenType.ttdqstring) {
6475                return false;
6476            }
6477        }
6478
6479        // Skip if this is the alias part of SQL Server's proprietary column alias syntax
6480        // e.g., in "column_alias = expression", column_alias is an alias, not a column reference
6481        if (isSqlServerProprietaryColumnAlias(objectName, parent)) {
6482            return false;
6483        }
6484
6485        // Skip if this is a lateral column alias reference (Snowflake, BigQuery, etc.)
6486        // e.g., in "SELECT col as x, x + 1 as y", x is a lateral alias reference, not a source column
6487        // Only check unqualified names (no table prefix) - qualified names like "t.x" are not lateral aliases
6488        if (objectName.getTableToken() == null && isLateralColumnAlias(columnName)) {
6489            return false;
6490        }
6491
6492        // Handle PL/SQL package constants (e.g., sch.pk_constv2.c_cdsl in VALUES clause)
6493        // These are not resolvable as table columns, but we still collect them so they can
6494        // be output as "missed." columns when linkOrphanColumnToFirstTable=false, or linked
6495        // to the first physical table when linkOrphanColumnToFirstTable=true.
6496        // Note: The qualified prefix (schema.table) is preserved in the TObjectName tokens
6497        // (schemaToken, tableToken) for DataFlowAnalyzer to use when creating relationships.
6498        if (isPlsqlPackageConstant(objectName, parent)) {
6499            // Clear sourceTable since this is not a real table column
6500            // Don't set dbObjectType to variable so that populateOrphanColumns() in TSQLResolver2
6501            // will process it and set ownStmt, enabling linkOrphanColumnToFirstTable to work
6502            objectName.setSourceTable(null);
6503            if (DEBUG_SCOPE_BUILD) {
6504                System.out.println("[DEBUG] Collected PL/SQL package constant as orphan: " + objectName.toString());
6505            }
6506            // Return true to collect as orphan column
6507            return true;
6508        }
6509
6510        // Oracle PL/SQL special identifiers (pseudo-columns, implicit vars, exception handlers, cursor attrs)
6511        // Best practice: filter in ScopeBuilder (early), with strict gating:
6512        // - Oracle vendor only
6513        // - Quoted identifiers override keywords (e.g., "ROWNUM" is a user identifier)
6514        if (dbVendor == EDbVendor.dbvoracle && objectName.getQuoteType() == EQuoteType.notQuoted) {
6515            String nameOnly = objectName.getColumnNameOnly();
6516
6517            // 1) Pseudo-columns whose names are Oracle reserved words
6518            // (ROWNUM, LEVEL, ROWID): an unquoted occurrence can never be a
6519            // user column, so no statement context is needed to rule it out.
6520            // The name list is shared with the Phase 1 publish path in
6521            // TCustomSqlStatement so the two cannot drift apart (Mantis #4675).
6522            // Context-gated pseudo-columns (CONNECT_BY_*, NEXTVAL/CURRVAL,
6523            // VERSIONS_*) are classified in Phase 1, which has the statement
6524            // context this method lacks; the mark it leaves behind is honoured
6525            // by the isColumnReference() guard below.
6526            if (OraclePseudoColumnUtil.isDetachedReservedPseudoColumn(nameOnly)) {
6527                markNotAColumn(objectName, EDbObjectType.constant);
6528                return false;
6529            }
6530
6531            // 2) PL/SQL inquiry directives: $$PLSQL_UNIT, $$PLSQL_LINE, $$PLSQL_UNIT_OWNER, etc.
6532            // These are compile-time constants, not column references
6533            if (nameOnly != null && nameOnly.startsWith("$$")) {
6534                markNotAColumn(objectName, EDbObjectType.constant);
6535                return false;
6536            }
6537
6538            // 3) Inside PL/SQL blocks: filter implicit identifiers & cursor attributes
6539            if (currentPlsqlBlockScope != null) {
6540                if (nameOnly != null) {
6541                    String lower = nameOnly.toLowerCase(Locale.ROOT);
6542                    // SQLCODE (implicit variable) / SQLERRM (built-in; often written like a variable)
6543                    if ("sqlcode".equals(lower) || "sqlerrm".equals(lower)) {
6544                        markNotAColumn(objectName, EDbObjectType.variable);
6545                        return false;
6546                    }
6547                }
6548
6549                // Cursor attributes: SQL%NOTFOUND, cursor%ROWCOUNT, etc.
6550                if (isOraclePlsqlCursorAttribute(objectName)) {
6551                    markNotAColumn(objectName, EDbObjectType.variable);
6552                    return false;
6553                }
6554
6555                // Boolean literals: TRUE, FALSE (case-insensitive)
6556                // These are PL/SQL boolean constants, not column references
6557                if (nameOnly != null) {
6558                    String lower = nameOnly.toLowerCase(Locale.ROOT);
6559                    if ("true".equals(lower) || "false".equals(lower)) {
6560                        markNotAColumn(objectName, EDbObjectType.constant);
6561                        return false;
6562                    }
6563                }
6564
6565                // Oracle predefined exceptions (unqualified names in RAISE/WHEN clauses)
6566                // Examples: NO_DATA_FOUND, TOO_MANY_ROWS, CONFIGURATION_MISMATCH, etc.
6567                if (nameOnly != null && isOraclePredefinedException(nameOnly)) {
6568                    markNotAColumn(objectName, EDbObjectType.variable);
6569                    return false;
6570                }
6571
6572                // Collection methods: .COUNT, .LAST, .FIRST, .DELETE, .EXISTS, .PRIOR, .NEXT, .TRIM, .EXTEND
6573                // These appear as qualified names like "collection.COUNT" where COUNT is the method
6574                if (isOraclePlsqlCollectionMethod(objectName)) {
6575                    markNotAColumn(objectName, EDbObjectType.variable);
6576                    return false;
6577                }
6578
6579                // Cursor variable references: check if this is a known cursor variable
6580                // Example: emp in "OPEN emp FOR SELECT * FROM employees"
6581                // This is based on actual TCursorDeclStmt/TOpenforStmt declarations tracked in scope
6582                if (nameOnly != null && cursorVariableNames.contains(nameOnly.toLowerCase(Locale.ROOT))) {
6583                    markNotAColumn(objectName, EDbObjectType.variable);
6584                    if (DEBUG_SCOPE_BUILD) {
6585                        System.out.println("[DEBUG] Skipping cursor variable: " + nameOnly);
6586                    }
6587                    return false;
6588                }
6589
6590                // Record variable fields: when "table.column" refers to a record variable field
6591                // Example: rec.field_name where rec is declared as "rec record_type%ROWTYPE"
6592                // Check if the "table" part is a known variable in the current scope
6593                if (objectName.getTableToken() != null) {
6594                    String tablePrefix = objectName.getTableToken().toString();
6595                    if (tablePrefix != null && isVariableInPlsqlScopeChain(tablePrefix)) {
6596                        // The "table" part is actually a record variable, so this is a record field access
6597                        markNotAColumn(objectName, EDbObjectType.variable);
6598                        return false;
6599                    }
6600                    // Also check cursor FOR loop record names (e.g., "rec" in "for rec in (SELECT ...)")
6601                    if (tablePrefix != null && cursorForLoopRecordNames.contains(tablePrefix.toLowerCase(Locale.ROOT))) {
6602                        // The "table" part is a cursor FOR loop record, so this is a record field access
6603                        markNotAColumn(objectName, EDbObjectType.variable);
6604                        if (DEBUG_SCOPE_BUILD) {
6605                            System.out.println("[DEBUG] Skipping cursor FOR loop record field: " + objectName.toString());
6606                        }
6607                        return false;
6608                    }
6609
6610                    // Package member references (pkg.member or schema.pkg.member)
6611                    // Check if the table prefix matches a known package
6612                    if (tablePrefix != null && packageRegistry != null && packageRegistry.isPackage(tablePrefix)) {
6613                        OraclePackageNamespace pkgNs = packageRegistry.getPackage(tablePrefix);
6614                        String memberName = objectName.getColumnNameOnly();
6615                        if (pkgNs != null && memberName != null && pkgNs.hasMember(memberName)) {
6616                            markNotAColumn(objectName, EDbObjectType.variable);
6617                            if (DEBUG_SCOPE_BUILD) {
6618                                System.out.println("[DEBUG] Skipping package member reference: " +
6619                                    tablePrefix + "." + memberName);
6620                            }
6621                            return false;
6622                        }
6623                    }
6624                }
6625
6626                // Package-level variable (unqualified) when inside package body
6627                // Check if this is a known package member without qualification
6628                if (currentPackageScope != null && objectName.getTableToken() == null) {
6629                    OraclePackageNamespace pkgNs = currentPackageScope.getPackageNamespace();
6630                    if (pkgNs != null && nameOnly != null && pkgNs.hasMember(nameOnly)) {
6631                        markNotAColumn(objectName, EDbObjectType.variable);
6632                        if (DEBUG_SCOPE_BUILD) {
6633                            System.out.println("[DEBUG] Skipping unqualified package member: " + nameOnly);
6634                        }
6635                        return false;
6636                    }
6637                }
6638            }
6639        }
6640
6641        // Accept if it appears in an expression context
6642        if (parent instanceof TExpression) {
6643            return true;
6644        }
6645
6646        // Accept if it appears in a result column
6647        if (parent instanceof TResultColumn) {
6648            return true;
6649        }
6650
6651        // Default: accept as column reference
6652        return true;
6653    }
6654
6655    /**
6656     * Mark a TObjectName as "not a column" so downstream collectors/resolvers can skip it.
6657     */
6658    private void markNotAColumn(TObjectName objectName, EDbObjectType objType) {
6659        if (objectName == null) return;
6660        objectName.setValidate_column_status(TBaseType.MARKED_NOT_A_COLUMN_IN_COLUMN_RESOLVER);
6661        // IMPORTANT: setSourceTable(null) may set dbObjectType to column; override after
6662        objectName.setSourceTable(null);
6663        if (objType != null) {
6664            objectName.setDbObjectTypeDirectly(objType);
6665        }
6666    }
6667
6668    /**
6669     * Oracle PL/SQL cursor attributes are syntactically identified by '%' (e.g., SQL%NOTFOUND, cur%ROWCOUNT).
6670     * These are never table columns.
6671     */
6672    private boolean isOraclePlsqlCursorAttribute(TObjectName objectName) {
6673        if (dbVendor != EDbVendor.dbvoracle) return false;
6674        if (currentPlsqlBlockScope == null) return false;
6675        if (objectName == null) return false;
6676
6677        String text = objectName.toString();
6678        if (text == null) return false;
6679        int idx = text.lastIndexOf('%');
6680        if (idx < 0 || idx == text.length() - 1) return false;
6681
6682        String attr = text.substring(idx + 1).trim().toUpperCase(Locale.ROOT);
6683        // Common PL/SQL cursor attributes
6684        switch (attr) {
6685            case "FOUND":
6686            case "NOTFOUND":
6687            case "ROWCOUNT":
6688            case "ISOPEN":
6689            case "BULK_ROWCOUNT":
6690            case "BULK_EXCEPTIONS":
6691                return true;
6692            default:
6693                return false;
6694        }
6695    }
6696
6697    /**
6698     * Oracle predefined exceptions that should not be treated as column references.
6699     * These include standard PL/SQL exceptions and DBMS_* package exceptions.
6700     */
6701    private static final java.util.Set<String> ORACLE_PREDEFINED_EXCEPTIONS = new java.util.HashSet<>(java.util.Arrays.asList(
6702        // Standard PL/SQL exceptions
6703        "ACCESS_INTO_NULL", "CASE_NOT_FOUND", "COLLECTION_IS_NULL", "CURSOR_ALREADY_OPEN",
6704        "DUP_VAL_ON_INDEX", "INVALID_CURSOR", "INVALID_NUMBER", "LOGIN_DENIED",
6705        "NO_DATA_FOUND", "NO_DATA_NEEDED", "NOT_LOGGED_ON", "PROGRAM_ERROR",
6706        "ROWTYPE_MISMATCH", "SELF_IS_NULL", "STORAGE_ERROR", "SUBSCRIPT_BEYOND_COUNT",
6707        "SUBSCRIPT_OUTSIDE_LIMIT", "SYS_INVALID_ROWID", "TIMEOUT_ON_RESOURCE",
6708        "TOO_MANY_ROWS", "VALUE_ERROR", "ZERO_DIVIDE",
6709        // DBMS_STANDARD exceptions
6710        "CONFIGURATION_MISMATCH", "OTHERS"
6711    ));
6712
6713    private boolean isOraclePredefinedException(String name) {
6714        if (name == null) return false;
6715        return ORACLE_PREDEFINED_EXCEPTIONS.contains(name.toUpperCase(Locale.ROOT));
6716    }
6717
6718    /**
6719     * Oracle PL/SQL collection methods that should not be treated as column references.
6720     * Examples: my_collection.COUNT, my_array.LAST, my_table.DELETE
6721     */
6722    private boolean isOraclePlsqlCollectionMethod(TObjectName objectName) {
6723        if (dbVendor != EDbVendor.dbvoracle) return false;
6724        if (currentPlsqlBlockScope == null) return false;
6725        if (objectName == null) return false;
6726
6727        // Check if this is a qualified name (has a table/object prefix)
6728        // Collection methods appear as "collection_name.METHOD"
6729        if (objectName.getTableToken() == null) return false;
6730
6731        String methodName = objectName.getColumnNameOnly();
6732        if (methodName == null) return false;
6733
6734        String upper = methodName.toUpperCase(Locale.ROOT);
6735        switch (upper) {
6736            case "COUNT":
6737            case "FIRST":
6738            case "LAST":
6739            case "NEXT":
6740            case "PRIOR":
6741            case "EXISTS":
6742            case "DELETE":
6743            case "TRIM":
6744            case "EXTEND":
6745                return true;
6746            default:
6747                return false;
6748        }
6749    }
6750
6751    /**
6752     * Detect whether this TObjectName belongs to an Oracle exception handler condition.
6753     * Example: EXCEPTION WHEN no_data_found THEN ... / WHEN OTHERS THEN ...
6754     *
6755     * We use parent-chain context rather than a keyword list to avoid false positives.
6756     */
6757    private boolean isInsideOracleExceptionHandler(TObjectName objectName) {
6758        if (dbVendor != EDbVendor.dbvoracle) return false;
6759        if (currentPlsqlBlockScope == null) return false;
6760        if (objectName == null) return false;
6761
6762        TParseTreeNode node = objectName.getParentObjectName();
6763        while (node != null) {
6764            if (node instanceof TExceptionHandler) {
6765                return true;
6766            }
6767            node = node.getParentObjectName();
6768        }
6769        return false;
6770    }
6771
6772    /**
6773     * Check if a TObjectName is a keyword argument in a built-in function.
6774     * For example, DAY in DATEDIFF(DAY, start_date, end_date) is a keyword, not a column.
6775     * Uses TBuiltFunctionUtil to check against the configured keyword argument positions.
6776     */
6777    private boolean isFunctionKeywordArgument(TObjectName objectName) {
6778        // Traverse up to find the containing expression
6779        TParseTreeNode parent = objectName.getParentObjectName();
6780
6781        // Debug output for function keyword detection
6782        if (DEBUG_SCOPE_BUILD) {
6783            System.out.println("[DEBUG] isFunctionKeywordArgument: objectName=" + objectName.toString() +
6784                " parent=" + (parent != null ? parent.getClass().getSimpleName() : "null"));
6785        }
6786
6787        if (!(parent instanceof TExpression)) {
6788            return false;
6789        }
6790
6791        TExpression expr = (TExpression) parent;
6792
6793        // Check if the expression type indicates this could be a keyword argument
6794        // Keywords like DAY, MONTH, YEAR in DATEDIFF/DATEADD are parsed as simple_constant_t
6795        // but still have objectOperand set, so we need to check both types
6796        EExpressionType exprType = expr.getExpressionType();
6797        if (exprType != EExpressionType.simple_object_name_t &&
6798            exprType != EExpressionType.simple_constant_t) {
6799            return false;
6800        }
6801
6802        // Traverse up to find the containing function call
6803        TParseTreeNode exprParent = expr.getParentObjectName();
6804
6805        // The expression might be inside a TExpressionList (function args)
6806        if (exprParent instanceof TExpressionList) {
6807            TExpressionList argList = (TExpressionList) exprParent;
6808            TParseTreeNode argListParent = argList.getParentObjectName();
6809
6810            if (argListParent instanceof TFunctionCall) {
6811                TFunctionCall functionCall = (TFunctionCall) argListParent;
6812                return checkFunctionKeywordPosition(functionCall, argList, expr, objectName);
6813            }
6814        }
6815
6816        // Direct parent might be TFunctionCall in some cases
6817        if (exprParent instanceof TFunctionCall) {
6818            TFunctionCall functionCall = (TFunctionCall) exprParent;
6819            TExpressionList args = functionCall.getArgs();
6820            if (args != null) {
6821                return checkFunctionKeywordPosition(functionCall, args, expr, objectName);
6822            }
6823        }
6824
6825        return false;
6826    }
6827
6828    /**
6829     * Check if the expression is at a keyword argument position in the function call.
6830     */
6831    private boolean checkFunctionKeywordPosition(TFunctionCall functionCall,
6832                                                  TExpressionList argList,
6833                                                  TExpression expr,
6834                                                  TObjectName objectName) {
6835        // Find the position of this expression in the argument list
6836        int position = -1;
6837        for (int i = 0; i < argList.size(); i++) {
6838            if (argList.getExpression(i) == expr) {
6839                position = i + 1; // TBuiltFunctionUtil uses 1-based positions
6840                break;
6841            }
6842        }
6843
6844        if (position < 0) {
6845            return false;
6846        }
6847
6848        // Get function name
6849        String functionName = functionCall.getFunctionName() != null
6850                ? functionCall.getFunctionName().toString()
6851                : null;
6852        if (functionName == null || functionName.isEmpty()) {
6853            return false;
6854        }
6855
6856        // Check against the configured keyword argument positions
6857        // Use the ScopeBuilder's dbVendor since TFunctionCall.dbvendor may not be set
6858        Set<Integer> keywordPositions = TBuiltFunctionUtil.argumentsIncludeKeyword(
6859                dbVendor, functionName);
6860
6861        if (keywordPositions != null && keywordPositions.contains(position)) {
6862            if (DEBUG_SCOPE_BUILD) {
6863                System.out.println("[DEBUG] Skipping function keyword argument: " +
6864                        objectName.toString() + " at position " + position +
6865                        " in function " + functionName);
6866            }
6867            return true;
6868        }
6869
6870        return false;
6871    }
6872
6873    /**
6874     * Check if a TObjectName is a PL/SQL package constant (not a table column).
6875     *
6876     * This filter applies ONLY when all of the following are true:
6877     * - Vendor is Oracle
6878     * - We are inside a PL/SQL block (currentPlsqlBlockScope != null)
6879     * - The TObjectName is multi-part: schema.object.part (e.g., sch.pk_constv2.c_cdsl)
6880     * - The TObjectName is used in an expression/value context (not DDL/definition context)
6881     *
6882     * Decision rule (no naming heuristics):
6883     * - Try to resolve the prefix (schema.object) as a table/alias/CTE in the current scope
6884     * - If neither resolves -> it's a package constant, NOT a column reference
6885     * - If either resolves -> it's a real column reference
6886     *
6887     * @param objectName The TObjectName to check
6888     * @param parent The parent node (may be null for some TObjectName nodes)
6889     * @return true if this is a PL/SQL package constant (should NOT be collected as column)
6890     */
6891    private boolean isPlsqlPackageConstant(TObjectName objectName, TParseTreeNode parent) {
6892        // Gating condition 1: Oracle vendor only
6893        if (dbVendor != EDbVendor.dbvoracle) {
6894            return false;
6895        }
6896
6897        // Gating condition 2: Must be inside a PL/SQL block
6898        if (currentPlsqlBlockScope == null) {
6899            return false;
6900        }
6901
6902        // Gating condition 3: Must be multi-part name (schema.object.part)
6903        // e.g., sch.pk_constv2.c_cdsl where:
6904        //   - schemaToken = "sch"
6905        //   - tableToken = "pk_constv2"
6906        //   - partToken = "c_cdsl"
6907        if (objectName.getSchemaToken() == null ||
6908            objectName.getTableToken() == null ||
6909            objectName.getPartToken() == null) {
6910            return false;
6911        }
6912
6913        // Gating condition 4: Should not be in DDL definition context
6914        // Skip if parent indicates a DDL context (these are already filtered earlier,
6915        // but check here for safety). For VALUES clause expressions, parent is often null.
6916        if (parent instanceof TColumnDefinition || parent instanceof TTable) {
6917            return false;
6918        }
6919
6920        // Now check if the prefix is resolvable as a table/alias in the current scope
6921        IScope scope = determineColumnScope(objectName);
6922        if (scope == null) {
6923            // Conservative: if we can't determine scope, don't filter
6924            return false;
6925        }
6926
6927        String schemaName = objectName.getSchemaString();   // "sch"
6928        String qualifier = objectName.getTableString();      // "pk_constv2"
6929
6930        // Try to resolve as table alias or table name
6931        INamespace ns1 = scope.resolveTable(qualifier);
6932
6933        // Try to resolve as schema-qualified table name
6934        INamespace ns2 = null;
6935        if (schemaName != null && !schemaName.isEmpty()) {
6936            ns2 = scope.resolveTable(schemaName + "." + qualifier);
6937        }
6938
6939        if (ns1 == null && ns2 == null) {
6940            // Not resolvable as a table/alias in this scope
6941            // -> Treat as package constant, NOT a column reference
6942            if (DEBUG_SCOPE_BUILD) {
6943                System.out.println("[DEBUG] Filtered PL/SQL package constant: " +
6944                    objectName.toString() + " (prefix '" + schemaName + "." + qualifier +
6945                    "' not resolvable in scope)");
6946            }
6947            return true;
6948        }
6949
6950        // Resolvable as a table/alias -> treat as real column reference
6951        return false;
6952    }
6953
6954
6955    /**
6956     * SQL Server datepart keywords - used in DATEDIFF, DATEADD, DATEPART, DATENAME functions.
6957     * These are parsed as TObjectName but are actually date/time keywords, not columns.
6958     */
6959    private static final Set<String> SQL_SERVER_DATEPART_KEYWORDS = new HashSet<>(Arrays.asList(
6960        // Standard datepart values
6961        "year", "yy", "yyyy",
6962        "quarter", "qq", "q",
6963        "month", "mm", "m",
6964        "dayofyear", "dy", "y",
6965        "day", "dd", "d",
6966        "week", "wk", "ww",
6967        "weekday", "dw", "w",
6968        "hour", "hh",
6969        "minute", "mi", "n",
6970        "second", "ss", "s",
6971        "millisecond", "ms",
6972        "microsecond", "mcs",
6973        "nanosecond", "ns",
6974        // ISO week
6975        "iso_week", "isowk", "isoww",
6976        // Timezone offset
6977        "tzoffset", "tz"
6978    ));
6979
6980    /**
6981     * Check if a name is a niladic function (built-in function without parentheses)
6982     * for the current database vendor.
6983     *
6984     * Uses TNiladicFunctionUtil with builtinFunctions/niladicFunctions.properties file.
6985     *
6986     * Niladic functions are functions that can be called without parentheses and look like
6987     * column references but are actually function calls returning values.
6988     * Examples: CURRENT_USER (SQL Server), CURRENT_DATETIME (BigQuery), SYSDATE (Oracle)
6989     *
6990     * Note: Regular built-in functions that require parentheses (like DAY(date), COUNT(*))
6991     * are NOT filtered here because they would have parentheses in the SQL and thus be
6992     * parsed as TFunctionCall nodes, not TObjectName.
6993     *
6994     * @param name The identifier name to check
6995     * @return true if it's a known niladic function for the current vendor
6996     */
6997    private boolean isBuiltInFunctionName(String name) {
6998        if (name == null || name.isEmpty()) {
6999            return false;
7000        }
7001
7002        boolean isNiladic = TNiladicFunctionUtil.isNiladicFunction(dbVendor, name);
7003
7004        if (DEBUG_SCOPE_BUILD && isNiladic) {
7005            System.out.println("[DEBUG] Identified niladic function: " + name + " for vendor " + dbVendor);
7006        }
7007
7008        return isNiladic;
7009    }
7010
7011    /**
7012     * Check if the given name is a Snowflake procedure system variable.
7013     * These are special variables available in Snowflake stored procedures:
7014     * - SQLROWCOUNT: Number of rows affected by the last SQL statement
7015     * - SQLERRM: Error message of the last SQL statement
7016     * - SQLSTATE: SQL state code of the last SQL statement
7017     * - SQLCODE: Deprecated, replaced by SQLSTATE
7018     */
7019    private boolean isSnowflakeProcedureSystemVariable(String name) {
7020        if (name == null || name.isEmpty()) {
7021            return false;
7022        }
7023        String upperName = name.toUpperCase();
7024        return "SQLROWCOUNT".equals(upperName) ||
7025               "SQLERRM".equals(upperName) ||
7026               "SQLSTATE".equals(upperName) ||
7027               "SQLCODE".equals(upperName);
7028    }
7029
7030    /**
7031     * Find a Snowflake stage table in the current scope.
7032     * Stage tables are identified by their table name starting with '@' or being a quoted
7033     * string starting with '@' (e.g., '@stage/path' or '@schema.stage_name').
7034     *
7035     * @return The stage table if found, null otherwise
7036     */
7037    private TTable findSnowflakeStageTableInScope() {
7038        // Check if we have a current select scope with a FROM scope
7039        if (currentSelectScope == null) {
7040            if (DEBUG_SCOPE_BUILD) {
7041                System.out.println("[DEBUG] findSnowflakeStageTableInScope: currentSelectScope is null");
7042            }
7043            return null;
7044        }
7045
7046        FromScope fromScope = currentSelectScope.getFromScope();
7047        if (fromScope == null) {
7048            if (DEBUG_SCOPE_BUILD) {
7049                System.out.println("[DEBUG] findSnowflakeStageTableInScope: fromScope is null");
7050            }
7051            return null;
7052        }
7053
7054        if (DEBUG_SCOPE_BUILD) {
7055            System.out.println("[DEBUG] findSnowflakeStageTableInScope: fromScope has " +
7056                fromScope.getChildren().size() + " children");
7057        }
7058
7059        // Search for stage tables through the FromScope's children (namespaces)
7060        for (ScopeChild child : fromScope.getChildren()) {
7061            INamespace namespace = child.getNamespace();
7062            if (namespace != null) {
7063                TTable table = namespace.getFinalTable();
7064                if (DEBUG_SCOPE_BUILD) {
7065                    System.out.println("[DEBUG]   child: alias=" + child.getAlias() +
7066                        " table=" + (table != null ? table.getTableName() : "null") +
7067                        " isStage=" + (table != null ? isSnowflakeStageTable(table) : "N/A"));
7068                }
7069                if (table != null && isSnowflakeStageTable(table)) {
7070                    return table;
7071                }
7072            }
7073        }
7074
7075        return null;
7076    }
7077
7078    /**
7079     * Check if a TTable is a Snowflake stage table.
7080     * Stage tables can be identified by:
7081     * - Table type is stageReference
7082     * - Table name starting with '@' (internal stage)
7083     * - Quoted string starting with '@' (external stage with path)
7084     *
7085     * @param table The table to check
7086     * @return true if this is a stage table
7087     */
7088    private boolean isSnowflakeStageTable(TTable table) {
7089        if (table == null) {
7090            return false;
7091        }
7092
7093        // Check for stageReference table type (e.g., @schema.stage_name/path)
7094        if (table.getTableType() == ETableSource.stageReference) {
7095            return true;
7096        }
7097
7098        if (table.getTableName() == null) {
7099            return false;
7100        }
7101
7102        String tableName = table.getTableName().toString();
7103        if (tableName == null || tableName.isEmpty()) {
7104            return false;
7105        }
7106
7107        // Check for stage table patterns:
7108        // - @stage_name
7109        // - '@stage/path/'
7110        // - @schema.stage_name
7111        return tableName.startsWith("@") ||
7112               tableName.startsWith("'@") ||
7113               tableName.startsWith("\"@");
7114    }
7115
7116    /**
7117     * Check if a TObjectName represents a Snowflake stage file positional column.
7118     * Snowflake stage files allow positional column references like $1, $2, etc.,
7119     * and JSON path access like $1:field.
7120     *
7121     * Patterns recognized:
7122     * - Simple positional: $1, $2, $10, etc. (columnNameOnly = "$1")
7123     * - JSON path: $1:fieldName (objectString = "$1", columnNameOnly = ":fieldName")
7124     *
7125     * @param objectName The object name to check
7126     * @return true if this is a Snowflake stage file positional column
7127     */
7128    private boolean isSnowflakeStagePositionalColumn(TObjectName objectName) {
7129        if (objectName == null) {
7130            return false;
7131        }
7132
7133        String colName = objectName.getColumnNameOnly();
7134        String objStr = objectName.getObjectString();
7135
7136        // Pattern 1: Simple positional column ($1, $2, etc.)
7137        // columnNameOnly = "$1", objectString = ""
7138        if (colName != null && colName.length() >= 2 && colName.startsWith("$")) {
7139            char secondChar = colName.charAt(1);
7140            if (Character.isDigit(secondChar)) {
7141                // Verify all remaining chars are digits
7142                int i = 2;
7143                while (i < colName.length() && Character.isDigit(colName.charAt(i))) {
7144                    i++;
7145                }
7146                if (i == colName.length()) {
7147                    return true;
7148                }
7149            }
7150        }
7151
7152        // Pattern 2: JSON path access ($1:fieldName)
7153        // objectString = "$1", columnNameOnly = ":fieldName"
7154        if (objStr != null && objStr.length() >= 2 && objStr.startsWith("$")) {
7155            char secondChar = objStr.charAt(1);
7156            if (Character.isDigit(secondChar)) {
7157                // Verify remaining chars are digits
7158                int i = 2;
7159                while (i < objStr.length() && Character.isDigit(objStr.charAt(i))) {
7160                    i++;
7161                }
7162                // If objectString is pure positional ($1, $12, etc.) and columnNameOnly starts with ':'
7163                if (i == objStr.length() && colName != null && colName.startsWith(":")) {
7164                    return true;
7165                }
7166            }
7167        }
7168
7169        return false;
7170    }
7171
7172    /**
7173     * Check if a variable name exists in the current PL/SQL block scope chain.
7174     * This walks up the scope chain from the current scope to all parent PL/SQL scopes.
7175     * Used to filter out variable references that should not be collected as column references.
7176     *
7177     * @param variableName The variable name to check (case-insensitive)
7178     * @return true if the variable exists in any scope in the chain
7179     */
7180    private boolean isVariableInPlsqlScopeChain(String variableName) {
7181        // Check the current scope first
7182        if (currentPlsqlBlockScope != null &&
7183            currentPlsqlBlockScope.getVariableNamespace().hasColumn(variableName) == ColumnLevel.EXISTS) {
7184            return true;
7185        }
7186
7187        // Check all parent scopes in the stack
7188        // The stack contains saved parent scopes when we enter nested blocks
7189        for (PlsqlBlockScope parentScope : plsqlBlockScopeStack) {
7190            if (parentScope.getVariableNamespace().hasColumn(variableName) == ColumnLevel.EXISTS) {
7191                return true;
7192            }
7193        }
7194
7195        return false;
7196    }
7197
7198    /**
7199     * Check if a TObjectName is a lambda expression parameter by examining the parent chain.
7200     * Lambda parameters (e.g., x in "x -> x + 1" or acc, x in "(acc, x) -> acc + x")
7201     * should not be treated as column references.
7202     *
7203     * This method is called when lambdaParameters set hasn't been populated yet
7204     * (because TObjectName is visited before TExpression for the lambda).
7205     *
7206     * @param objectName The object name to check
7207     * @return true if it's a lambda parameter
7208     */
7209    private boolean isLambdaParameter(TObjectName objectName) {
7210        if (objectName == null) return false;
7211        if (lambdaParameterStack.isEmpty()) return false;
7212
7213        // Check if this objectName's name matches any parameter in the current lambda context
7214        String name = objectName.toString();
7215        if (name == null) return false;
7216        String nameLower = name.toLowerCase();
7217
7218        // Check all lambda contexts on the stack (for nested lambdas)
7219        for (Set<String> paramNames : lambdaParameterStack) {
7220            if (paramNames.contains(nameLower)) {
7221                // Also add to lambdaParameters for future reference
7222                lambdaParameters.add(objectName);
7223                return true;
7224            }
7225        }
7226
7227        return false;
7228    }
7229
7230    /**
7231     * Check if a name is a known SQL Server schema name.
7232     * Schema names in SQL Server include system schemas and common user schemas.
7233     *
7234     * When a 2-part function name like "dbo.ufnGetInventoryStock" is encountered,
7235     * we need to distinguish between:
7236     * - schema.function() call (e.g., dbo.ufnGetInventoryStock) - NOT a column reference
7237     * - column.method() call (e.g., Demographics.value) - IS a column reference
7238     *
7239     * We use schema name detection to identify the former case.
7240     *
7241     * @param name The potential schema name to check
7242     * @return true if it's a known SQL Server schema name
7243     */
7244    private boolean isSqlServerSchemaName(String name) {
7245        if (name == null || name.isEmpty()) {
7246            return false;
7247        }
7248
7249        String upperName = name.toUpperCase();
7250
7251        // System schemas
7252        if (upperName.equals("DBO") ||
7253            upperName.equals("SYS") ||
7254            upperName.equals("INFORMATION_SCHEMA") ||
7255            upperName.equals("GUEST") ||
7256            upperName.equals("DB_OWNER") ||
7257            upperName.equals("DB_ACCESSADMIN") ||
7258            upperName.equals("DB_SECURITYADMIN") ||
7259            upperName.equals("DB_DDLADMIN") ||
7260            upperName.equals("DB_BACKUPOPERATOR") ||
7261            upperName.equals("DB_DATAREADER") ||
7262            upperName.equals("DB_DATAWRITER") ||
7263            upperName.equals("DB_DENYDATAREADER") ||
7264            upperName.equals("DB_DENYDATAWRITER")) {
7265            return true;
7266        }
7267
7268        return false;
7269    }
7270
7271    /**
7272     * Check if a TObjectName is a cursor name in cursor-related statements.
7273     * Cursor names appear in DECLARE CURSOR, OPEN, FETCH, CLOSE, DEALLOCATE statements
7274     * and should not be treated as column references.
7275     *
7276     * @param objectName The object name to check
7277     * @return true if it's a cursor name in a cursor-related statement
7278     */
7279    private static boolean isNumericLiteral(String text) {
7280        if (text == null || text.isEmpty()) return false;
7281        boolean hasDigit = false;
7282        boolean hasDot = false;
7283        for (int i = 0; i < text.length(); i++) {
7284            char c = text.charAt(i);
7285            if (c >= '0' && c <= '9') {
7286                hasDigit = true;
7287            } else if (c == '.' && !hasDot) {
7288                hasDot = true;
7289            } else {
7290                return false;
7291            }
7292        }
7293        return hasDigit;
7294    }
7295
7296    private boolean isCursorName(TObjectName objectName) {
7297        // Traverse up the parent chain to find cursor-related statements
7298        if (objectName.getDbObjectType() == EDbObjectType.cursor) return true;
7299
7300        TParseTreeNode node = objectName.getParentObjectName();
7301        while (node != null) {
7302            // SQL Server cursor statements
7303            if (node instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare) {
7304                gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare declare =
7305                    (gudusoft.gsqlparser.stmt.mssql.TMssqlDeclare) node;
7306                if (declare.getCursorName() == objectName) {
7307                    return true;
7308                }
7309            }
7310            if (node instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlOpen) {
7311                gudusoft.gsqlparser.stmt.mssql.TMssqlOpen open =
7312                    (gudusoft.gsqlparser.stmt.mssql.TMssqlOpen) node;
7313                if (open.getCursorName() == objectName) {
7314                    return true;
7315                }
7316            }
7317            if (node instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlFetch) {
7318                gudusoft.gsqlparser.stmt.mssql.TMssqlFetch fetch =
7319                    (gudusoft.gsqlparser.stmt.mssql.TMssqlFetch) node;
7320                if (fetch.getCursorName() == objectName) {
7321                    return true;
7322                }
7323            }
7324            if (node instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlClose) {
7325                gudusoft.gsqlparser.stmt.mssql.TMssqlClose close =
7326                    (gudusoft.gsqlparser.stmt.mssql.TMssqlClose) node;
7327                if (close.getCursorName() == objectName) {
7328                    return true;
7329                }
7330            }
7331            if (node instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlDeallocate) {
7332                gudusoft.gsqlparser.stmt.mssql.TMssqlDeallocate deallocate =
7333                    (gudusoft.gsqlparser.stmt.mssql.TMssqlDeallocate) node;
7334                if (deallocate.getCursorName() == objectName) {
7335                    return true;
7336                }
7337            }
7338            // Generic cursor statements (used by other databases)
7339            if (node instanceof gudusoft.gsqlparser.stmt.TDeclareCursorStmt) {
7340                gudusoft.gsqlparser.stmt.TDeclareCursorStmt declare =
7341                    (gudusoft.gsqlparser.stmt.TDeclareCursorStmt) node;
7342                if (declare.getCursorName() == objectName) {
7343                    return true;
7344                }
7345            }
7346            if (node instanceof gudusoft.gsqlparser.stmt.TOpenStmt) {
7347                gudusoft.gsqlparser.stmt.TOpenStmt open =
7348                    (gudusoft.gsqlparser.stmt.TOpenStmt) node;
7349                if (open.getCursorName() == objectName) {
7350                    return true;
7351                }
7352            }
7353            if (node instanceof gudusoft.gsqlparser.stmt.TFetchStmt) {
7354                gudusoft.gsqlparser.stmt.TFetchStmt fetch =
7355                    (gudusoft.gsqlparser.stmt.TFetchStmt) node;
7356                if (fetch.getCursorName() == objectName) {
7357                    return true;
7358                }
7359            }
7360            if (node instanceof gudusoft.gsqlparser.stmt.TCloseStmt) {
7361                gudusoft.gsqlparser.stmt.TCloseStmt close =
7362                    (gudusoft.gsqlparser.stmt.TCloseStmt) node;
7363                if (close.getCursorName() == objectName) {
7364                    return true;
7365                }
7366            }
7367            node = node.getParentObjectName();
7368        }
7369        return false;
7370    }
7371
7372    /**
7373     * Check if a name is a SQL Server datepart keyword.
7374     * These keywords are used in DATEDIFF, DATEADD, DATEPART, DATENAME functions
7375     * and should not be treated as column references.
7376     *
7377     * @param name The identifier name to check
7378     * @return true if it's a known SQL Server datepart keyword
7379     */
7380    private boolean isSqlServerDatepartKeyword(String name) {
7381        if (name == null) {
7382            return false;
7383        }
7384        // Only apply this check for SQL Server and Azure SQL Database
7385        if (dbVendor != EDbVendor.dbvmssql && dbVendor != EDbVendor.dbvazuresql) {
7386            return false;
7387        }
7388        return SQL_SERVER_DATEPART_KEYWORDS.contains(name.toLowerCase());
7389    }
7390
7391    /**
7392     * SQL Server date functions that take a datepart keyword as first argument.
7393     */
7394    private static final Set<String> SQL_SERVER_DATE_FUNCTIONS = new HashSet<>(Arrays.asList(
7395        "datediff", "dateadd", "datepart", "datename", "datetrunc",
7396        "datediff_big"  // SQL Server 2016+
7397    ));
7398
7399    /**
7400     * Check if a TObjectName is in a date function context.
7401     * This verifies that the token is preceded by "FUNCTION_NAME(" pattern.
7402     *
7403     * @param objectName The object name to check
7404     * @return true if it appears to be a datepart argument in a date function
7405     */
7406    private boolean isInDateFunctionContext(TObjectName objectName) {
7407        TSourceToken startToken = objectName.getStartToken();
7408        if (startToken == null) {
7409            return false;
7410        }
7411
7412        // Use the token's container to access the token list
7413        TCustomSqlStatement stmt = objectName.getGsqlparser() != null ?
7414            (objectName.getGsqlparser().sqlstatements != null &&
7415             objectName.getGsqlparser().sqlstatements.size() > 0 ?
7416             objectName.getGsqlparser().sqlstatements.get(0) : null) : null;
7417        if (stmt == null || stmt.sourcetokenlist == null) {
7418            return false;
7419        }
7420        TSourceTokenList tokenList = stmt.sourcetokenlist;
7421
7422        // Find the position of our token
7423        int pos = startToken.posinlist;
7424        if (pos < 0) {
7425            return false;
7426        }
7427
7428        // Look for opening paren before this token (skipping whitespace)
7429        int parenPos = pos - 1;
7430        while (parenPos >= 0 && tokenList.get(parenPos).tokentype == ETokenType.ttwhitespace) {
7431            parenPos--;
7432        }
7433        if (parenPos < 0) {
7434            return false;
7435        }
7436
7437        TSourceToken parenToken = tokenList.get(parenPos);
7438        if (parenToken.tokentype != ETokenType.ttleftparenthesis) {
7439            return false;
7440        }
7441
7442        // Look for function name before the opening paren (skipping whitespace)
7443        int funcPos = parenPos - 1;
7444        while (funcPos >= 0 && tokenList.get(funcPos).tokentype == ETokenType.ttwhitespace) {
7445            funcPos--;
7446        }
7447        if (funcPos < 0) {
7448            return false;
7449        }
7450
7451        TSourceToken funcToken = tokenList.get(funcPos);
7452        String funcName = funcToken.toString().toLowerCase();
7453        return SQL_SERVER_DATE_FUNCTIONS.contains(funcName);
7454    }
7455
7456    /**
7457     * Check if a TObjectName is the alias part of SQL Server's proprietary column alias syntax.
7458     * In SQL Server, "column_alias = expression" is a valid way to alias a column.
7459     * The left side (column_alias) should not be treated as a column reference.
7460     *
7461     * Example: SELECT day_diff = DATEDIFF(DAY, start_date, end_date)
7462     * Here "day_diff" is an alias, not a column from any table.
7463     *
7464     * This method checks against the set populated by preVisit(TResultColumn).
7465     */
7466    private boolean isSqlServerProprietaryColumnAlias(TObjectName objectName, TParseTreeNode parent) {
7467        if (sqlServerProprietaryAliases.contains(objectName)) {
7468            if (DEBUG_SCOPE_BUILD) {
7469                System.out.println("[DEBUG] Skipping SQL Server proprietary column alias: " +
7470                        objectName.toString());
7471            }
7472            return true;
7473        }
7474        return false;
7475    }
7476
7477    /**
7478     * Determine which scope a column reference belongs to
7479     */
7480    private IScope determineColumnScope(TObjectName objectName) {
7481        // Special handling for ORDER BY columns in combined queries (UNION/INTERSECT/EXCEPT)
7482        // The parser moves ORDER BY from a branch to the combined query, but the column
7483        // should be resolved in the branch's scope where it originally appeared.
7484        if (currentSelectScope != null) {
7485            TSelectSqlStatement selectStmt = getStatementForScope(currentSelectScope);
7486            if (selectStmt != null && selectStmt.isCombinedQuery()) {
7487                // Check if column is in an ORDER BY clause
7488                if (isInOrderByClause(objectName, selectStmt)) {
7489                    // Find the branch that contains this column's position
7490                    SelectScope branchScope = findBranchScopeByPosition(selectStmt, objectName);
7491                    if (branchScope != null) {
7492                        return branchScope;
7493                    }
7494                }
7495            }
7496            return currentSelectScope;
7497        }
7498
7499        // Or use current UpdateScope if processing UPDATE statement
7500        if (currentUpdateScope != null) {
7501            return currentUpdateScope;
7502        }
7503
7504        // Or use current MergeScope if processing MERGE statement
7505        if (currentMergeScope != null) {
7506            return currentMergeScope;
7507        }
7508
7509        // Or use current DeleteScope if processing DELETE statement
7510        if (currentDeleteScope != null) {
7511            return currentDeleteScope;
7512        }
7513
7514        // Fallback to top of stack
7515        return scopeStack.isEmpty() ? globalScope : scopeStack.peek();
7516    }
7517
7518    /**
7519     * Get the TSelectSqlStatement for a SelectScope
7520     */
7521    private TSelectSqlStatement getStatementForScope(SelectScope scope) {
7522        for (Map.Entry<TSelectSqlStatement, SelectScope> entry : statementScopeMap.entrySet()) {
7523            if (entry.getValue() == scope) {
7524                return entry.getKey();
7525            }
7526        }
7527        return null;
7528    }
7529
7530    /**
7531     * Check if an object name is inside an ORDER BY clause
7532     */
7533    private boolean isInOrderByClause(TObjectName objectName, TSelectSqlStatement stmt) {
7534        TOrderBy orderBy = stmt.getOrderbyClause();
7535        if (orderBy == null) {
7536            return false;
7537        }
7538        // Check if objectName's position is within ORDER BY's position range
7539        long objOffset = objectName.getStartToken().posinlist;
7540        long orderByStart = orderBy.getStartToken().posinlist;
7541        long orderByEnd = orderBy.getEndToken().posinlist;
7542        return objOffset >= orderByStart && objOffset <= orderByEnd;
7543    }
7544
7545    /**
7546     * Find the branch SelectScope that contains the given column's position.
7547     * Returns null if no matching branch is found.
7548     */
7549    private SelectScope findBranchScopeByPosition(TSelectSqlStatement combinedStmt, TObjectName objectName) {
7550        if (!combinedStmt.isCombinedQuery()) {
7551            return null;
7552        }
7553
7554        long columnLine = objectName.getStartToken().lineNo;
7555
7556        // Search through branches recursively
7557        return findBranchScopeByLineRecursive(combinedStmt, columnLine);
7558    }
7559
7560    /**
7561     * Iteratively search for the branch that contains the given line number.
7562     * Uses explicit stack to avoid StackOverflow on deeply nested UNION chains.
7563     */
7564    private SelectScope findBranchScopeByLineRecursive(TSelectSqlStatement stmt, long targetLine) {
7565        Deque<TSelectSqlStatement> stack = new ArrayDeque<>();
7566        stack.push(stmt);
7567
7568        while (!stack.isEmpty()) {
7569            TSelectSqlStatement current = stack.pop();
7570
7571            if (!current.isCombinedQuery()) {
7572                // This is a leaf branch - check if it contains the target line
7573                if (current.tables != null && current.tables.size() > 0) {
7574                    for (int i = 0; i < current.tables.size(); i++) {
7575                        TTable table = current.tables.getTable(i);
7576                        if (table.getStartToken().lineNo == targetLine) {
7577                            return statementScopeMap.get(current);
7578                        }
7579                    }
7580                }
7581                // Alternative: check if statement's range includes the target line
7582                long stmtStartLine = current.getStartToken().lineNo;
7583                long stmtEndLine = current.getEndToken().lineNo;
7584                if (targetLine >= stmtStartLine && targetLine <= stmtEndLine) {
7585                    return statementScopeMap.get(current);
7586                }
7587            } else {
7588                // Combined query - push children (right first so left is processed first)
7589                if (current.getRightStmt() != null) {
7590                    stack.push(current.getRightStmt());
7591                }
7592                if (current.getLeftStmt() != null) {
7593                    stack.push(current.getLeftStmt());
7594                }
7595            }
7596        }
7597        return null;
7598    }
7599
7600    // ========== Accessors ==========
7601
7602    public GlobalScope getGlobalScope() {
7603        return globalScope;
7604    }
7605
7606    public INameMatcher getNameMatcher() {
7607        return nameMatcher;
7608    }
7609
7610    public Map<TUpdateSqlStatement, UpdateScope> getUpdateScopeMap() {
7611        return Collections.unmodifiableMap(updateScopeMap);
7612    }
7613
7614    public Map<TDeleteSqlStatement, DeleteScope> getDeleteScopeMap() {
7615        return Collections.unmodifiableMap(deleteScopeMap);
7616    }
7617
7618    /**
7619     * Get the mapping of USING columns to their right-side tables.
7620     * In JOIN...USING syntax, USING columns should preferentially resolve
7621     * to the right-side (physical) table for TGetTableColumn compatibility.
7622     *
7623     * @return Map of USING column TObjectName -> right-side TTable
7624     */
7625    public Map<TObjectName, TTable> getUsingColumnToRightTable() {
7626        return Collections.unmodifiableMap(usingColumnToRightTable);
7627    }
7628
7629    /**
7630     * Get the set of virtual trigger tables (deleted/inserted in SQL Server triggers).
7631     * These tables should be excluded from table output since their columns are
7632     * resolved to the trigger's target table.
7633     *
7634     * @return Set of TTable objects that are virtual trigger tables
7635     */
7636    public Set<TTable> getVirtualTriggerTables() {
7637        return Collections.unmodifiableSet(virtualTriggerTables);
7638    }
7639
7640    /**
7641     * Get the set of SET clause target columns (UPDATE SET left-side columns).
7642     * These columns already have sourceTable correctly set to the UPDATE target table
7643     * and should NOT be re-resolved through star column push-down.
7644     *
7645     * @return Set of TObjectName nodes that are SET clause target columns
7646     */
7647    public Set<TObjectName> getSetClauseTargetColumns() {
7648        return Collections.unmodifiableSet(setClauseTargetColumns);
7649    }
7650
7651    /**
7652     * Get the set of INSERT ALL target columns (from TInsertIntoValue columnList).
7653     * These columns already have sourceTable correctly set to the INSERT target table
7654     * and should NOT be re-resolved against the subquery scope.
7655     *
7656     * @return Set of TObjectName nodes that are INSERT ALL target columns
7657     */
7658    public Set<TObjectName> getInsertAllTargetColumns() {
7659        return Collections.unmodifiableSet(insertAllTargetColumns);
7660    }
7661
7662    /**
7663     * Get the map of MERGE INSERT VALUES columns to their USING (source) table.
7664     * After name resolution, the resolver should restore sourceTable for these columns
7665     * to ensure they correctly link to the USING table per MERGE semantics.
7666     *
7667     * @return Map of TObjectName to their USING table
7668     */
7669    public Map<TObjectName, TTable> getMergeInsertValuesColumns() {
7670        return Collections.unmodifiableMap(mergeInsertValuesColumns);
7671    }
7672}