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