001package gudusoft.gsqlparser.resolver2;
002
003import gudusoft.gsqlparser.EDbObjectType;
004import gudusoft.gsqlparser.EDbVendor;
005import gudusoft.gsqlparser.EExpressionType;
006import gudusoft.gsqlparser.ESqlClause;
007import gudusoft.gsqlparser.ESqlStatementType;
008import gudusoft.gsqlparser.ETableEffectType;
009import gudusoft.gsqlparser.ETableSource;
010import gudusoft.gsqlparser.TBaseType;
011import gudusoft.gsqlparser.TCustomSqlStatement;
012import gudusoft.gsqlparser.TSourceToken;
013import gudusoft.gsqlparser.TStatementList;
014import gudusoft.gsqlparser.nodes.*;
015import gudusoft.gsqlparser.resolver2.model.ColumnSource;
016import gudusoft.gsqlparser.resolver2.namespace.CTENamespace;
017import gudusoft.gsqlparser.resolver2.namespace.INamespace;
018import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
019import gudusoft.gsqlparser.stmt.TMergeSqlStatement;
020import gudusoft.gsqlparser.stmt.TCreateTableSqlStatement;
021import gudusoft.gsqlparser.stmt.TAlterTableStatement;
022
023import gudusoft.gsqlparser.resolver2.format.DisplayNameMode;
024import gudusoft.gsqlparser.resolver2.format.DisplayNameNormalizer;
025import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
026import gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher;
027import gudusoft.gsqlparser.resolver2.result.IResolutionResult;
028import gudusoft.gsqlparser.resolver2.result.ResolutionResultImpl;
029import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
030
031import java.util.*;
032
033
034/**
035 * Formats the resolution results from {@link TSQLResolver2} into structured output
036 * for testing assertions and debugging.
037 *
038 * <p>This class provides a consistent way to extract and format <b>Tables / Fields / CTE</b>
039 * information from {@link TSQLResolver2} results, handling special scenarios such as
040 * <b>UNNEST</b>, <b>CTE</b>, <b>JOIN...USING</b>, <b>SELECT * EXCEPT</b>, etc.</p>
041 *
042 * <h2>Separation of Concerns Principle</h2>
043 *
044 * <p><b>Key Principle:</b> {@code TSQLResolver2ResultFormatter} is <b>only responsible for
045 * formatting output</b>, NOT for name resolution.</p>
046 *
047 * <ul>
048 *   <li><b>Name resolution logic:</b> Must be implemented in {@link TSQLResolver2},
049 *       {@link ScopeBuilder}, and {@link NameResolver}</li>
050 *   <li><b>Formatting logic:</b> Implemented in this class ({@code TSQLResolver2ResultFormatter})</li>
051 * </ul>
052 *
053 * <p><b>Violating this principle will cause:</b></p>
054 * <ul>
055 *   <li>Users directly using the {@link TSQLResolver2} API will not get correct resolution results</li>
056 *   <li>Data consistency issues in resolution results</li>
057 * </ul>
058 *
059 * <p>This means the formatter should only <b>read</b> data that has already been resolved
060 * by the resolver components. It should never attempt to perform resolution logic such as
061 * guessing table names for unresolved columns or modifying the AST nodes.</p>
062 *
063 * <h2>Configuration Options</h2>
064 *
065 * <p>The formatter supports various configuration options to control output:</p>
066 * <ul>
067 *   <li>{@link #setShowCTE(boolean)} - Include CTE tables in output</li>
068 *   <li>{@link #setOnlyPhysicalTables(boolean)} - Filter to physical tables only</li>
069 *   <li>{@link #setShowUnnest(boolean)} - Include UNNEST tables when onlyPhysicalTables=true</li>
070 *   <li>{@link #setShowPivotTable(boolean)} - Include PIVOT tables when onlyPhysicalTables=true</li>
071 *   <li>{@link #setShowLateralView(boolean)} - Include LATERAL VIEW tables when onlyPhysicalTables=true</li>
072 *   <li>{@link #setShowDatatype(boolean)} - Include column datatypes</li>
073 *   <li>{@link #setShowColumnLocation(boolean)} - Include SQL clause location</li>
074 *   <li>{@link #setShowTableEffect(boolean)} - Include table effect type</li>
075 * </ul>
076 *
077 * <h2>Usage Example</h2>
078 *
079 * <pre>{@code
080 * TSQLResolver2 resolver = new TSQLResolver2(env, statements, config);
081 * resolver.resolve();
082 *
083 * TSQLResolver2ResultFormatter formatter = new TSQLResolver2ResultFormatter(resolver);
084 * formatter.setShowCTE(true);
085 * formatter.setShowDatatype(true);
086 *
087 * String result = formatter.format();
088 * }</pre>
089 *
090 * @see TSQLResolver2
091 * @see ScopeBuilder
092 * @see NameResolver
093 * @see gudusoft.gsqlparser.util.TGetTableColumn
094 */
095public class TSQLResolver2ResultFormatter {
096
097    // ========== Configuration Options ==========
098
099    /** Include CTE tables and their columns in Fields output */
100    private boolean showCTE = false;
101
102    /** Include column datatypes in output (e.g., column:string) */
103    private boolean showDatatype = false;
104
105    /** Include separate Ctes section with CTE column definitions */
106    private boolean showColumnsOfCTE = false;
107
108    /** Include star columns (*) in output */
109    private boolean listStarColumn = true;
110
111    /** Include table effect type in output (e.g., tableName(effectInsert)) */
112    private boolean showTableEffect = false;
113
114    /** Include column location/clause in output (e.g., columnName(selectList)) */
115    private boolean showColumnLocation = false;
116
117    /**
118     * Only include physical/base tables in output, excluding:
119     * - PL/SQL record variables (rec_xxx)
120     * - Cursor variables
121     * - Package variables
122     * - Other non-table sources
123     *
124     * This option makes output compatible with the old TGetTableColumn format.
125     */
126    private boolean onlyPhysicalTables = false;
127
128    /**
129     * When true (default), UNNEST tables will be included in output even when onlyPhysicalTables=true.
130     * When false, UNNEST tables are excluded when onlyPhysicalTables=true.
131     */
132    private boolean showUnnest = true;
133
134    /**
135     * When true (default), PIVOT tables will be included in output even when onlyPhysicalTables=true.
136     * When false, PIVOT tables are excluded when onlyPhysicalTables=true.
137     */
138    private boolean showPivotTable = true;
139
140    /**
141     * When true (default), LATERAL VIEW tables will be included in output even when onlyPhysicalTables=true.
142     * When false, LATERAL VIEW tables are excluded when onlyPhysicalTables=true.
143     */
144    private boolean showLateralView = true;
145
146    /**
147     * When true (default), orphan columns (columns that cannot be definitively linked
148     * to a single table due to ambiguity or missing metadata) will be linked to the
149     * first candidate table in candidateTables.
150     *
151     * When false, orphan columns will use "missed" as their table prefix in output.
152     *
153     * Example: For "SELECT column1 FROM tablea, tableb" where column1 exists in both tables:
154     * - linkOrphanColumnToFirstTable=true: outputs "tablea.column1"
155     * - linkOrphanColumnToFirstTable=false: outputs "missed.column1"
156     */
157    private boolean linkOrphanColumnToFirstTable = true;
158
159    // ========== Internal State ==========
160
161    private final TSQLResolver2 resolver;
162    private final TStatementList statements;
163    private final ScopeBuildResult buildResult;
164
165    /** Name matcher for identifier normalization (from config or default) */
166    private final INameMatcher nameMatcher;
167
168    /** Display name normalizer for stripping delimiters without case folding */
169    private final DisplayNameNormalizer displayNameNormalizer;
170
171    /** Display name mode (DISPLAY, SQL_RENDER, CANONICAL) */
172    private DisplayNameMode displayNameMode = DisplayNameMode.DISPLAY;
173
174    /** Resolution result interface for statement-centric access */
175    private final IResolutionResult resolutionResult;
176
177    // ========== Constructor ==========
178
179    /**
180     * Create a formatter for the given resolver.
181     *
182     * @param resolver The TSQLResolver2 instance (must have called resolve())
183     */
184    public TSQLResolver2ResultFormatter(TSQLResolver2 resolver) {
185        this.resolver = resolver;
186        this.statements = resolver.getStatements();
187        this.buildResult = resolver.getScopeBuildResult();
188        // Use name matcher from resolver's config
189        TSQLResolverConfig config = resolver.getConfig();
190        this.nameMatcher = config != null ? config.getNameMatcher() : null;
191        // Initialize display name normalizer
192        EDbVendor vendor = config != null ? config.getVendor() : getVendorFromStatements();
193        this.displayNameNormalizer = new DisplayNameNormalizer(vendor);
194        if (config != null) {
195            this.displayNameMode = config.getDisplayNameMode();
196            this.displayNameNormalizer.setMode(this.displayNameMode);
197            this.displayNameNormalizer.setStripDelimiters(config.isStripDelimitersForDisplay());
198        }
199        // Create resolution result interface for statement-centric access
200        this.resolutionResult = (buildResult != null)
201            ? new ResolutionResultImpl(buildResult, statements)
202            : null;
203    }
204
205    /**
206     * Create a formatter with a specific configuration.
207     *
208     * @param resolver The TSQLResolver2 instance
209     * @param config Configuration to apply
210     */
211    public TSQLResolver2ResultFormatter(TSQLResolver2 resolver, TSQLResolverConfig config) {
212        this.resolver = resolver;
213        this.statements = resolver.getStatements();
214        this.buildResult = resolver.getScopeBuildResult();
215        if (config != null) {
216            this.showDatatype = config.isShowDatatype();
217            this.showCTE = config.isShowCTE();
218            this.nameMatcher = config.getNameMatcher();
219            this.displayNameMode = config.getDisplayNameMode();
220        } else {
221            this.nameMatcher = null;
222        }
223        // Initialize display name normalizer
224        EDbVendor vendor = config != null ? config.getVendor() : getVendorFromStatements();
225        this.displayNameNormalizer = new DisplayNameNormalizer(vendor);
226        if (config != null) {
227            this.displayNameNormalizer.setMode(this.displayNameMode);
228            this.displayNameNormalizer.setStripDelimiters(config.isStripDelimitersForDisplay());
229        }
230        // Create resolution result interface for statement-centric access
231        this.resolutionResult = (buildResult != null)
232            ? new ResolutionResultImpl(buildResult, statements)
233            : null;
234    }
235
236    /**
237     * Get vendor from statements if not provided in config.
238     */
239    private EDbVendor getVendorFromStatements() {
240        if (statements != null && statements.size() > 0) {
241            return statements.get(0).dbvendor;
242        }
243        return null;
244    }
245
246    // ========== Configuration Setters ==========
247
248    public TSQLResolver2ResultFormatter setShowCTE(boolean showCTE) {
249        this.showCTE = showCTE;
250        return this;
251    }
252
253    public TSQLResolver2ResultFormatter setShowDatatype(boolean showDatatype) {
254        this.showDatatype = showDatatype;
255        return this;
256    }
257
258    public TSQLResolver2ResultFormatter setShowColumnsOfCTE(boolean showColumnsOfCTE) {
259        this.showColumnsOfCTE = showColumnsOfCTE;
260        return this;
261    }
262
263    public TSQLResolver2ResultFormatter setListStarColumn(boolean listStarColumn) {
264        this.listStarColumn = listStarColumn;
265        return this;
266    }
267
268    /**
269     * Get the current display name mode.
270     *
271     * @return the current DisplayNameMode
272     */
273    public DisplayNameMode getDisplayNameMode() {
274        return displayNameMode;
275    }
276
277    /**
278     * Set the display name mode for identifier formatting.
279     *
280     * <p>This controls how identifiers (table names, column names) are formatted in output:</p>
281     * <ul>
282     *   <li>{@link DisplayNameMode#DISPLAY} - Strip delimiters, preserve original case
283     *       (e.g., {@code [OrderID]} → {@code OrderID})</li>
284     *   <li>{@link DisplayNameMode#SQL_RENDER} - Preserve delimiters for valid SQL regeneration
285     *       (e.g., {@code [Order ID]} → {@code [Order ID]})</li>
286     *   <li>{@link DisplayNameMode#CANONICAL} - Apply vendor-specific case folding
287     *       (e.g., Oracle: {@code MyTable} → {@code MYTABLE})</li>
288     * </ul>
289     *
290     * <p>This method can be called after instantiation to change the mode for each SQL text
291     * being processed.</p>
292     *
293     * @param mode the DisplayNameMode to use
294     * @return this formatter for chaining
295     */
296    public TSQLResolver2ResultFormatter setDisplayNameMode(DisplayNameMode mode) {
297        this.displayNameMode = mode != null ? mode : DisplayNameMode.DISPLAY;
298        this.displayNameNormalizer.setMode(this.displayNameMode);
299        return this;
300    }
301
302    /**
303     * Set whether to include table effect type in output.
304     * When true, tables will be displayed as tableName(effectType) where effectType
305     * indicates how the table is used (e.g., effectInsert, effectUpdate, effectSelect).
306     *
307     * @param showTableEffect true to include table effect
308     * @return this formatter for chaining
309     */
310    public TSQLResolver2ResultFormatter setShowTableEffect(boolean showTableEffect) {
311        this.showTableEffect = showTableEffect;
312        return this;
313    }
314
315    /**
316     * Set whether to include column location/clause in output.
317     * When true, columns will be displayed as columnName(location) where location
318     * indicates the SQL clause where the column appears (e.g., selectList, where, groupBy).
319     *
320     * @param showColumnLocation true to include column location
321     * @return this formatter for chaining
322     */
323    public TSQLResolver2ResultFormatter setShowColumnLocation(boolean showColumnLocation) {
324        this.showColumnLocation = showColumnLocation;
325        return this;
326    }
327
328    /**
329     * Set whether to only include physical/base tables in output.
330     * When true, excludes PL/SQL record variables, cursor variables, and other non-table sources.
331     * This makes output compatible with the old TGetTableColumn format.
332     *
333     * @param onlyPhysicalTables true to filter to physical tables only
334     * @return this formatter for chaining
335     */
336    public TSQLResolver2ResultFormatter setOnlyPhysicalTables(boolean onlyPhysicalTables) {
337        this.onlyPhysicalTables = onlyPhysicalTables;
338        return this;
339    }
340
341    /**
342     * Set whether to include UNNEST tables in output when onlyPhysicalTables=true.
343     * Default is true (UNNEST tables are always included).
344     *
345     * @param showUnnest true to include UNNEST tables
346     * @return this formatter for chaining
347     */
348    public TSQLResolver2ResultFormatter setShowUnnest(boolean showUnnest) {
349        this.showUnnest = showUnnest;
350        return this;
351    }
352
353    /**
354     * Set whether to include PIVOT tables in output when onlyPhysicalTables=true.
355     * Default is true (PIVOT tables are always included).
356     *
357     * @param showPivotTable true to include PIVOT tables
358     * @return this formatter for chaining
359     */
360    public TSQLResolver2ResultFormatter setShowPivotTable(boolean showPivotTable) {
361        this.showPivotTable = showPivotTable;
362        return this;
363    }
364
365    /**
366     * Set whether to include LATERAL VIEW tables in output when onlyPhysicalTables=true.
367     * Default is true (LATERAL VIEW tables are always included).
368     *
369     * @param showLateralView true to include LATERAL VIEW tables
370     * @return this formatter for chaining
371     */
372    public TSQLResolver2ResultFormatter setShowLateralView(boolean showLateralView) {
373        this.showLateralView = showLateralView;
374        return this;
375    }
376
377    /**
378     * Set whether orphan columns should be linked to the first candidate table.
379     *
380     * When true (default), orphan columns (columns that cannot be definitively linked
381     * to a single table) will be linked to the first candidate table.
382     *
383     * When false, orphan columns will use "missed" as their table prefix.
384     *
385     * @param linkOrphanColumnToFirstTable true to use first candidate, false to use "missed"
386     * @return this formatter for chaining
387     */
388    public TSQLResolver2ResultFormatter setLinkOrphanColumnToFirstTable(boolean linkOrphanColumnToFirstTable) {
389        this.linkOrphanColumnToFirstTable = linkOrphanColumnToFirstTable;
390        return this;
391    }
392
393    // ========== Configuration Getters ==========
394
395    public boolean isShowCTE() { return showCTE; }
396    public boolean isShowDatatype() { return showDatatype; }
397    public boolean isShowColumnsOfCTE() { return showColumnsOfCTE; }
398    public boolean isOnlyPhysicalTables() { return onlyPhysicalTables; }
399    public boolean isShowUnnest() { return showUnnest; }
400    public boolean isShowPivotTable() { return showPivotTable; }
401    public boolean isShowLateralView() { return showLateralView; }
402    public boolean isListStarColumn() { return listStarColumn; }
403    public boolean isLinkOrphanColumnToFirstTable() { return linkOrphanColumnToFirstTable; }
404    public boolean isShowTableEffect() { return showTableEffect; }
405    public boolean isShowColumnLocation() { return showColumnLocation; }
406
407    /**
408     * Get the resolution result interface for statement-centric access.
409     * This provides a clean API for programmatically accessing resolution results.
410     *
411     * <p>Usage example:</p>
412     * <pre>
413     * IResolutionResult result = formatter.getResolutionResult();
414     * for (TCustomSqlStatement stmt : parser.sqlstatements) {
415     *     for (TTable table : result.getTables(stmt)) {
416     *         System.out.println("Table: " + table.getFullName());
417     *         for (TObjectName col : result.getColumnsForTable(stmt, table)) {
418     *             System.out.println("  Column: " + col.getColumnNameOnly());
419     *         }
420     *     }
421     * }
422     * </pre>
423     *
424     * @return The resolution result interface, or null if resolver has not been called
425     */
426    public IResolutionResult getResolutionResult() {
427        return resolutionResult;
428    }
429
430    // ========== Main Formatting Methods ==========
431
432    /**
433     * Format the resolver results into a structured string.
434     *
435     * @return Formatted string with Tables, Fields, and optionally Ctes sections
436     */
437    public String format() {
438        // Collect tables
439        Set<String> tables = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
440        for (int i = 0; i < statements.size(); i++) {
441            collectTablesFromStatement(statements.get(i), tables);
442        }
443
444        // Collect fields
445        Set<String> fields = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
446        // 1. Collect DDL fields (constraints, CTAS targets) via AST traversal
447        for (int i = 0; i < statements.size(); i++) {
448            collectDDLFieldsFromStatement(statements.get(i), fields);
449        }
450        // 2. Collect resolved fields (SELECT/DML) via global single-pass optimization
451        collectResolvedFieldsGlobal(fields);
452
453        // Collect CTE columns if needed
454        Set<String> cteColumns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
455        if (showColumnsOfCTE) {
456            collectCTEColumns(cteColumns);
457        }
458
459        // Build result string
460        StringBuilder sb = new StringBuilder();
461        sb.append("Tables:\n");
462        for (String table : tables) {
463            sb.append(table).append("\n");
464        }
465        sb.append("\n");
466        sb.append("Fields:\n");
467        for (String field : fields) {
468            sb.append(field).append("\n");
469        }
470
471        if (showColumnsOfCTE && !cteColumns.isEmpty()) {
472            sb.append("\n");
473            sb.append("Ctes:\n");
474            for (String cteCol : cteColumns) {
475                sb.append(cteCol).append("\n");
476            }
477        }
478
479        return sb.toString().trim();
480    }
481
482    /**
483     * Get the list of tables found in the statements.
484     *
485     * @return Set of table names in sorted order
486     */
487    public Set<String> getTables() {
488        Set<String> tables = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
489        for (int i = 0; i < statements.size(); i++) {
490            collectTablesFromStatement(statements.get(i), tables);
491        }
492        return tables;
493    }
494
495    /**
496     * Get the list of fields (table.column) found in the statements.
497     *
498     * @return Set of field names in sorted order
499     */
500    public Set<String> getFields() {
501        Set<String> fields = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
502        for (int i = 0; i < statements.size(); i++) {
503            collectDDLFieldsFromStatement(statements.get(i), fields);
504        }
505        collectResolvedFieldsGlobal(fields);
506        return fields;
507    }
508
509    // ========== Table Collection ==========
510
511    private void collectTablesFromStatement(Object stmt, Set<String> tables) {
512        if (!(stmt instanceof TCustomSqlStatement)) {
513            return;
514        }
515
516        TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt;
517
518        // For top-level statements, use optimized single-pass collection
519        if (customStmt.getParentStmt() == null) {
520            collectTablesSinglePass(customStmt, tables);
521        } else {
522            // For nested statements (rare case), use simple collection
523            collectTablesFromStatementDirect(customStmt, tables);
524        }
525    }
526
527    /**
528     * Optimized single-pass table collection for top-level statements.
529     */
530    private void collectTablesSinglePass(TCustomSqlStatement topStmt, Set<String> tables) {
531        // Use a queue to avoid deep recursion, and a set to avoid re-processing
532        java.util.Deque<TCustomSqlStatement> queue = new java.util.ArrayDeque<>();
533        Set<TCustomSqlStatement> visited = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
534        queue.add(topStmt);
535
536        while (!queue.isEmpty()) {
537            TCustomSqlStatement stmt = queue.poll();
538            if (stmt == null || visited.contains(stmt)) continue;
539            visited.add(stmt);
540
541            // Handle UNION/combined queries
542            if (stmt instanceof TSelectSqlStatement) {
543                TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt;
544                if (selectStmt.isCombinedQuery()) {
545                    if (selectStmt.getLeftStmt() != null) queue.add(selectStmt.getLeftStmt());
546                    if (selectStmt.getRightStmt() != null) queue.add(selectStmt.getRightStmt());
547                    continue;
548                }
549            }
550
551            // Handle CREATE TABLE target
552            if (stmt instanceof TCreateTableSqlStatement) {
553                TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) stmt;
554                if (createStmt.getTargetTable() != null) {
555                    tables.add(getTableDisplayName(createStmt.getTargetTable()));
556                }
557            }
558
559            // Collect tables from this statement
560            for (int i = 0; i < stmt.tables.size(); i++) {
561                TTable table = stmt.tables.getTable(i);
562                if (table == null) continue;
563
564                // Add subquery's statement to queue
565                if (table.getTableType() == ETableSource.subquery) {
566                    if (table.getSubquery() != null) queue.add(table.getSubquery());
567                    continue;
568                }
569
570                // Skip JOINs (they're containers, not actual tables)
571                if (table.getTableType() == ETableSource.join) continue;
572
573                // Skip function tables (table-valued functions) - they're included in Fields, not Tables
574                if (table.getTableType() == ETableSource.function) continue;
575
576                // Skip CTEs if showCTE is false
577                if (table.isCTEName() && !showCTE) continue;
578
579                // Skip virtual trigger tables (deleted/inserted) - resolved to trigger target during name resolution
580                // This is unconditional - virtual trigger tables should never appear in output
581                if (resolver != null && resolver.getVirtualTriggerTables().contains(table)) continue;
582
583                // Skip non-physical tables (record variables, etc.) if onlyPhysicalTables is true
584                if (shouldSkipTableForPhysicalFilter(table)) continue;
585
586                tables.add(getTableDisplayName(table));
587            }
588
589            // Add nested statements to queue
590            for (int i = 0; i < stmt.getStatements().size(); i++) {
591                Object nested = stmt.getStatements().get(i);
592                if (nested instanceof TCustomSqlStatement) {
593                    queue.add((TCustomSqlStatement) nested);
594                }
595            }
596        }
597    }
598
599    /**
600     * Direct table collection for a single statement (no deep recursion).
601     */
602    private void collectTablesFromStatementDirect(TCustomSqlStatement customStmt, Set<String> tables) {
603        // Handle CREATE TABLE target
604        if (customStmt instanceof TCreateTableSqlStatement) {
605            TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) customStmt;
606            if (createStmt.getTargetTable() != null) {
607                tables.add(getTableDisplayName(createStmt.getTargetTable()));
608            }
609        }
610
611        // Collect tables from statement
612        for (int i = 0; i < customStmt.tables.size(); i++) {
613            TTable table = customStmt.tables.getTable(i);
614            if (table == null) continue;
615            if (table.getTableType() == ETableSource.subquery) continue;
616            if (table.getTableType() == ETableSource.join) continue;
617            if (table.getTableType() == ETableSource.function) continue;
618            if (table.isCTEName() && !showCTE) continue;
619            // Skip virtual trigger tables (deleted/inserted) - resolved to trigger target during name resolution
620            if (resolver != null && resolver.getVirtualTriggerTables().contains(table)) continue;
621            if (shouldSkipTableForPhysicalFilter(table)) continue;
622
623            tables.add(getTableDisplayName(table));
624        }
625    }
626
627    private String getTableDisplayName(TTable table) {
628        String baseName;
629        if (table.getTableType() == ETableSource.subquery) {
630            return "(subquery, alias:" + table.getAliasName() + ")";
631        } else if (table.getTableType() == ETableSource.unnest) {
632            String alias = table.getAliasName();
633            return (alias != null && !alias.isEmpty() ? alias : "") + "(unnest table)";
634        } else if (table.getTableType() == ETableSource.pivoted_table) {
635            // For Tables section, return just the table name without prefix
636            baseName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
637        } else if (table.getTableType() == ETableSource.function) {
638            return "(table-valued function:" + normalizeTableName(table.getTableName()) + ")";
639        } else if (table.getTableType() == ETableSource.lateralView) {
640            return "(lateral_view:" + normalizeTableName(table.getTableName()) + ")";
641        } else if (table.isCTEName()) {
642            baseName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
643        } else {
644            baseName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
645        }
646
647        // Append table effect type if enabled and table is a base table
648        if (showTableEffect && table.isBaseTable()) {
649            return baseName + "(" + table.getEffectType() + ")";
650        }
651        return baseName;
652    }
653
654    /**
655     * Check if a table is a physical/base table based on table type information
656     * already determined by TSQLResolver2.
657     *
658     * <p>This method only uses semantic information available from the table object,
659     * not name-based heuristics. Non-physical tables include:</p>
660     * <ul>
661     *   <li>Subqueries (tableType = subquery)</li>
662     *   <li>JOINs (tableType = join)</li>
663     *   <li>Table functions (tableType = function)</li>
664     *   <li>CTEs (isCTEName = true)</li>
665     * </ul>
666     *
667     * <p>Note: Virtual trigger tables (deleted/inserted in SQL Server triggers) are
668     * filtered unconditionally in the table collection methods, not here.</p>
669     *
670     * @param table The TTable to check
671     * @return true if the table is a physical database table
672     */
673    private boolean isPhysicalTable(TTable table) {
674        if (table == null) return false;
675
676        // Only objectname type tables can be physical tables
677        // This filters out subqueries, joins, functions, pivoted tables, unnest, etc.
678        if (table.getTableType() != ETableSource.objectname) {
679            return false;
680        }
681
682        // Tables from CTE are considered virtual, not physical
683        if (table.isCTEName()) {
684            return false;
685        }
686
687        String tableName = table.getName();
688        if (tableName == null || tableName.isEmpty()) {
689            return false;
690        }
691
692        return true;
693    }
694
695    /**
696     * Check if a table should be skipped based on onlyPhysicalTables and the show* options.
697     *
698     * <p>When onlyPhysicalTables=true, tables are skipped unless:</p>
699     * <ul>
700     *   <li>They are physical tables (isPhysicalTable returns true)</li>
701     *   <li>They are UNNEST tables and showUnnest=true</li>
702     *   <li>They are PIVOT tables and showPivotTable=true</li>
703     *   <li>They are LATERAL VIEW tables and showLateralView=true</li>
704     * </ul>
705     *
706     * @param table The TTable to check
707     * @return true if the table should be skipped, false if it should be included
708     */
709    private boolean shouldSkipTableForPhysicalFilter(TTable table) {
710        if (!onlyPhysicalTables) {
711            return false; // Not filtering, don't skip
712        }
713
714        // CTAS target tables are DDL targets, not existing physical tables
715        // Skip them when onlyPhysicalTables=true
716        if (buildResult != null && buildResult.isCTASTargetTable(table)) {
717            return true;
718        }
719
720        // Physical tables are always included when onlyPhysicalTables=true
721        if (isPhysicalTable(table)) {
722            return false;
723        }
724
725        // Check if table type should be included based on show* options
726        ETableSource tableType = table.getTableType();
727        if (tableType == ETableSource.unnest && showUnnest) {
728            return false;
729        }
730        if (tableType == ETableSource.pivoted_table && showPivotTable) {
731            return false;
732        }
733        if (tableType == ETableSource.lateralView && showLateralView) {
734            return false;
735        }
736
737        // All other non-physical tables are skipped when onlyPhysicalTables=true
738        return true;
739    }
740
741    // ========== Field Collection ==========
742
743    /**
744     * Collect DDL fields (constraints, CTAS targets) from statements.
745     * This only handles DDL-specific columns not covered by the resolver's column list.
746     */
747    private void collectDDLFieldsFromStatement(Object stmt, Set<String> fields) {
748        if (!(stmt instanceof TCustomSqlStatement)) {
749            return;
750        }
751
752        TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt;
753
754        // Handle UNION/combined queries
755        if (customStmt instanceof TSelectSqlStatement) {
756            TSelectSqlStatement selectStmt = (TSelectSqlStatement) customStmt;
757            if (selectStmt.isCombinedQuery()) {
758                collectDDLFieldsFromStatement(selectStmt.getLeftStmt(), fields);
759                collectDDLFieldsFromStatement(selectStmt.getRightStmt(), fields);
760                return;
761            }
762        }
763
764        // Handle CREATE TABLE - collect constraint columns only
765        // Note: CTAS target columns (tuple aliases, standard aliases, simple column refs)
766        // are now fully handled by ScopeBuilder.preVisit(TResultColumn) and included in
767        // allColumnReferences. They will be output via collectResolvedFieldsGlobal().
768        if (customStmt instanceof TCreateTableSqlStatement) {
769            TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) customStmt;
770            // Collect constraint columns (PRIMARY KEY, UNIQUE, FOREIGN KEY references)
771            collectConstraintColumns(createStmt, fields);
772        }
773
774        // Handle ALTER TABLE - collect constraint columns
775        if (customStmt instanceof TAlterTableStatement) {
776            TAlterTableStatement alterStmt = (TAlterTableStatement) customStmt;
777            collectAlterTableConstraintColumns(alterStmt, fields);
778        }
779
780        // Recurse for nested statements to find deeper DDLs (e.g. inside blocks)
781        for (int i = 0; i < customStmt.getStatements().size(); i++) {
782            Object nestedStmt = customStmt.getStatements().get(i);
783            if (nestedStmt instanceof TCustomSqlStatement) {
784                collectDDLFieldsFromStatement(nestedStmt, fields);
785            }
786        }
787    }
788
789    /**
790     * Optimized global field collection for all resolved columns.
791     * Iterates through all column references once globally, calculating table prefixes on-the-fly.
792     * This avoids the O(N*M) complexity of per-statement AST traversal for deep nesting.
793     */
794    private void collectResolvedFieldsGlobal(Set<String> fields) {
795        if (buildResult == null) return;
796
797        // Get vendor info once
798        TSQLResolverConfig resolverConfig = resolver != null ? resolver.getConfig() : null;
799        EDbVendor vendor = resolverConfig != null ? resolverConfig.getVendor() : null;
800        if (vendor == null && statements != null && statements.size() > 0) {
801            vendor = statements.get(0).dbvendor;
802        }
803        boolean isSparkOrHive = vendor == EDbVendor.dbvsparksql || vendor == EDbVendor.dbvhive;
804        boolean isSnowflake = vendor == EDbVendor.dbvsnowflake;
805        boolean isSqlServer = vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql;
806
807
808        // Single pass through all column references in the entire session
809        java.util.List<TObjectName> allColumnReferences = buildResult.getAllColumnReferences();
810        for (TObjectName col : allColumnReferences) {
811            if (col == null) continue;
812            if (col.getValidate_column_status() == TBaseType.MARKED_NOT_A_COLUMN_IN_COLUMN_RESOLVER) continue;
813            if (col.getDbObjectType() == EDbObjectType.column_alias) continue;
814
815            // Get column name with vendor-specific handling
816            String columnName = getColumnDisplayName(col, isSparkOrHive, isSnowflake, isSqlServer);
817
818            if (columnName == null || columnName.isEmpty()) continue;
819
820            // Handle star columns specially when listStarColumn=false:
821            // - We need to process unqualified stars (*) for PIVOT/UNNEST column expansion
822            // - Other star columns (qualified like table.*, OUTPUT DELETED.*) should be skipped
823            if ("*".equals(columnName) && !listStarColumn) {
824                // Only process unqualified stars (SELECT *) for PIVOT/UNNEST expansion
825                if (!isUnqualifiedStar(col)) {
826                    continue; // Skip qualified stars when listStarColumn=false
827                }
828                // For unqualified stars, continue processing to reach expandStarToAllTables()
829            }
830
831            // Skip ROWNUM pseudo-column when filtering to physical tables
832            // Note: ROWID is a real column stored in the table, but ROWNUM is a query-result pseudo-column
833            if (onlyPhysicalTables && "ROWNUM".equalsIgnoreCase(columnName)) continue;
834
835            // Get source table
836            TTable sourceTable = col.getSourceTable();
837
838            // Handle star columns specially
839            if ("*".equals(columnName)) {
840                if (!shouldIncludeStarColumn(col, sourceTable)) continue;
841
842                // For unqualified star columns (SELECT *), expand to all tables in FROM clause
843                // Note: We use expandStarToAllTables() which adds table.* entries for each table
844                // The core resolver's star expansion (attributeNodesDerivedFromFromClause) is for
845                // internal resolution purposes, not for output formatting
846                if (isUnqualifiedStar(col)) {
847                    expandStarToAllTables(col, fields);
848                    continue;
849                }
850            }
851
852            // Check if this is a qualified star column with expanded attributes from push-down
853            // These are columns like "src.*" that have been expanded via TSQLResolver2's
854            // star column push-down algorithm
855            // Note: Use col.toString() instead of columnName, because columnName (from getColumnNameOnly)
856            // would be just "*", but we need "src.*" to detect qualified stars
857            String colFullString = col.toString();
858            if (colFullString != null && colFullString.endsWith("*") && !colFullString.equals("*")) {
859                java.util.ArrayList<gudusoft.gsqlparser.TAttributeNode> expandedAttrs =
860                    col.getAttributeNodesDerivedFromFromClause();
861                if (expandedAttrs != null && !expandedAttrs.isEmpty()) {
862                    // Only process if the star column's sourceTable is a physical table (not subquery/CTE/pivot)
863                    // This ensures we output with the correct base table prefix
864                    // PIVOT tables are excluded since their columns are output individually via PivotNamespace
865                    if (sourceTable != null &&
866                        sourceTable.getTableType() != ETableSource.subquery &&
867                        sourceTable.getTableType() != ETableSource.openquery &&
868                        sourceTable.getTableType() != ETableSource.join &&
869                        sourceTable.getTableType() != ETableSource.pivoted_table &&
870                        !(sourceTable.isCTEName() && !showCTE) &&
871                        !shouldSkipTableForPhysicalFilter(sourceTable)) {
872
873                        String tablePrefix = getFieldTablePrefix(sourceTable);
874                        if (tablePrefix != null) {
875                            // Output the star column itself if listStarColumn is true
876                            if (listStarColumn) {
877                                fields.add(tablePrefix + ".*");
878                            }
879
880                            // Output the expanded individual columns using the star's source table
881                            // This ensures columns pushed down through the star get the correct table prefix
882                            // BUT skip columns that are explicitly resolved elsewhere (to a different table)
883                            for (gudusoft.gsqlparser.TAttributeNode attr : expandedAttrs) {
884                                if (attr == null) continue;
885                                String attrName = attr.getName();
886                                if (attrName != null && !attrName.isEmpty() && !attrName.endsWith("*")) {
887                                    // Extract just the column name
888                                    String justColumnName = attrName;
889                                    int dotIdx = attrName.lastIndexOf('.');
890                                    if (dotIdx >= 0) {
891                                        justColumnName = attrName.substring(dotIdx + 1);
892                                    }
893
894                                    // Check if this column is explicitly resolved to a DIFFERENT PHYSICAL table
895                                    // in the column references list - if so, skip it to avoid duplicates
896                                    // Note: We use getFinalTable() to trace through subqueries to the actual physical table
897                                    // This handles cases like: SELECT al1.COL1 FROM (SELECT t1.COL1, t2.* FROM T1 t1 JOIN T2 t2) al1
898                                    // where al1.COL1 should resolve to T1.COL1 (explicit column), not T2.COL1 (from star)
899                                    boolean resolvedToOtherPhysicalTable = false;
900                                    for (TObjectName otherCol : allColumnReferences) {
901                                        if (otherCol == null || otherCol == col) continue;
902                                        String otherColName = otherCol.getColumnNameOnly();
903                                        if (otherColName != null && otherColName.equalsIgnoreCase(justColumnName)) {
904                                            // Get the final physical table through ColumnSource or sourceTable
905                                            TTable otherFinalTable = null;
906                                            ColumnSource otherColSource = otherCol.getColumnSource();
907                                            if (otherColSource != null) {
908                                                otherFinalTable = otherColSource.getFinalTable();
909                                            }
910                                            // Fallback to sourceTable if ColumnSource doesn't have finalTable
911                                            if (otherFinalTable == null) {
912                                                otherFinalTable = otherCol.getSourceTable();
913                                            }
914
915                                            if (otherFinalTable != null && otherFinalTable != sourceTable) {
916                                                // Check if the other source is a physical table (not CTE/subquery)
917                                                if (otherFinalTable.getTableType() != ETableSource.subquery &&
918                                                    otherFinalTable.getTableType() != ETableSource.openquery &&
919                                                    otherFinalTable.getTableType() != ETableSource.join &&
920                                                    !otherFinalTable.isCTEName()) {
921                                                    // Resolved to a different physical table - skip
922                                                    resolvedToOtherPhysicalTable = true;
923                                                    break;
924                                                }
925                                            }
926                                        }
927                                    }
928
929                                    if (!resolvedToOtherPhysicalTable) {
930                                        fields.add(tablePrefix + "." + normalizeColumnName(justColumnName));
931                                    }
932                                }
933                            }
934                        }
935                    }
936                    continue; // Star column fully handled
937                }
938            }
939
940            // Use ColumnSource to determine final table (if available)
941            ColumnSource source = col.getColumnSource();
942            if (source != null) {
943                // Check if the resolution is ambiguous - if so, don't use getFinalTable()
944                // because getColumnSource() returns the first candidate but we shouldn't pick one
945                gudusoft.gsqlparser.resolver2.model.ResolutionResult colResolution = col.getResolution();
946                boolean isResolutionAmbiguous = colResolution != null && colResolution.isAmbiguous();
947
948                // For columns from UNION queries, get all tables and add an entry for each
949                java.util.List<TTable> allFinalTables = source.getAllFinalTables();
950                if (allFinalTables != null && allFinalTables.size() > 1) {
951                    // Multiple tables (UNION query) - add entry for each table
952                    for (TTable unionTable : allFinalTables) {
953                        if (unionTable == null) continue;
954                        if (unionTable.getTableType() == ETableSource.subquery) continue;
955                        if (unionTable.getTableType() == ETableSource.openquery) continue;
956                        if (unionTable.getTableType() == ETableSource.join) continue;
957                        if (unionTable.isCTEName() && !showCTE) continue;
958                        if (shouldSkipTableForPhysicalFilter(unionTable)) continue;
959
960                        String tablePrefix = getFieldTablePrefix(unionTable);
961                        if (tablePrefix != null) {
962                            String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
963                            String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : "";
964                            fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr);
965                        }
966                    }
967                    continue; // Already handled this column for all tables
968                }
969
970                TTable finalTable = source.getFinalTable();
971                // Only use finalTable if the resolution is NOT ambiguous
972                // For ambiguous resolutions, let the orphan handling deal with it
973                if (finalTable != null && !isResolutionAmbiguous) {
974                    sourceTable = finalTable;
975                } else if (sourceTable == null && !source.isAmbiguous() && !isResolutionAmbiguous) {
976                    // ColumnSource exists but getFinalTable() is null AND sourceTable not set
977                    // AND not an ambiguous multi-table case
978                    // Check if we have candidate tables (e.g., from UNION branches)
979                    // If so, don't skip - let the orphan handling output all candidates
980                    java.util.List<TTable> candidates = source.getCandidateTables();
981                    if (candidates == null || candidates.isEmpty()) {
982                        // No candidates either - this is truly a calculated expression
983                        // Skip these - they don't trace to physical tables
984                        continue;
985                    }
986                    // Has candidate tables - fall through to orphan handling which will output them
987                }
988                // If getFinalTable() is null but sourceTable is set (e.g., UPDATE SET clause),
989                // use sourceTable as fallback
990            }
991            // If ColumnSource is null, fall back to col.getSourceTable()
992            // This handles INSERT columns, direct star columns, and other cases
993            // where the resolver doesn't set ColumnSource but the parser did resolve sourceTable
994
995            // Handle orphan columns (no definitive source table)
996            // Also handle columns that point to a subquery but couldn't be traced to a physical table
997            boolean isOrphan = (sourceTable == null);
998            boolean isUnresolvedSubqueryColumn = false;
999
1000            // Check if resolution or ColumnSource indicates ambiguity (multiple candidate tables)
1001            // Note: col.getResolution().isAmbiguous() checks the resolution result
1002            // source.isAmbiguous() checks if the ColumnSource has multiple candidate tables
1003            // For ambiguous resolutions, getColumnSource() returns the first candidate which won't have isAmbiguous()=true
1004            gudusoft.gsqlparser.resolver2.model.ResolutionResult resolvedResult = col.getResolution();
1005            if ((resolvedResult != null && resolvedResult.isAmbiguous()) ||
1006                (source != null && source.isAmbiguous())) {
1007                isUnresolvedSubqueryColumn = true;
1008            } else if (sourceTable != null && sourceTable.getTableType() == ETableSource.subquery && source == null) {
1009                // Check if this column is explicitly defined in the subquery's SELECT list
1010                // If so, it's a calculated/aliased column - skip it
1011                // If not, it's through an ambiguous star - mark as "missed"
1012                TSelectSqlStatement subquery = sourceTable.getSubquery();
1013                if (subquery != null && subquery.getResultColumnList() != null) {
1014                    boolean foundInSubquery = false;
1015                    boolean hasAmbiguousStar = false;
1016                    int tableCount = 0;
1017
1018                    // Count tables in FROM clause
1019                    if (subquery.tables != null) {
1020                        tableCount = subquery.tables.size();
1021                    }
1022
1023                    TResultColumnList resultCols = subquery.getResultColumnList();
1024                    for (int i = 0; i < resultCols.size(); i++) {
1025                        TResultColumn rc = resultCols.getResultColumn(i);
1026                        if (rc == null) continue;
1027
1028                        String rcStr = rc.toString().trim();
1029                        // Check if it's an unqualified star with multiple tables
1030                        if (rcStr.equals("*") && tableCount > 1) {
1031                            hasAmbiguousStar = true;
1032                            continue;
1033                        }
1034
1035                        // Check for explicit column/alias name
1036                        String rcName = null;
1037                        if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) {
1038                            rcName = rc.getAliasClause().getAliasName().toString();
1039                        } else if (rc.getExpr() != null &&
1040                                   rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t &&
1041                                   rc.getExpr().getObjectOperand() != null) {
1042                            rcName = rc.getExpr().getObjectOperand().getColumnNameOnly();
1043                        }
1044
1045                        if (rcName != null && nameMatcher.matches(rcName, columnName)) {
1046                            foundInSubquery = true;
1047                            break;
1048                        }
1049                    }
1050
1051                    // Column is unresolved only if it's NOT found in explicit columns AND there's an ambiguous star
1052                    isUnresolvedSubqueryColumn = !foundInSubquery && hasAmbiguousStar;
1053                }
1054            }
1055
1056            if (isOrphan || isUnresolvedSubqueryColumn) {
1057                // Check for candidate tables (e.g., from UNION branches)
1058                TTableList candidates = col.getCandidateTables();
1059                boolean hasCandidates = candidates != null && candidates.size() > 0;
1060
1061                // If we have multiple candidate tables from UNION/CTE propagation, output all of them
1062                // For regular ambiguous columns (not from UNION), fall through to "missed" handling
1063                if (hasCandidates && candidates.size() > 1 && sourceTable == null && col.isCandidatesFromUnion()) {
1064                    String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1065                    String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : "";
1066                    for (int ci = 0; ci < candidates.size(); ci++) {
1067                        TTable candidateTable = candidates.getTable(ci);
1068                        if (candidateTable != null) {
1069                            // Filter out tables that shouldn't be reported
1070                            if (candidateTable.getTableType() == ETableSource.subquery) continue;
1071                            if (candidateTable.getTableType() == ETableSource.openquery) continue;
1072                            if (candidateTable.getTableType() == ETableSource.join) continue;
1073                            if (candidateTable.isCTEName() && !showCTE) continue;
1074                            if (shouldSkipTableForPhysicalFilter(candidateTable)) continue;
1075
1076                            String tablePrefix = getFieldTablePrefix(candidateTable);
1077                            if (tablePrefix != null) {
1078                                fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr);
1079                            }
1080                        }
1081                    }
1082                    continue; // Already handled all candidates
1083                }
1084
1085                // Orphan column handling:
1086                // 1. Has candidates AND linkOrphanColumnToFirstTable=true → use first candidate table
1087                // 2. No candidates AND linkOrphanColumnToFirstTable=true → use statement's first physical table
1088                // 3. linkOrphanColumnToFirstTable=false → output missed.column
1089                // 4. No table available at all → output missed.column
1090                // Note: onlyPhysicalTables does NOT affect orphan column output
1091                if (sourceTable == null) {
1092                    // Check if the column resolved to a CTE - if so, skip it when showCTE=false
1093                    if (source != null) {
1094                        TTable finalTable = source.getFinalTable();
1095                        if (finalTable != null && finalTable.isCTEName() && !showCTE) {
1096                            continue; // Skip CTE columns when not showing CTEs
1097                        }
1098                    }
1099
1100                    if (hasCandidates && linkOrphanColumnToFirstTable) {
1101                        // Has candidates and linkOrphanColumnToFirstTable=true → use first candidate
1102                        sourceTable = candidates.getTable(0);
1103                    } else if (linkOrphanColumnToFirstTable) {
1104                        // No candidates but linkOrphanColumnToFirstTable=true
1105                        // → try statement's first physical table (matching TGetTableColumn behavior)
1106                        TCustomSqlStatement ownStmt = col.getOwnStmt();
1107                        // If ownStmt is null, try to find containing statement from AST
1108                        if (ownStmt == null) {
1109                            ownStmt = findContainingStatement(col);
1110                        }
1111                        if (ownStmt != null) {
1112                            TTable firstPhysicalTable = ownStmt.getFirstPhysicalTable();
1113                            if (firstPhysicalTable != null) {
1114                                sourceTable = firstPhysicalTable;
1115                            }
1116                        }
1117                        if (sourceTable == null) {
1118                            // Still no table → output as missed
1119                            String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1120                            String positionStr = getColumnPositionStr(col);
1121                            fields.add("missed." + columnName + positionStr + datatypeStr);
1122                            continue;
1123                        }
1124                    } else {
1125                        // linkOrphanColumnToFirstTable=false → output as missed
1126                        String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1127                        String positionStr = getColumnPositionStr(col);
1128                        fields.add("missed." + columnName + positionStr + datatypeStr);
1129                        continue;
1130                    }
1131                }
1132
1133                // For unresolved subquery columns (ambiguous), output as missed if linkOrphanColumnToFirstTable is false
1134                if (isUnresolvedSubqueryColumn && !linkOrphanColumnToFirstTable) {
1135                    String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1136                    String positionStr = getColumnPositionStr(col);
1137                    fields.add("missed." + columnName + positionStr + datatypeStr);
1138                    continue;
1139                }
1140            }
1141
1142            // Filter out tables that shouldn't be reported
1143            if (sourceTable.getTableType() == ETableSource.subquery) continue;
1144            if (sourceTable.getTableType() == ETableSource.openquery) continue;
1145            if (sourceTable.getTableType() == ETableSource.join) continue;
1146            if (sourceTable.isCTEName() && !showCTE) continue;
1147
1148            // Skip non-physical tables (record variables, etc.) if onlyPhysicalTables is true
1149            if (shouldSkipTableForPhysicalFilter(sourceTable)) continue;
1150
1151            // Skip star columns for PIVOT tables - individual columns are output via PivotNamespace
1152            if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.pivoted_table) continue;
1153
1154            // Calculate table prefix on-the-fly (avoids pre-building map via AST traversal)
1155            String tablePrefix = getFieldTablePrefix(sourceTable);
1156            if (tablePrefix != null) {
1157                String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1158                String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : "";
1159                fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr);
1160
1161                // For star columns on UNNEST tables, expand
1162                if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.unnest) {
1163                    expandUnnestStarColumns(sourceTable, tablePrefix, fields);
1164                }
1165            }
1166        }
1167
1168        // NOTE: TD_UNPIVOT columns are now collected by ScopeBuilder.processTDUnpivotTable()
1169        // which adds them to allColumnReferences. No need for fallback here.
1170    }
1171
1172    /**
1173     * Get the display name for a column, with vendor-specific handling.
1174     *
1175     * For STRUCT field access (BigQuery/Snowflake/SparkSQL), this uses the
1176     * ColumnSource.exposedName which contains the base column name, rather than
1177     * getColumnNameOnly() which returns the nested field name.
1178     */
1179    private String getColumnDisplayName(TObjectName col, boolean isSparkOrHive, boolean isSnowflake, boolean isSqlServer) {
1180        String colString = col.toString();
1181        String colNameOnly = col.getColumnNameOnly();
1182
1183        // For STRUCT field access (detected by evidence marker), use ColumnSource.exposedName
1184        // which contains the base column name (e.g., "customer" for "customer.customer_id")
1185        ColumnSource columnSource = col.getColumnSource();
1186        if (columnSource != null && "struct_field_access".equals(columnSource.getEvidence())) {
1187            String exposedName = columnSource.getExposedName();
1188            if (exposedName != null && !exposedName.isEmpty()) {
1189                colNameOnly = exposedName;
1190            }
1191        }
1192
1193        if (isSqlServer && colNameOnly != null &&
1194            colNameOnly.startsWith("[") && colNameOnly.endsWith("]") &&
1195            colNameOnly.length() > 2) {
1196            // Normalize SQL Server bracketed identifiers to avoid duplicates like [col3] and col3
1197            // The TreeSet uses case-insensitive ordering but doesn't handle bracket normalization
1198            return normalizeColumnName(colNameOnly);
1199        } else if (isSparkOrHive && colString != null && colNameOnly != null &&
1200            !colString.contains(".") &&
1201            colString.startsWith("`") && colString.endsWith("`") &&
1202            colString.length() > 2) {
1203            // Use DisplayNameNormalizer to handle backtick-quoted identifiers
1204            // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them)
1205            return normalizeColumnName(colString);
1206        } else if (isSnowflake && colString != null && colNameOnly != null &&
1207            !colString.contains(".") &&
1208            colString.startsWith("\"") && colString.endsWith("\"") &&
1209            colString.length() > 2) {
1210            // Use DisplayNameNormalizer to handle double-quoted identifiers
1211            // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them)
1212            return normalizeColumnName(colString);
1213        } else if (isSnowflake && colString != null && colString.startsWith("$")) {
1214            // Snowflake stage file positional column (e.g., $1, $1:apMac)
1215            // Extract JSON path if present
1216            int colonIndex = colString.indexOf(':');
1217            if (colonIndex > 0) {
1218                // Has JSON path - extract it (e.g., "$1:apMac" -> ":apMac")
1219                String jsonPath = colString.substring(colonIndex);
1220                return jsonPath;
1221            } else {
1222                // Simple positional column (e.g., "$1") - return as-is
1223                return colString;
1224            }
1225        } else {
1226            return normalizeColumnName(colNameOnly);
1227        }
1228    }
1229
1230    /**
1231     * Check if a star column should be included in output.
1232     */
1233    private boolean shouldIncludeStarColumn(TObjectName col, TTable sourceTable) {
1234        // Skip if the star's source table reference is a CTE
1235        if (sourceTable != null && sourceTable.isCTEName()) return false;
1236
1237        // Skip if the star was resolved through a CTE namespace
1238        ColumnSource starSource = col.getColumnSource();
1239        if (starSource != null && starSource.getSourceNamespace() instanceof CTENamespace) return false;
1240
1241        // Skip if the star's source table is a PIVOT/UNPIVOT table
1242        // PIVOT columns are output individually via PivotNamespace, not as table.*
1243        if (sourceTable != null && sourceTable.getTableType() == ETableSource.pivoted_table) return false;
1244
1245        // Find the SELECT statement containing this star column
1246        TParseTreeNode parent = col.getParentObjectName();
1247        TSelectSqlStatement starSelectStmt = null;
1248        while (parent != null) {
1249            if (parent instanceof TSelectSqlStatement) {
1250                starSelectStmt = (TSelectSqlStatement) parent;
1251                break;
1252            }
1253            parent = parent.getParentObjectName();
1254        }
1255
1256        if (starSelectStmt != null) {
1257            // Skip if in a nested procedural statement (e.g., inside BEGIN/END block)
1258            if (starSelectStmt.getParentStmt() != null) return false;
1259
1260            // Check if sourceTable is in the star's SELECT statement
1261            // This ensures we only report stars that trace to tables in their own SELECT
1262            if (sourceTable != null) {
1263                boolean foundInSameStmt = false;
1264                for (int ti = 0; ti < starSelectStmt.tables.size(); ti++) {
1265                    if (starSelectStmt.tables.getTable(ti) == sourceTable) {
1266                        foundInSameStmt = true;
1267                        break;
1268                    }
1269                }
1270                if (!foundInSameStmt) return false;
1271            }
1272        }
1273
1274        return true;
1275    }
1276
1277    /**
1278     * Check if a star column is unqualified (SELECT * vs SELECT t.*)
1279     */
1280    private boolean isUnqualifiedStar(TObjectName col) {
1281        // Check if the column has a table prefix
1282        String colStr = col.toString();
1283        // Unqualified star is just "*" without any prefix
1284        return "*".equals(colStr.trim());
1285    }
1286
1287    /**
1288     * Find the statement that directly contains the given table in its tables list.
1289     * Returns the most specific (innermost) statement that has the table.
1290     */
1291    private TCustomSqlStatement findStatementWithTable(TCustomSqlStatement stmt, TTable targetTable) {
1292        if (stmt == null) return null;
1293
1294        // Check if this statement directly contains the table
1295        if (stmt.tables != null) {
1296            for (int i = 0; i < stmt.tables.size(); i++) {
1297                if (stmt.tables.getTable(i) == targetTable) {
1298                    return stmt;
1299                }
1300            }
1301        }
1302
1303        // Check nested statements (subqueries, etc.)
1304        if (stmt.getStatements() != null) {
1305            for (int i = 0; i < stmt.getStatements().size(); i++) {
1306                Object nested = stmt.getStatements().get(i);
1307                if (nested instanceof TCustomSqlStatement) {
1308                    TCustomSqlStatement found = findStatementWithTable((TCustomSqlStatement) nested, targetTable);
1309                    if (found != null) return found;
1310                }
1311            }
1312        }
1313
1314        // For SELECT statements, check left/right for UNION queries
1315        if (stmt instanceof TSelectSqlStatement) {
1316            TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt;
1317            if (selectStmt.isCombinedQuery()) {
1318                TCustomSqlStatement found = findStatementWithTable(selectStmt.getLeftStmt(), targetTable);
1319                if (found != null) return found;
1320                found = findStatementWithTable(selectStmt.getRightStmt(), targetTable);
1321                if (found != null) return found;
1322            }
1323        }
1324
1325        return null;
1326    }
1327
1328    /**
1329     * Expand unqualified star column to all tables in the FROM clause
1330     */
1331    private void expandStarToAllTables(TObjectName col, Set<String> fields) {
1332        // Find the containing statement - try multiple approaches
1333        TCustomSqlStatement containingStmt = null;
1334
1335        // Approach 1 (PRIORITY): Find via TResultColumn parent
1336        // For star columns in SELECT list, traverse up to find the TResultColumn,
1337        // then get the containing statement from the result column list's parent.
1338        // This is more reliable than parent chain traversal because the star column's
1339        // TObjectName parent may have been modified during resolution.
1340        TParseTreeNode parent = col.getParentObjectName();
1341        while (parent != null) {
1342            if (parent instanceof gudusoft.gsqlparser.nodes.TResultColumn) {
1343                // Found the result column - now get its parent statement
1344                gudusoft.gsqlparser.nodes.TResultColumn rc = (gudusoft.gsqlparser.nodes.TResultColumn) parent;
1345                TParseTreeNode rcParent = rc.getParentObjectName();
1346                while (rcParent != null) {
1347                    if (rcParent instanceof TCustomSqlStatement) {
1348                        containingStmt = (TCustomSqlStatement) rcParent;
1349                        break;
1350                    }
1351                    rcParent = rcParent.getParentObjectName();
1352                }
1353                break;
1354            }
1355            if (parent instanceof TCustomSqlStatement) {
1356                containingStmt = (TCustomSqlStatement) parent;
1357                break;
1358            }
1359            parent = parent.getParentObjectName();
1360        }
1361
1362        // Approach 2 (FALLBACK): Try to find via source table's statement
1363        if (containingStmt == null) {
1364            TTable sourceTable = col.getSourceTable();
1365            if (sourceTable != null && sourceTable.getGsqlparser() != null) {
1366                // Find the statement containing this source table
1367                for (int i = 0; i < statements.size(); i++) {
1368                    TCustomSqlStatement stmt = statements.get(i);
1369                    containingStmt = findStatementWithTable(stmt, sourceTable);
1370                    if (containingStmt != null) break;
1371                }
1372            }
1373        }
1374
1375        if (containingStmt == null || containingStmt.tables == null) {
1376            // Fallback: use first statement if available
1377            if (statements != null && statements.size() > 0) {
1378                TCustomSqlStatement firstStmt = statements.get(0);
1379                if (firstStmt != null && firstStmt.tables != null) {
1380                    containingStmt = firstStmt;
1381                }
1382            }
1383            if (containingStmt == null || containingStmt.tables == null) {
1384                return;
1385            }
1386        }
1387
1388        // Add star column for each physical table in the FROM clause
1389        for (int i = 0; i < containingStmt.tables.size(); i++) {
1390            TTable table = containingStmt.tables.getTable(i);
1391            if (table == null) continue;
1392
1393            // Skip non-physical table types
1394            if (table.getTableType() == ETableSource.subquery) continue;
1395            if (table.getTableType() == ETableSource.openquery) continue;
1396            if (table.getTableType() == ETableSource.join) continue;
1397            if (table.isCTEName() && !showCTE) continue;
1398
1399            // Skip implicit lateral derived tables - they shouldn't have star columns
1400            if (table.getEffectType() == ETableEffectType.tetImplicitLateralDerivedTable) continue;
1401
1402            // Skip non-physical tables if onlyPhysicalTables is true
1403            // Note: PIVOT/UNPIVOT and UNNEST column expansion is now handled by the core resolver
1404            // This is a fallback path when the core resolver hasn't expanded the star
1405            if (shouldSkipTableForPhysicalFilter(table)) continue;
1406
1407            String tablePrefix = getFieldTablePrefix(table);
1408            if (tablePrefix != null) {
1409                // For UNNEST tables, add star if requested, then expand individual columns
1410                if (table.getTableType() == ETableSource.unnest) {
1411                    if (listStarColumn) {
1412                        fields.add(tablePrefix + ".*");
1413                    }
1414                    expandUnnestStarColumns(table, tablePrefix, fields);
1415                } else if (table.getTableType() == ETableSource.pivoted_table) {
1416                    // For PIVOT/UNPIVOT tables, output the generated columns from PivotNamespace
1417                    // These are virtual tables so we don't output table.*, only individual columns
1418                    if (buildResult != null) {
1419                        INamespace namespace = buildResult.getNamespaceForTable(table);
1420                        if (namespace != null) {
1421                            Map<String, ColumnSource> columnSources = namespace.getAllColumnSources();
1422                            if (columnSources != null && !columnSources.isEmpty()) {
1423                                for (String columnName : columnSources.keySet()) {
1424                                    if (columnName != null && !columnName.isEmpty()) {
1425                                        fields.add(tablePrefix + "." + normalizeColumnName(columnName));
1426                                    }
1427                                }
1428                            }
1429                        }
1430                    }
1431                } else {
1432                    // For regular tables, add star column if listStarColumn is true
1433                    if (listStarColumn) {
1434                        fields.add(tablePrefix + ".*");
1435                    }
1436                }
1437            }
1438        }
1439    }
1440
1441    private String getFieldTablePrefix(TTable table) {
1442        if (table.getTableType() == ETableSource.subquery) {
1443            return "(subquery, alias:" + table.getAliasName() + ")";
1444        } else if (table.getTableType() == ETableSource.unnest) {
1445            String alias = table.getAliasName();
1446            return "(unnest-table:" + (alias != null ? alias : "") + ")";
1447        } else if (table.getTableType() == ETableSource.pivoted_table) {
1448            // Check if this is UNPIVOT or PIVOT
1449            boolean isUnpivot = false;
1450            // First try to get from TPivotClause
1451            if (table.getPivotedTable() != null && table.getPivotedTable().getPivotClause() != null) {
1452                isUnpivot = (table.getPivotedTable().getPivotClause().getType() == TPivotClause.unpivot);
1453            } else {
1454                // Fallback: check if the table alias suggests UNPIVOT
1455                // When no explicit alias is provided, TPivotClause.doParse() uses "unpivot_alias"
1456                String tableName = table.getTableName() != null ? table.getTableName().toString() : "";
1457                String aliasName = table.getAliasName() != null ? table.getAliasName() : "";
1458                isUnpivot = tableName.startsWith("unpivot_") || aliasName.startsWith("unpivot_");
1459            }
1460            String prefix = isUnpivot ? "(unpivot-table:" : "(pivot-table:";
1461            return prefix + normalizeTableName(table.getTableName()) + ")";
1462        } else if (table.getTableType() == ETableSource.function) {
1463            return "(table-valued function:" + normalizeTableName(table.getTableName()) + ")";
1464        } else if (table.getTableType() == ETableSource.lateralView) {
1465            return "(lateral_view:" + normalizeTableName(table.getTableName()) + ")";
1466        } else if (table.isCTEName() && showCTE) {
1467            String tableName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
1468            return tableName + "(CTE)";
1469        } else {
1470            return table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
1471        }
1472    }
1473
1474    /**
1475     * Normalize a table name using the configured name matcher.
1476     *
1477     * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific
1478     * qualified name normalization (handles case folding and multi-part identifiers).
1479     * Falls back to manual parsing and quote stripping if no matcher is configured.</p>
1480     *
1481     * @param tableName The TObjectName representing the table name
1482     * @return The normalized table name
1483     */
1484    private String normalizeTableName(TObjectName tableName) {
1485        if (tableName == null) return "";
1486        String fullName = tableName.toString();
1487        if (fullName == null || fullName.isEmpty()) return "";
1488
1489        // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly
1490        if (displayNameNormalizer != null) {
1491            return displayNameNormalizer.normalizeQualifiedName(fullName);
1492        }
1493
1494        // Fallback when no normalizer is available
1495        // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding)
1496        if (nameMatcher instanceof VendorNameMatcher) {
1497            return ((VendorNameMatcher) nameMatcher).normalizeQualifiedName(fullName, ESQLDataObjectType.dotTable);
1498        }
1499
1500        // Fallback: manual parsing with quote stripping
1501        return normalizeTableNameFallback(fullName);
1502    }
1503
1504    /**
1505     * Fallback table name normalization when no vendor matcher is configured.
1506     * Handles both fully-quoted identifiers and multi-part identifiers.
1507     */
1508    private String normalizeTableNameFallback(String fullName) {
1509        // First check if there are any dots outside of quotes (multi-part identifier)
1510        boolean hasUnquotedDot = false;
1511        char quoteChar = 0;
1512        for (int i = 0; i < fullName.length(); i++) {
1513            char c = fullName.charAt(i);
1514            if (quoteChar == 0) {
1515                if (c == '`' || c == '"' || c == '[') {
1516                    quoteChar = (c == '[') ? ']' : c;
1517                } else if (c == '.') {
1518                    hasUnquotedDot = true;
1519                    break;
1520                }
1521            } else {
1522                if (c == quoteChar) {
1523                    quoteChar = 0;
1524                }
1525            }
1526        }
1527
1528        // If no unquoted dots, check if entire string is quoted
1529        if (!hasUnquotedDot) {
1530            return stripQuotes(fullName);
1531        }
1532
1533        // Otherwise, split by dot to handle multi-part identifiers like `schema`.`table`
1534        // But we need to be careful not to split inside quotes
1535        StringBuilder result = new StringBuilder();
1536        StringBuilder currentPart = new StringBuilder();
1537        quoteChar = 0;
1538
1539        for (int i = 0; i < fullName.length(); i++) {
1540            char c = fullName.charAt(i);
1541
1542            if (quoteChar == 0) {
1543                // Not inside a quote
1544                if (c == '`' || c == '"' || c == '[') {
1545                    quoteChar = (c == '[') ? ']' : c;
1546                    currentPart.append(c);
1547                } else if (c == '.') {
1548                    // End of a part
1549                    if (result.length() > 0) result.append(".");
1550                    result.append(stripQuotes(currentPart.toString()));
1551                    currentPart.setLength(0);
1552                } else {
1553                    currentPart.append(c);
1554                }
1555            } else {
1556                // Inside a quote
1557                currentPart.append(c);
1558                if (c == quoteChar) {
1559                    quoteChar = 0;
1560                }
1561            }
1562        }
1563
1564        // Don't forget the last part
1565        if (currentPart.length() > 0) {
1566            if (result.length() > 0) result.append(".");
1567            result.append(stripQuotes(currentPart.toString()));
1568        }
1569
1570        return result.toString();
1571    }
1572
1573    private void collectConstraintColumns(TCreateTableSqlStatement createStmt, Set<String> fields) {
1574        if (createStmt == null || createStmt.getTargetTable() == null) return;
1575
1576        String tableName = createStmt.getTargetTable().getTableName() != null
1577                ? normalizeTableName(createStmt.getTargetTable().getTableName()) : "";
1578
1579        // Collect columns from table-level constraints
1580        if (createStmt.getTableConstraints() != null) {
1581            for (int i = 0; i < createStmt.getTableConstraints().size(); i++) {
1582                TConstraint constraint = createStmt.getTableConstraints().getConstraint(i);
1583                collectColumnsFromConstraint(constraint, tableName, fields);
1584            }
1585        }
1586
1587        // Collect columns from column-level constraints (inline constraints)
1588        if (createStmt.getColumnList() != null) {
1589            for (int i = 0; i < createStmt.getColumnList().size(); i++) {
1590                TColumnDefinition colDef = createStmt.getColumnList().getColumn(i);
1591                if (colDef != null && colDef.getConstraints() != null) {
1592                    for (int j = 0; j < colDef.getConstraints().size(); j++) {
1593                        TConstraint constraint = colDef.getConstraints().getConstraint(j);
1594                        collectColumnsFromConstraint(constraint, tableName, fields);
1595                    }
1596                }
1597            }
1598        }
1599    }
1600
1601    private void collectAlterTableConstraintColumns(TAlterTableStatement alterStmt, Set<String> fields) {
1602        if (alterStmt == null) return;
1603
1604        // Get table name from getTableName() first, fall back to getTargetTable()
1605        String tableName = "";
1606        if (alterStmt.getTableName() != null) {
1607            tableName = normalizeTableName(alterStmt.getTableName());
1608        } else if (alterStmt.getTargetTable() != null && alterStmt.getTargetTable().getTableName() != null) {
1609            tableName = normalizeTableName(alterStmt.getTargetTable().getTableName());
1610        }
1611        if (tableName.isEmpty()) return;
1612
1613        // Collect from ALTER TABLE options (ADD CONSTRAINT, etc.)
1614        if (alterStmt.getAlterTableOptionList() != null) {
1615            for (int i = 0; i < alterStmt.getAlterTableOptionList().size(); i++) {
1616                TAlterTableOption option = alterStmt.getAlterTableOptionList().getAlterTableOption(i);
1617                if (option == null) continue;
1618
1619                // Handle constraints
1620                if (option.getConstraintList() != null) {
1621                    for (int j = 0; j < option.getConstraintList().size(); j++) {
1622                        TConstraint constraint = option.getConstraintList().getConstraint(j);
1623                        collectColumnsFromConstraint(constraint, tableName, fields);
1624                    }
1625                }
1626
1627                // Handle index columns (for AddConstraintPK, AddConstraintUnique, AddConstraintFK)
1628                // These store columns as TPTNodeList<TColumnWithSortOrder>
1629                if (option.getIndexCols() != null) {
1630                    for (int j = 0; j < option.getIndexCols().size(); j++) {
1631                        TColumnWithSortOrder colWithSort = option.getIndexCols().getElement(j);
1632                        if (colWithSort != null && colWithSort.getColumnName() != null) {
1633                            String colName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly());
1634                            if (colName != null && !colName.isEmpty()) {
1635                                fields.add(tableName + "." + colName);
1636                            }
1637                        }
1638                    }
1639                }
1640
1641                // Note: We do NOT collect columns from ALTER COLUMN, DROP COLUMN, RENAME COLUMN,
1642                // CHANGE COLUMN operations. These are DDL targets (columns being modified),
1643                // not column references. The old resolver does not collect these either.
1644
1645                // Handle FOREIGN KEY references (for ADD CONSTRAINT FK)
1646                if (option.getReferencedObjectName() != null && option.getReferencedColumnList() != null) {
1647                    String refTableName = normalizeTableName(option.getReferencedObjectName());
1648                    for (int j = 0; j < option.getReferencedColumnList().size(); j++) {
1649                        TObjectName colObj = option.getReferencedColumnList().getObjectName(j);
1650                        if (colObj != null) {
1651                            String colName = normalizeColumnName(colObj.getColumnNameOnly());
1652                            if (colName != null && !colName.isEmpty()) {
1653                                fields.add(refTableName + "." + colName);
1654                            }
1655                        }
1656                    }
1657                }
1658            }
1659        }
1660    }
1661
1662    private void collectColumnsFromConstraint(TConstraint constraint, String tableName, Set<String> fields) {
1663        if (constraint == null) return;
1664
1665        // Collect columns from constraint's column list (TPTNodeList<TColumnWithSortOrder>)
1666        if (constraint.getColumnList() != null) {
1667            for (int i = 0; i < constraint.getColumnList().size(); i++) {
1668                TColumnWithSortOrder colWithSort = constraint.getColumnList().getElement(i);
1669                if (colWithSort != null && colWithSort.getColumnName() != null) {
1670                    String columnName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly());
1671                    if (columnName != null && !columnName.isEmpty()) {
1672                        // Constraint columns don't include datatype
1673                        // Get location from the column if available
1674                        String locationStr = "";
1675                        if (showColumnLocation && colWithSort.getColumnName().getLocation() != null) {
1676                            locationStr = "(" + colWithSort.getColumnName().getLocation() + ")";
1677                        }
1678                        fields.add(tableName + "." + columnName + locationStr);
1679                    }
1680                }
1681            }
1682        }
1683
1684        // Collect columns from FOREIGN KEY REFERENCES
1685        if (constraint.getReferencedColumnList() != null && constraint.getReferencedObject() != null) {
1686            String refTableName = normalizeTableName(constraint.getReferencedObject());
1687            for (int i = 0; i < constraint.getReferencedColumnList().size(); i++) {
1688                TObjectName colName = constraint.getReferencedColumnList().getObjectName(i);
1689                if (colName != null) {
1690                    String columnName = normalizeColumnName(colName.getColumnNameOnly());
1691                    if (columnName != null && !columnName.isEmpty()) {
1692                        // Use location already set during parsing
1693                        String locationStr = "";
1694                        if (showColumnLocation && colName.getLocation() != null) {
1695                            locationStr = "(" + colName.getLocation() + ")";
1696                        }
1697                        fields.add(refTableName + "." + columnName + locationStr);
1698                    }
1699                }
1700            }
1701        }
1702    }
1703
1704    private void expandUnnestStarColumns(TTable unnestTable, String tablePrefix, Set<String> fields) {
1705        if (unnestTable == null || unnestTable.getTableType() != ETableSource.unnest) {
1706            return;
1707        }
1708
1709        // Get the implicit column name (alias or 'value')
1710        String alias = unnestTable.getAliasName();
1711        String implicitColumnName = (alias != null && !alias.isEmpty()) ? alias : "value";
1712        fields.add(tablePrefix + "." + implicitColumnName);
1713
1714        // Check for WITH OFFSET column
1715        TUnnestClause unnestClause = unnestTable.getUnnestClause();
1716        if (unnestClause != null && unnestClause.getWithOffset() != null) {
1717            String offsetColumnName;
1718            if (unnestClause.getWithOffsetAlais() != null &&
1719                unnestClause.getWithOffsetAlais().getAliasName() != null) {
1720                offsetColumnName = unnestClause.getWithOffsetAlais().getAliasName().toString();
1721            } else {
1722                offsetColumnName = "offset";
1723            }
1724            fields.add(tablePrefix + "." + offsetColumnName);
1725        }
1726    }
1727
1728    /**
1729     * Normalize a column name using the configured name matcher.
1730     *
1731     * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific
1732     * normalization logic (handles case folding according to vendor rules).
1733     * Falls back to simple quote stripping if no matcher is configured.</p>
1734     *
1735     * @param columnName The raw column name which may include quotes
1736     * @return The normalized column name
1737     */
1738    private String normalizeColumnName(String columnName) {
1739        if (columnName == null || columnName.isEmpty()) return columnName;
1740
1741        // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly
1742        if (displayNameNormalizer != null) {
1743            return displayNameNormalizer.normalizeIdentifier(columnName);
1744        }
1745
1746        // Fallback when no normalizer is available
1747        // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding)
1748        if (nameMatcher instanceof VendorNameMatcher) {
1749            return ((VendorNameMatcher) nameMatcher).normalize(columnName, ESQLDataObjectType.dotColumn);
1750        }
1751
1752        // Use generic INameMatcher if available
1753        if (nameMatcher != null) {
1754            return nameMatcher.normalize(columnName);
1755        }
1756
1757        // Fallback: simple quote stripping
1758        return stripQuotes(columnName);
1759    }
1760
1761    /**
1762     * Strip surrounding quotes from an identifier.
1763     * This is a fallback when no name matcher is configured.
1764     *
1765     * @param name The identifier which may include quotes
1766     * @return The identifier without surrounding quotes
1767     */
1768    private String stripQuotes(String name) {
1769        if (name == null || name.isEmpty()) return name;
1770
1771        // Remove surrounding double quotes
1772        if (name.startsWith("\"") && name.endsWith("\"") && name.length() > 1) {
1773            return name.substring(1, name.length() - 1);
1774        }
1775        // Remove surrounding backticks (MySQL)
1776        if (name.startsWith("`") && name.endsWith("`") && name.length() > 1) {
1777            return name.substring(1, name.length() - 1);
1778        }
1779        // Remove surrounding square brackets (SQL Server)
1780        if (name.startsWith("[") && name.endsWith("]") && name.length() > 1) {
1781            return name.substring(1, name.length() - 1);
1782        }
1783        return name;
1784    }
1785
1786    private String getColumnDatatype(TObjectName col) {
1787        if (!showDatatype) return "";
1788
1789        if (col.getLinkedColumnDef() != null) {
1790            TTypeName datatype = col.getLinkedColumnDef().getDatatype();
1791            if (datatype != null) {
1792                String typeName = datatype.getDataTypeName();
1793                if (typeName != null && !typeName.isEmpty()) {
1794                    StringBuilder result = new StringBuilder(":" + typeName.toLowerCase());
1795                    // Add length if available (e.g., varchar:30, char:50)
1796                    if (datatype.getLength() != null) {
1797                        result.append(":").append(datatype.getLength().toString());
1798                    }
1799                    // Add precision if available (e.g., decimal:10)
1800                    else if (datatype.getPrecision() != null) {
1801                        result.append(":").append(datatype.getPrecision().toString());
1802                        // Add scale if available (e.g., decimal:10:2)
1803                        if (datatype.getScale() != null) {
1804                            result.append(":").append(datatype.getScale().toString());
1805                        }
1806                    }
1807                    // Add display length if available (e.g., int:11 for MySQL INT(11))
1808                    else if (datatype.getDisplayLength() != null) {
1809                        result.append(":").append(datatype.getDisplayLength().toString());
1810                    }
1811                    return result.toString();
1812                }
1813            }
1814        }
1815        return "";
1816    }
1817
1818    // ========== CTE Column Collection ==========
1819
1820    /**
1821     * Collect CTE columns for the Ctes: output section.
1822     * Prioritizes CTENamespace from resolver when available, falls back to AST traversal.
1823     */
1824    private void collectCTEColumns(Set<String> cteColumns) {
1825        if (buildResult == null) return;
1826
1827        // Get CTE namespaces from resolver
1828        Map<String, CTENamespace> cteNamespaces = buildResult.getCTENamespaces();
1829
1830        if (cteNamespaces != null && !cteNamespaces.isEmpty()) {
1831            // Preferred: Use CTENamespace to get columns
1832            for (Map.Entry<String, CTENamespace> entry : cteNamespaces.entrySet()) {
1833                String cteName = entry.getKey();
1834                CTENamespace ns = entry.getValue();
1835
1836                Map<String, ColumnSource> columnSources = ns.getAllColumnSources();
1837                if (columnSources != null) {
1838                    for (String colName : columnSources.keySet()) {
1839                        cteColumns.add(cteName + "." + colName);
1840                    }
1841                }
1842            }
1843        } else {
1844            // Fallback: Collect from AST when namespaces not available
1845            for (int i = 0; i < statements.size(); i++) {
1846                collectCTEColumnsFromAST(statements.get(i), cteColumns);
1847            }
1848        }
1849    }
1850
1851    /**
1852     * Fallback method to collect CTE columns from AST when CTENamespace is not available.
1853     */
1854    private void collectCTEColumnsFromAST(Object stmt, Set<String> cteColumns) {
1855        if (!(stmt instanceof TSelectSqlStatement)) {
1856            if (stmt instanceof TCustomSqlStatement) {
1857                TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt;
1858                for (int i = 0; i < customStmt.getStatements().size(); i++) {
1859                    collectCTEColumnsFromAST(customStmt.getStatements().get(i), cteColumns);
1860                }
1861            }
1862            return;
1863        }
1864
1865        TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt;
1866
1867        // Handle combined queries
1868        if (selectStmt.isCombinedQuery()) {
1869            collectCTEColumnsFromAST(selectStmt.getLeftStmt(), cteColumns);
1870            collectCTEColumnsFromAST(selectStmt.getRightStmt(), cteColumns);
1871            return;
1872        }
1873
1874        // Process CTEs in this statement
1875        if (selectStmt.getCteList() != null) {
1876            for (int i = 0; i < selectStmt.getCteList().size(); i++) {
1877                TCTE cte = selectStmt.getCteList().getCTE(i);
1878                String cteName = cte.getTableName().toString();
1879
1880                // Get columns from CTE definition
1881                if (cte.getColumnList() != null) {
1882                    for (int j = 0; j < cte.getColumnList().size(); j++) {
1883                        TObjectName colName = cte.getColumnList().getObjectName(j);
1884                        if (colName != null) {
1885                            cteColumns.add(cteName + "." + colName.getColumnNameOnly());
1886                        }
1887                    }
1888                } else if (cte.getSubquery() != null) {
1889                    // Infer columns from subquery result columns
1890                    TResultColumnList resultCols = cte.getSubquery().getResultColumnList();
1891                    if (resultCols != null) {
1892                        for (int j = 0; j < resultCols.size(); j++) {
1893                            TResultColumn rc = resultCols.getResultColumn(j);
1894                            String colName = inferColumnName(rc);
1895                            if (colName != null && !"*".equals(colName)) {
1896                                cteColumns.add(cteName + "." + colName);
1897                            }
1898                        }
1899                    }
1900                }
1901            }
1902        }
1903
1904        // Recursively process nested statements
1905        for (int i = 0; i < selectStmt.getStatements().size(); i++) {
1906            collectCTEColumnsFromAST(selectStmt.getStatements().get(i), cteColumns);
1907        }
1908    }
1909
1910    private String inferColumnName(TResultColumn rc) {
1911        if (rc == null) return null;
1912
1913        // Check for alias
1914        if (rc.getAliasClause() != null) {
1915            return rc.getAliasClause().toString();
1916        }
1917
1918        // Check for simple column reference
1919        if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) {
1920            return rc.getExpr().getObjectOperand().getColumnNameOnly();
1921        }
1922
1923        return null;
1924    }
1925
1926    // ========== Static Factory Methods ==========
1927
1928    /**
1929     * Create a formatter with default settings.
1930     */
1931    public static TSQLResolver2ResultFormatter create(TSQLResolver2 resolver) {
1932        return new TSQLResolver2ResultFormatter(resolver);
1933    }
1934
1935    /**
1936     * Create a formatter configured to show CTE tables and columns.
1937     */
1938    public static TSQLResolver2ResultFormatter createWithCTE(TSQLResolver2 resolver) {
1939        return new TSQLResolver2ResultFormatter(resolver).setShowCTE(true);
1940    }
1941
1942    /**
1943     * Find the containing SQL statement for a column reference by traversing up the AST.
1944     * This is used as a fallback when ownStmt is not set (e.g., for PL/SQL package constants).
1945     *
1946     * @param col The column reference
1947     * @return The containing statement, or null if not found
1948     */
1949    private TCustomSqlStatement findContainingStatement(TObjectName col) {
1950        if (col == null) return null;
1951
1952        TParseTreeNode parent = col.getParentObjectName();
1953        while (parent != null) {
1954            if (parent instanceof TCustomSqlStatement) {
1955                return (TCustomSqlStatement) parent;
1956            }
1957            parent = parent.getParentObjectName();
1958        }
1959        return null;
1960    }
1961
1962    /**
1963     * Get the position string for a column reference, using the actual column name part's position
1964     * for multi-part identifiers (e.g., for "sch.pkg.col", use the position of "col" not "sch").
1965     * This matches TGetTableColumn behavior.
1966     *
1967     * @param col The column reference
1968     * @return Position string in format "(line,column)"
1969     */
1970    private String getColumnPositionStr(TObjectName col) {
1971        if (col == null) {
1972            return "(0,0)";
1973        }
1974        // For multi-part names, use the partToken (column name part) position if available
1975        TSourceToken partToken = col.getPartToken();
1976        if (partToken != null) {
1977            return "(" + partToken.lineNo + "," + partToken.columnNo + ")";
1978        }
1979        // Fallback to the object name's position
1980        return "(" + col.getLineNo() + "," + col.getColumnNo() + ")";
1981    }
1982
1983    /**
1984     * Create a formatter configured to show datatypes.
1985     */
1986    public static TSQLResolver2ResultFormatter createWithDatatype(TSQLResolver2 resolver) {
1987        return new TSQLResolver2ResultFormatter(resolver).setShowDatatype(true);
1988    }
1989
1990    /**
1991     * Create a formatter configured to show CTE column definitions.
1992     */
1993    public static TSQLResolver2ResultFormatter createWithCTEColumns(TSQLResolver2 resolver) {
1994        return new TSQLResolver2ResultFormatter(resolver).setShowColumnsOfCTE(true);
1995    }
1996
1997    /**
1998     * Read the expected output from a file.
1999     * Utility method for tests to load expected results.
2000     */
2001    public static String getDesiredTablesColumns(String filePath) {
2002        try {
2003            return new String(java.nio.file.Files.readAllBytes(
2004                java.nio.file.Paths.get(filePath)), java.nio.charset.StandardCharsets.UTF_8);
2005        } catch (java.io.IOException e) {
2006            return "";
2007        }
2008    }
2009}