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 - follow left chain iteratively
755        if (customStmt instanceof TSelectSqlStatement) {
756            TSelectSqlStatement selectStmt = (TSelectSqlStatement) customStmt;
757            while (selectStmt.isCombinedQuery()) {
758                collectDDLFieldsFromStatement(selectStmt.getRightStmt(), fields);
759                selectStmt = selectStmt.getLeftStmt();
760            }
761            if (selectStmt != customStmt) {
762                collectDDLFieldsFromStatement(selectStmt, fields);
763                return;
764            }
765        }
766
767        // Handle CREATE TABLE - collect constraint columns only
768        // Note: CTAS target columns (tuple aliases, standard aliases, simple column refs)
769        // are now fully handled by ScopeBuilder.preVisit(TResultColumn) and included in
770        // allColumnReferences. They will be output via collectResolvedFieldsGlobal().
771        if (customStmt instanceof TCreateTableSqlStatement) {
772            TCreateTableSqlStatement createStmt = (TCreateTableSqlStatement) customStmt;
773            // Collect constraint columns (PRIMARY KEY, UNIQUE, FOREIGN KEY references)
774            collectConstraintColumns(createStmt, fields);
775        }
776
777        // Handle ALTER TABLE - collect constraint columns
778        if (customStmt instanceof TAlterTableStatement) {
779            TAlterTableStatement alterStmt = (TAlterTableStatement) customStmt;
780            collectAlterTableConstraintColumns(alterStmt, fields);
781        }
782
783        // Recurse for nested statements to find deeper DDLs (e.g. inside blocks)
784        for (int i = 0; i < customStmt.getStatements().size(); i++) {
785            Object nestedStmt = customStmt.getStatements().get(i);
786            if (nestedStmt instanceof TCustomSqlStatement) {
787                collectDDLFieldsFromStatement(nestedStmt, fields);
788            }
789        }
790    }
791
792    /**
793     * Optimized global field collection for all resolved columns.
794     * Iterates through all column references once globally, calculating table prefixes on-the-fly.
795     * This avoids the O(N*M) complexity of per-statement AST traversal for deep nesting.
796     */
797    private void collectResolvedFieldsGlobal(Set<String> fields) {
798        if (buildResult == null) return;
799
800        // Get vendor info once
801        TSQLResolverConfig resolverConfig = resolver != null ? resolver.getConfig() : null;
802        EDbVendor vendor = resolverConfig != null ? resolverConfig.getVendor() : null;
803        if (vendor == null && statements != null && statements.size() > 0) {
804            vendor = statements.get(0).dbvendor;
805        }
806        boolean isSparkOrHive = vendor == EDbVendor.dbvsparksql || vendor == EDbVendor.dbvhive;
807        boolean isSnowflake = vendor == EDbVendor.dbvsnowflake;
808        boolean isSqlServer = vendor == EDbVendor.dbvmssql || vendor == EDbVendor.dbvazuresql;
809
810
811        // Single pass through all column references in the entire session
812        java.util.List<TObjectName> allColumnReferences = buildResult.getAllColumnReferences();
813        for (TObjectName col : allColumnReferences) {
814            if (col == null) continue;
815            if (col.getValidate_column_status() == TBaseType.MARKED_NOT_A_COLUMN_IN_COLUMN_RESOLVER) continue;
816            if (col.getDbObjectType() == EDbObjectType.column_alias) continue;
817
818            // Get column name with vendor-specific handling
819            String columnName = getColumnDisplayName(col, isSparkOrHive, isSnowflake, isSqlServer);
820
821            if (columnName == null || columnName.isEmpty()) continue;
822
823            // Handle star columns specially when listStarColumn=false:
824            // - We need to process unqualified stars (*) for PIVOT/UNNEST column expansion
825            // - Other star columns (qualified like table.*, OUTPUT DELETED.*) should be skipped
826            if ("*".equals(columnName) && !listStarColumn) {
827                // Only process unqualified stars (SELECT *) for PIVOT/UNNEST expansion
828                if (!isUnqualifiedStar(col)) {
829                    continue; // Skip qualified stars when listStarColumn=false
830                }
831                // For unqualified stars, continue processing to reach expandStarToAllTables()
832            }
833
834            // Skip ROWNUM pseudo-column when filtering to physical tables
835            // Note: ROWID is a real column stored in the table, but ROWNUM is a query-result pseudo-column
836            if (onlyPhysicalTables && "ROWNUM".equalsIgnoreCase(columnName)) continue;
837
838            // Get source table
839            TTable sourceTable = col.getSourceTable();
840
841            // Handle star columns specially
842            if ("*".equals(columnName)) {
843                if (!shouldIncludeStarColumn(col, sourceTable)) continue;
844
845                // For unqualified star columns (SELECT *), expand to all tables in FROM clause
846                // Note: We use expandStarToAllTables() which adds table.* entries for each table
847                // The core resolver's star expansion (attributeNodesDerivedFromFromClause) is for
848                // internal resolution purposes, not for output formatting
849                if (isUnqualifiedStar(col)) {
850                    expandStarToAllTables(col, fields);
851                    continue;
852                }
853            }
854
855            // Check if this is a qualified star column with expanded attributes from push-down
856            // These are columns like "src.*" that have been expanded via TSQLResolver2's
857            // star column push-down algorithm
858            // Note: Use col.toString() instead of columnName, because columnName (from getColumnNameOnly)
859            // would be just "*", but we need "src.*" to detect qualified stars
860            String colFullString = col.toString();
861            if (colFullString != null && colFullString.endsWith("*") && !colFullString.equals("*")) {
862                java.util.ArrayList<gudusoft.gsqlparser.TAttributeNode> expandedAttrs =
863                    col.getAttributeNodesDerivedFromFromClause();
864                if (expandedAttrs != null && !expandedAttrs.isEmpty()) {
865                    // Only process if the star column's sourceTable is a physical table (not subquery/CTE/pivot)
866                    // This ensures we output with the correct base table prefix
867                    // PIVOT tables are excluded since their columns are output individually via PivotNamespace
868                    if (sourceTable != null &&
869                        sourceTable.getTableType() != ETableSource.subquery &&
870                        sourceTable.getTableType() != ETableSource.openquery &&
871                        sourceTable.getTableType() != ETableSource.join &&
872                        sourceTable.getTableType() != ETableSource.pivoted_table &&
873                        !(sourceTable.isCTEName() && !showCTE) &&
874                        !shouldSkipTableForPhysicalFilter(sourceTable)) {
875
876                        String tablePrefix = getFieldTablePrefix(sourceTable);
877                        if (tablePrefix != null) {
878                            // Output the star column itself if listStarColumn is true
879                            if (listStarColumn) {
880                                fields.add(tablePrefix + ".*");
881                            }
882
883                            // Output the expanded individual columns using the star's source table
884                            // This ensures columns pushed down through the star get the correct table prefix
885                            // BUT skip columns that are explicitly resolved elsewhere (to a different table)
886                            for (gudusoft.gsqlparser.TAttributeNode attr : expandedAttrs) {
887                                if (attr == null) continue;
888                                String attrName = attr.getName();
889                                if (attrName != null && !attrName.isEmpty() && !attrName.endsWith("*")) {
890                                    // Extract just the column name
891                                    String justColumnName = attrName;
892                                    int dotIdx = attrName.lastIndexOf('.');
893                                    if (dotIdx >= 0) {
894                                        justColumnName = attrName.substring(dotIdx + 1);
895                                    }
896
897                                    // Check if this column is explicitly resolved to a DIFFERENT PHYSICAL table
898                                    // in the column references list - if so, skip it to avoid duplicates
899                                    // Note: We use getFinalTable() to trace through subqueries to the actual physical table
900                                    // This handles cases like: SELECT al1.COL1 FROM (SELECT t1.COL1, t2.* FROM T1 t1 JOIN T2 t2) al1
901                                    // where al1.COL1 should resolve to T1.COL1 (explicit column), not T2.COL1 (from star)
902                                    boolean resolvedToOtherPhysicalTable = false;
903                                    for (TObjectName otherCol : allColumnReferences) {
904                                        if (otherCol == null || otherCol == col) continue;
905                                        String otherColName = otherCol.getColumnNameOnly();
906                                        if (otherColName != null && otherColName.equalsIgnoreCase(justColumnName)) {
907                                            // Get the final physical table through ColumnSource or sourceTable
908                                            TTable otherFinalTable = null;
909                                            ColumnSource otherColSource = otherCol.getColumnSource();
910                                            if (otherColSource != null) {
911                                                otherFinalTable = otherColSource.getFinalTable();
912                                            }
913                                            // Fallback to sourceTable if ColumnSource doesn't have finalTable
914                                            if (otherFinalTable == null) {
915                                                otherFinalTable = otherCol.getSourceTable();
916                                            }
917
918                                            if (otherFinalTable != null && otherFinalTable != sourceTable) {
919                                                // Check if the other source is a physical table (not CTE/subquery)
920                                                if (otherFinalTable.getTableType() != ETableSource.subquery &&
921                                                    otherFinalTable.getTableType() != ETableSource.openquery &&
922                                                    otherFinalTable.getTableType() != ETableSource.join &&
923                                                    !otherFinalTable.isCTEName()) {
924                                                    // Resolved to a different physical table - skip
925                                                    resolvedToOtherPhysicalTable = true;
926                                                    break;
927                                                }
928                                            }
929                                        }
930                                    }
931
932                                    if (!resolvedToOtherPhysicalTable) {
933                                        fields.add(tablePrefix + "." + normalizeColumnName(justColumnName));
934                                    }
935                                }
936                            }
937                        }
938                    }
939                    continue; // Star column fully handled
940                }
941            }
942
943            // Use ColumnSource to determine final table (if available)
944            ColumnSource source = col.getColumnSource();
945            if (source != null) {
946                // Check if the resolution is ambiguous - if so, don't use getFinalTable()
947                // because getColumnSource() returns the first candidate but we shouldn't pick one
948                gudusoft.gsqlparser.resolver2.model.ResolutionResult colResolution = col.getResolution();
949                boolean isResolutionAmbiguous = colResolution != null && colResolution.isAmbiguous();
950
951                // For columns from UNION queries, get all tables and add an entry for each
952                java.util.List<TTable> allFinalTables = source.getAllFinalTables();
953                if (allFinalTables != null && allFinalTables.size() > 1) {
954                    // Multiple tables (UNION query) - add entry for each table
955                    for (TTable unionTable : allFinalTables) {
956                        if (unionTable == null) continue;
957                        if (unionTable.getTableType() == ETableSource.subquery) continue;
958                        if (unionTable.getTableType() == ETableSource.openquery) continue;
959                        if (unionTable.getTableType() == ETableSource.join) continue;
960                        if (unionTable.isCTEName() && !showCTE) continue;
961                        if (shouldSkipTableForPhysicalFilter(unionTable)) continue;
962
963                        String tablePrefix = getFieldTablePrefix(unionTable);
964                        if (tablePrefix != null) {
965                            String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
966                            String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : "";
967                            fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr);
968                        }
969                    }
970                    continue; // Already handled this column for all tables
971                }
972
973                // Check if this is a column alias tracing (e.g., "col AS alias")
974                // When getFinalColumnName() is non-null, the column name is an alias.
975                // The original column reference inside the subquery already produces the
976                // correct entry in the output, so skip getFinalTable() to avoid duplicates.
977                String finalColName = source.getFinalColumnName();
978                TTable finalTable = source.getFinalTable();
979                // Only use finalTable if the resolution is NOT ambiguous
980                // For ambiguous resolutions, let the orphan handling deal with it
981                if (finalTable != null && !isResolutionAmbiguous && finalColName == null) {
982                    sourceTable = finalTable;
983                } else if (sourceTable == null && !source.isAmbiguous() && !isResolutionAmbiguous) {
984                    // ColumnSource exists but getFinalTable() is null AND sourceTable not set
985                    // AND not an ambiguous multi-table case
986                    // Check if we have candidate tables (e.g., from UNION branches)
987                    // If so, don't skip - let the orphan handling output all candidates
988                    java.util.List<TTable> candidates = source.getCandidateTables();
989                    if (candidates == null || candidates.isEmpty()) {
990                        // No candidates either - this is truly a calculated expression
991                        // Skip these - they don't trace to physical tables
992                        continue;
993                    }
994                    // Has candidate tables - fall through to orphan handling which will output them
995                }
996                // If getFinalTable() is null but sourceTable is set (e.g., UPDATE SET clause),
997                // use sourceTable as fallback
998            }
999            // If ColumnSource is null, fall back to col.getSourceTable()
1000            // This handles INSERT columns, direct star columns, and other cases
1001            // where the resolver doesn't set ColumnSource but the parser did resolve sourceTable
1002
1003            // Handle orphan columns (no definitive source table)
1004            // Also handle columns that point to a subquery but couldn't be traced to a physical table
1005            boolean isOrphan = (sourceTable == null);
1006            boolean isUnresolvedSubqueryColumn = false;
1007
1008            // Check if resolution or ColumnSource indicates ambiguity (multiple candidate tables)
1009            // Note: col.getResolution().isAmbiguous() checks the resolution result
1010            // source.isAmbiguous() checks if the ColumnSource has multiple candidate tables
1011            // For ambiguous resolutions, getColumnSource() returns the first candidate which won't have isAmbiguous()=true
1012            gudusoft.gsqlparser.resolver2.model.ResolutionResult resolvedResult = col.getResolution();
1013            if ((resolvedResult != null && resolvedResult.isAmbiguous()) ||
1014                (source != null && source.isAmbiguous())) {
1015                isUnresolvedSubqueryColumn = true;
1016            } else if (sourceTable != null && sourceTable.getTableType() == ETableSource.subquery && source == null) {
1017                // Check if this column is explicitly defined in the subquery's SELECT list
1018                // If so, it's a calculated/aliased column - skip it
1019                // If not, it's through an ambiguous star - mark as "missed"
1020                TSelectSqlStatement subquery = sourceTable.getSubquery();
1021                if (subquery != null && subquery.getResultColumnList() != null) {
1022                    boolean foundInSubquery = false;
1023                    boolean hasAmbiguousStar = false;
1024                    int tableCount = 0;
1025
1026                    // Count tables in FROM clause
1027                    if (subquery.tables != null) {
1028                        tableCount = subquery.tables.size();
1029                    }
1030
1031                    TResultColumnList resultCols = subquery.getResultColumnList();
1032                    for (int i = 0; i < resultCols.size(); i++) {
1033                        TResultColumn rc = resultCols.getResultColumn(i);
1034                        if (rc == null) continue;
1035
1036                        String rcStr = rc.toString().trim();
1037                        // Check if it's an unqualified star with multiple tables
1038                        if (rcStr.equals("*") && tableCount > 1) {
1039                            hasAmbiguousStar = true;
1040                            continue;
1041                        }
1042
1043                        // Check for explicit column/alias name
1044                        String rcName = null;
1045                        if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) {
1046                            rcName = rc.getAliasClause().getAliasName().toString();
1047                        } else if (rc.getExpr() != null &&
1048                                   rc.getExpr().getExpressionType() == EExpressionType.simple_object_name_t &&
1049                                   rc.getExpr().getObjectOperand() != null) {
1050                            rcName = rc.getExpr().getObjectOperand().getColumnNameOnly();
1051                        }
1052
1053                        if (rcName != null && nameMatcher.matches(rcName, columnName)) {
1054                            foundInSubquery = true;
1055                            break;
1056                        }
1057                    }
1058
1059                    // Column is unresolved only if it's NOT found in explicit columns AND there's an ambiguous star
1060                    isUnresolvedSubqueryColumn = !foundInSubquery && hasAmbiguousStar;
1061                }
1062            }
1063
1064            if (isOrphan || isUnresolvedSubqueryColumn) {
1065                // Check for candidate tables (e.g., from UNION branches)
1066                TTableList candidates = col.getCandidateTables();
1067                boolean hasCandidates = candidates != null && candidates.size() > 0;
1068
1069                // If we have multiple candidate tables from UNION/CTE propagation, output all of them
1070                // For regular ambiguous columns (not from UNION), fall through to "missed" handling
1071                if (hasCandidates && candidates.size() > 1 && sourceTable == null && col.isCandidatesFromUnion()) {
1072                    String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1073                    String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : "";
1074                    for (int ci = 0; ci < candidates.size(); ci++) {
1075                        TTable candidateTable = candidates.getTable(ci);
1076                        if (candidateTable != null) {
1077                            // Filter out tables that shouldn't be reported
1078                            if (candidateTable.getTableType() == ETableSource.subquery) continue;
1079                            if (candidateTable.getTableType() == ETableSource.openquery) continue;
1080                            if (candidateTable.getTableType() == ETableSource.join) continue;
1081                            if (candidateTable.isCTEName() && !showCTE) continue;
1082                            if (shouldSkipTableForPhysicalFilter(candidateTable)) continue;
1083
1084                            String tablePrefix = getFieldTablePrefix(candidateTable);
1085                            if (tablePrefix != null) {
1086                                fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr);
1087                            }
1088                        }
1089                    }
1090                    continue; // Already handled all candidates
1091                }
1092
1093                // Orphan column handling:
1094                // 1. Has candidates AND linkOrphanColumnToFirstTable=true → use first candidate table
1095                // 2. No candidates AND linkOrphanColumnToFirstTable=true → use statement's first physical table
1096                // 3. linkOrphanColumnToFirstTable=false → output missed.column
1097                // 4. No table available at all → output missed.column
1098                // Note: onlyPhysicalTables does NOT affect orphan column output
1099                if (sourceTable == null) {
1100                    // Check if the column resolved to a CTE - if so, skip it when showCTE=false
1101                    if (source != null) {
1102                        TTable finalTable = source.getFinalTable();
1103                        if (finalTable != null && finalTable.isCTEName() && !showCTE) {
1104                            continue; // Skip CTE columns when not showing CTEs
1105                        }
1106                    }
1107
1108                    if (hasCandidates && linkOrphanColumnToFirstTable) {
1109                        // Has candidates and linkOrphanColumnToFirstTable=true → use first candidate
1110                        sourceTable = candidates.getTable(0);
1111                    } else if (linkOrphanColumnToFirstTable) {
1112                        // No candidates but linkOrphanColumnToFirstTable=true
1113                        // → try statement's first physical table (matching TGetTableColumn behavior)
1114                        TCustomSqlStatement ownStmt = col.getOwnStmt();
1115                        // If ownStmt is null, try to find containing statement from AST
1116                        if (ownStmt == null) {
1117                            ownStmt = findContainingStatement(col);
1118                        }
1119                        if (ownStmt != null) {
1120                            TTable firstPhysicalTable = ownStmt.getFirstPhysicalTable();
1121                            if (firstPhysicalTable != null) {
1122                                sourceTable = firstPhysicalTable;
1123                            }
1124                        }
1125                        if (sourceTable == null) {
1126                            // Still no table → output as missed
1127                            String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1128                            String positionStr = getColumnPositionStr(col);
1129                            fields.add("missed." + columnName + positionStr + datatypeStr);
1130                            continue;
1131                        }
1132                    } else {
1133                        // linkOrphanColumnToFirstTable=false → output as missed
1134                        String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1135                        String positionStr = getColumnPositionStr(col);
1136                        fields.add("missed." + columnName + positionStr + datatypeStr);
1137                        continue;
1138                    }
1139                }
1140
1141                // For unresolved subquery columns (ambiguous), output as missed if linkOrphanColumnToFirstTable is false
1142                if (isUnresolvedSubqueryColumn && !linkOrphanColumnToFirstTable) {
1143                    String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1144                    String positionStr = getColumnPositionStr(col);
1145                    fields.add("missed." + columnName + positionStr + datatypeStr);
1146                    continue;
1147                }
1148            }
1149
1150            // Filter out tables that shouldn't be reported
1151            if (sourceTable.getTableType() == ETableSource.subquery) continue;
1152            if (sourceTable.getTableType() == ETableSource.openquery) continue;
1153            if (sourceTable.getTableType() == ETableSource.join) continue;
1154            if (sourceTable.isCTEName() && !showCTE) continue;
1155
1156            // Skip non-physical tables (record variables, etc.) if onlyPhysicalTables is true
1157            if (shouldSkipTableForPhysicalFilter(sourceTable)) continue;
1158
1159            // Skip star columns for PIVOT tables - individual columns are output via PivotNamespace
1160            if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.pivoted_table) continue;
1161
1162            // Calculate table prefix on-the-fly (avoids pre-building map via AST traversal)
1163            String tablePrefix = getFieldTablePrefix(sourceTable);
1164            if (tablePrefix != null) {
1165                String datatypeStr = showDatatype ? getColumnDatatype(col) : "";
1166                String locationStr = showColumnLocation ? "(" + col.getLocation() + ")" : "";
1167                fields.add(tablePrefix + "." + columnName + locationStr + datatypeStr);
1168
1169                // For star columns on UNNEST tables, expand
1170                if ("*".equals(columnName) && sourceTable.getTableType() == ETableSource.unnest) {
1171                    expandUnnestStarColumns(sourceTable, tablePrefix, fields);
1172                }
1173            }
1174        }
1175
1176        // NOTE: TD_UNPIVOT columns are now collected by ScopeBuilder.processTDUnpivotTable()
1177        // which adds them to allColumnReferences. No need for fallback here.
1178    }
1179
1180    /**
1181     * Get the display name for a column, with vendor-specific handling.
1182     *
1183     * For STRUCT field access (BigQuery/Snowflake/SparkSQL), this uses the
1184     * ColumnSource.exposedName which contains the base column name, rather than
1185     * getColumnNameOnly() which returns the nested field name.
1186     */
1187    private String getColumnDisplayName(TObjectName col, boolean isSparkOrHive, boolean isSnowflake, boolean isSqlServer) {
1188        String colString = col.toString();
1189        String colNameOnly = col.getColumnNameOnly();
1190
1191        // For STRUCT field access (detected by evidence marker), use ColumnSource.exposedName
1192        // which contains the base column name (e.g., "customer" for "customer.customer_id")
1193        ColumnSource columnSource = col.getColumnSource();
1194        if (columnSource != null && "struct_field_access".equals(columnSource.getEvidence())) {
1195            String exposedName = columnSource.getExposedName();
1196            if (exposedName != null && !exposedName.isEmpty()) {
1197                colNameOnly = exposedName;
1198            }
1199        }
1200
1201        if (isSqlServer && colNameOnly != null &&
1202            colNameOnly.startsWith("[") && colNameOnly.endsWith("]") &&
1203            colNameOnly.length() > 2) {
1204            // Normalize SQL Server bracketed identifiers to avoid duplicates like [col3] and col3
1205            // The TreeSet uses case-insensitive ordering but doesn't handle bracket normalization
1206            return normalizeColumnName(colNameOnly);
1207        } else if (isSparkOrHive && colString != null && colNameOnly != null &&
1208            !colString.contains(".") &&
1209            colString.startsWith("`") && colString.endsWith("`") &&
1210            colString.length() > 2) {
1211            // Use DisplayNameNormalizer to handle backtick-quoted identifiers
1212            // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them)
1213            return normalizeColumnName(colString);
1214        } else if (isSnowflake && colString != null && colNameOnly != null &&
1215            !colString.contains(".") &&
1216            colString.startsWith("\"") && colString.endsWith("\"") &&
1217            colString.length() > 2) {
1218            // Use DisplayNameNormalizer to handle double-quoted identifiers
1219            // This respects displayNameMode (DISPLAY strips quotes, SQL_RENDER preserves them)
1220            return normalizeColumnName(colString);
1221        } else if (isSnowflake && colString != null && colString.startsWith("$")) {
1222            // Snowflake stage file positional column (e.g., $1, $1:apMac)
1223            // Extract JSON path if present
1224            int colonIndex = colString.indexOf(':');
1225            if (colonIndex > 0) {
1226                // Has JSON path - extract it (e.g., "$1:apMac" -> ":apMac")
1227                String jsonPath = colString.substring(colonIndex);
1228                return jsonPath;
1229            } else {
1230                // Simple positional column (e.g., "$1") - return as-is
1231                return colString;
1232            }
1233        } else {
1234            return normalizeColumnName(colNameOnly);
1235        }
1236    }
1237
1238    /**
1239     * Check if a star column should be included in output.
1240     */
1241    private boolean shouldIncludeStarColumn(TObjectName col, TTable sourceTable) {
1242        // Skip if the star's source table reference is a CTE
1243        if (sourceTable != null && sourceTable.isCTEName()) return false;
1244
1245        // Skip if the star was resolved through a CTE namespace
1246        ColumnSource starSource = col.getColumnSource();
1247        if (starSource != null && starSource.getSourceNamespace() instanceof CTENamespace) return false;
1248
1249        // Skip if the star's source table is a PIVOT/UNPIVOT table
1250        // PIVOT columns are output individually via PivotNamespace, not as table.*
1251        if (sourceTable != null && sourceTable.getTableType() == ETableSource.pivoted_table) return false;
1252
1253        // Find the SELECT statement containing this star column
1254        TParseTreeNode parent = col.getParentObjectName();
1255        TSelectSqlStatement starSelectStmt = null;
1256        while (parent != null) {
1257            if (parent instanceof TSelectSqlStatement) {
1258                starSelectStmt = (TSelectSqlStatement) parent;
1259                break;
1260            }
1261            parent = parent.getParentObjectName();
1262        }
1263
1264        if (starSelectStmt != null) {
1265            // Skip if in a nested procedural statement (e.g., inside BEGIN/END block)
1266            if (starSelectStmt.getParentStmt() != null) return false;
1267
1268            // Check if sourceTable is in the star's SELECT statement
1269            // This ensures we only report stars that trace to tables in their own SELECT
1270            if (sourceTable != null) {
1271                boolean foundInSameStmt = false;
1272                for (int ti = 0; ti < starSelectStmt.tables.size(); ti++) {
1273                    if (starSelectStmt.tables.getTable(ti) == sourceTable) {
1274                        foundInSameStmt = true;
1275                        break;
1276                    }
1277                }
1278                if (!foundInSameStmt) return false;
1279            }
1280        }
1281
1282        return true;
1283    }
1284
1285    /**
1286     * Check if a star column is unqualified (SELECT * vs SELECT t.*)
1287     */
1288    private boolean isUnqualifiedStar(TObjectName col) {
1289        // Check if the column has a table prefix
1290        String colStr = col.toString();
1291        // Unqualified star is just "*" without any prefix
1292        return "*".equals(colStr.trim());
1293    }
1294
1295    /**
1296     * Find the statement that directly contains the given table in its tables list.
1297     * Returns the most specific (innermost) statement that has the table.
1298     */
1299    private TCustomSqlStatement findStatementWithTable(TCustomSqlStatement stmt, TTable targetTable) {
1300        if (stmt == null) return null;
1301
1302        // Check if this statement directly contains the table
1303        if (stmt.tables != null) {
1304            for (int i = 0; i < stmt.tables.size(); i++) {
1305                if (stmt.tables.getTable(i) == targetTable) {
1306                    return stmt;
1307                }
1308            }
1309        }
1310
1311        // Check nested statements (subqueries, etc.)
1312        if (stmt.getStatements() != null) {
1313            for (int i = 0; i < stmt.getStatements().size(); i++) {
1314                Object nested = stmt.getStatements().get(i);
1315                if (nested instanceof TCustomSqlStatement) {
1316                    TCustomSqlStatement found = findStatementWithTable((TCustomSqlStatement) nested, targetTable);
1317                    if (found != null) return found;
1318                }
1319            }
1320        }
1321
1322        // For SELECT statements, iteratively check UNION branches
1323        if (stmt instanceof TSelectSqlStatement) {
1324            TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt;
1325            if (selectStmt.isCombinedQuery()) {
1326                Deque<TSelectSqlStatement> stack = new ArrayDeque<>();
1327                stack.push(selectStmt);
1328                while (!stack.isEmpty()) {
1329                    TSelectSqlStatement current = stack.pop();
1330                    if (current.isCombinedQuery()) {
1331                        if (current.getRightStmt() != null) stack.push(current.getRightStmt());
1332                        if (current.getLeftStmt() != null) stack.push(current.getLeftStmt());
1333                    } else {
1334                        TCustomSqlStatement found = findStatementWithTable(current, targetTable);
1335                        if (found != null) return found;
1336                    }
1337                }
1338            }
1339        }
1340
1341        return null;
1342    }
1343
1344    /**
1345     * Expand unqualified star column to all tables in the FROM clause
1346     */
1347    private void expandStarToAllTables(TObjectName col, Set<String> fields) {
1348        // Find the containing statement - try multiple approaches
1349        TCustomSqlStatement containingStmt = null;
1350
1351        // Approach 1 (PRIORITY): Find via TResultColumn parent
1352        // For star columns in SELECT list, traverse up to find the TResultColumn,
1353        // then get the containing statement from the result column list's parent.
1354        // This is more reliable than parent chain traversal because the star column's
1355        // TObjectName parent may have been modified during resolution.
1356        TParseTreeNode parent = col.getParentObjectName();
1357        while (parent != null) {
1358            if (parent instanceof gudusoft.gsqlparser.nodes.TResultColumn) {
1359                // Found the result column - now get its parent statement
1360                gudusoft.gsqlparser.nodes.TResultColumn rc = (gudusoft.gsqlparser.nodes.TResultColumn) parent;
1361                TParseTreeNode rcParent = rc.getParentObjectName();
1362                while (rcParent != null) {
1363                    if (rcParent instanceof TCustomSqlStatement) {
1364                        containingStmt = (TCustomSqlStatement) rcParent;
1365                        break;
1366                    }
1367                    rcParent = rcParent.getParentObjectName();
1368                }
1369                break;
1370            }
1371            if (parent instanceof TCustomSqlStatement) {
1372                containingStmt = (TCustomSqlStatement) parent;
1373                break;
1374            }
1375            parent = parent.getParentObjectName();
1376        }
1377
1378        // Approach 2 (FALLBACK): Try to find via source table's statement
1379        if (containingStmt == null) {
1380            TTable sourceTable = col.getSourceTable();
1381            if (sourceTable != null && sourceTable.getGsqlparser() != null) {
1382                // Find the statement containing this source table
1383                for (int i = 0; i < statements.size(); i++) {
1384                    TCustomSqlStatement stmt = statements.get(i);
1385                    containingStmt = findStatementWithTable(stmt, sourceTable);
1386                    if (containingStmt != null) break;
1387                }
1388            }
1389        }
1390
1391        if (containingStmt == null || containingStmt.tables == null) {
1392            // Fallback: use first statement if available
1393            if (statements != null && statements.size() > 0) {
1394                TCustomSqlStatement firstStmt = statements.get(0);
1395                if (firstStmt != null && firstStmt.tables != null) {
1396                    containingStmt = firstStmt;
1397                }
1398            }
1399            if (containingStmt == null || containingStmt.tables == null) {
1400                return;
1401            }
1402        }
1403
1404        // Add star column for each physical table in the FROM clause
1405        for (int i = 0; i < containingStmt.tables.size(); i++) {
1406            TTable table = containingStmt.tables.getTable(i);
1407            if (table == null) continue;
1408
1409            // Skip non-physical table types
1410            if (table.getTableType() == ETableSource.subquery) continue;
1411            if (table.getTableType() == ETableSource.openquery) continue;
1412            if (table.getTableType() == ETableSource.join) continue;
1413            if (table.isCTEName() && !showCTE) continue;
1414
1415            // Skip implicit lateral derived tables - they shouldn't have star columns
1416            if (table.getEffectType() == ETableEffectType.tetImplicitLateralDerivedTable) continue;
1417
1418            // Skip non-physical tables if onlyPhysicalTables is true
1419            // Note: PIVOT/UNPIVOT and UNNEST column expansion is now handled by the core resolver
1420            // This is a fallback path when the core resolver hasn't expanded the star
1421            if (shouldSkipTableForPhysicalFilter(table)) continue;
1422
1423            String tablePrefix = getFieldTablePrefix(table);
1424            if (tablePrefix != null) {
1425                // For UNNEST tables, add star if requested, then expand individual columns
1426                if (table.getTableType() == ETableSource.unnest) {
1427                    if (listStarColumn) {
1428                        fields.add(tablePrefix + ".*");
1429                    }
1430                    expandUnnestStarColumns(table, tablePrefix, fields);
1431                } else if (table.getTableType() == ETableSource.pivoted_table) {
1432                    // For PIVOT/UNPIVOT tables, output the generated columns from PivotNamespace
1433                    // These are virtual tables so we don't output table.*, only individual columns
1434                    if (buildResult != null) {
1435                        INamespace namespace = buildResult.getNamespaceForTable(table);
1436                        if (namespace != null) {
1437                            Map<String, ColumnSource> columnSources = namespace.getAllColumnSources();
1438                            if (columnSources != null && !columnSources.isEmpty()) {
1439                                for (String columnName : columnSources.keySet()) {
1440                                    if (columnName != null && !columnName.isEmpty()) {
1441                                        fields.add(tablePrefix + "." + normalizeColumnName(columnName));
1442                                    }
1443                                }
1444                            }
1445                        }
1446                    }
1447                } else {
1448                    // For regular tables, add star column if listStarColumn is true
1449                    if (listStarColumn) {
1450                        fields.add(tablePrefix + ".*");
1451                    }
1452                }
1453            }
1454        }
1455    }
1456
1457    private String getFieldTablePrefix(TTable table) {
1458        if (table.getTableType() == ETableSource.subquery) {
1459            return "(subquery, alias:" + table.getAliasName() + ")";
1460        } else if (table.getTableType() == ETableSource.unnest) {
1461            String alias = table.getAliasName();
1462            return "(unnest-table:" + (alias != null ? alias : "") + ")";
1463        } else if (table.getTableType() == ETableSource.pivoted_table) {
1464            // Check if this is UNPIVOT or PIVOT
1465            boolean isUnpivot = false;
1466            // First try to get from TPivotClause
1467            if (table.getPivotedTable() != null && table.getPivotedTable().getPivotClause() != null) {
1468                isUnpivot = (table.getPivotedTable().getPivotClause().getType() == TPivotClause.unpivot);
1469            } else {
1470                // Fallback: check if the table alias suggests UNPIVOT
1471                // When no explicit alias is provided, TPivotClause.doParse() uses "unpivot_alias"
1472                String tableName = table.getTableName() != null ? table.getTableName().toString() : "";
1473                String aliasName = table.getAliasName() != null ? table.getAliasName() : "";
1474                isUnpivot = tableName.startsWith("unpivot_") || aliasName.startsWith("unpivot_");
1475            }
1476            String prefix = isUnpivot ? "(unpivot-table:" : "(pivot-table:";
1477            return prefix + normalizeTableName(table.getTableName()) + ")";
1478        } else if (table.getTableType() == ETableSource.function) {
1479            return "(table-valued function:" + normalizeTableName(table.getTableName()) + ")";
1480        } else if (table.getTableType() == ETableSource.lateralView) {
1481            return "(lateral_view:" + normalizeTableName(table.getTableName()) + ")";
1482        } else if (table.isCTEName() && showCTE) {
1483            String tableName = table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
1484            return tableName + "(CTE)";
1485        } else {
1486            return table.getTableName() != null ? normalizeTableName(table.getTableName()) : table.getName();
1487        }
1488    }
1489
1490    /**
1491     * Normalize a table name using the configured name matcher.
1492     *
1493     * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific
1494     * qualified name normalization (handles case folding and multi-part identifiers).
1495     * Falls back to manual parsing and quote stripping if no matcher is configured.</p>
1496     *
1497     * @param tableName The TObjectName representing the table name
1498     * @return The normalized table name
1499     */
1500    private String normalizeTableName(TObjectName tableName) {
1501        if (tableName == null) return "";
1502        String fullName = tableName.toString();
1503        if (fullName == null || fullName.isEmpty()) return "";
1504
1505        // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly
1506        if (displayNameNormalizer != null) {
1507            return displayNameNormalizer.normalizeQualifiedName(fullName);
1508        }
1509
1510        // Fallback when no normalizer is available
1511        // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding)
1512        if (nameMatcher instanceof VendorNameMatcher) {
1513            return ((VendorNameMatcher) nameMatcher).normalizeQualifiedName(fullName, ESQLDataObjectType.dotTable);
1514        }
1515
1516        // Fallback: manual parsing with quote stripping
1517        return normalizeTableNameFallback(fullName);
1518    }
1519
1520    /**
1521     * Fallback table name normalization when no vendor matcher is configured.
1522     * Handles both fully-quoted identifiers and multi-part identifiers.
1523     */
1524    private String normalizeTableNameFallback(String fullName) {
1525        // First check if there are any dots outside of quotes (multi-part identifier)
1526        boolean hasUnquotedDot = false;
1527        char quoteChar = 0;
1528        for (int i = 0; i < fullName.length(); i++) {
1529            char c = fullName.charAt(i);
1530            if (quoteChar == 0) {
1531                if (c == '`' || c == '"' || c == '[') {
1532                    quoteChar = (c == '[') ? ']' : c;
1533                } else if (c == '.') {
1534                    hasUnquotedDot = true;
1535                    break;
1536                }
1537            } else {
1538                if (c == quoteChar) {
1539                    quoteChar = 0;
1540                }
1541            }
1542        }
1543
1544        // If no unquoted dots, check if entire string is quoted
1545        if (!hasUnquotedDot) {
1546            return stripQuotes(fullName);
1547        }
1548
1549        // Otherwise, split by dot to handle multi-part identifiers like `schema`.`table`
1550        // But we need to be careful not to split inside quotes
1551        StringBuilder result = new StringBuilder();
1552        StringBuilder currentPart = new StringBuilder();
1553        quoteChar = 0;
1554
1555        for (int i = 0; i < fullName.length(); i++) {
1556            char c = fullName.charAt(i);
1557
1558            if (quoteChar == 0) {
1559                // Not inside a quote
1560                if (c == '`' || c == '"' || c == '[') {
1561                    quoteChar = (c == '[') ? ']' : c;
1562                    currentPart.append(c);
1563                } else if (c == '.') {
1564                    // End of a part
1565                    if (result.length() > 0) result.append(".");
1566                    result.append(stripQuotes(currentPart.toString()));
1567                    currentPart.setLength(0);
1568                } else {
1569                    currentPart.append(c);
1570                }
1571            } else {
1572                // Inside a quote
1573                currentPart.append(c);
1574                if (c == quoteChar) {
1575                    quoteChar = 0;
1576                }
1577            }
1578        }
1579
1580        // Don't forget the last part
1581        if (currentPart.length() > 0) {
1582            if (result.length() > 0) result.append(".");
1583            result.append(stripQuotes(currentPart.toString()));
1584        }
1585
1586        return result.toString();
1587    }
1588
1589    private void collectConstraintColumns(TCreateTableSqlStatement createStmt, Set<String> fields) {
1590        if (createStmt == null || createStmt.getTargetTable() == null) return;
1591
1592        String tableName = createStmt.getTargetTable().getTableName() != null
1593                ? normalizeTableName(createStmt.getTargetTable().getTableName()) : "";
1594
1595        // Collect columns from table-level constraints
1596        if (createStmt.getTableConstraints() != null) {
1597            for (int i = 0; i < createStmt.getTableConstraints().size(); i++) {
1598                TConstraint constraint = createStmt.getTableConstraints().getConstraint(i);
1599                collectColumnsFromConstraint(constraint, tableName, fields);
1600            }
1601        }
1602
1603        // Collect columns from column-level constraints (inline constraints)
1604        if (createStmt.getColumnList() != null) {
1605            for (int i = 0; i < createStmt.getColumnList().size(); i++) {
1606                TColumnDefinition colDef = createStmt.getColumnList().getColumn(i);
1607                if (colDef != null && colDef.getConstraints() != null) {
1608                    for (int j = 0; j < colDef.getConstraints().size(); j++) {
1609                        TConstraint constraint = colDef.getConstraints().getConstraint(j);
1610                        collectColumnsFromConstraint(constraint, tableName, fields);
1611                    }
1612                }
1613            }
1614        }
1615    }
1616
1617    private void collectAlterTableConstraintColumns(TAlterTableStatement alterStmt, Set<String> fields) {
1618        if (alterStmt == null) return;
1619
1620        // Get table name from getTableName() first, fall back to getTargetTable()
1621        String tableName = "";
1622        if (alterStmt.getTableName() != null) {
1623            tableName = normalizeTableName(alterStmt.getTableName());
1624        } else if (alterStmt.getTargetTable() != null && alterStmt.getTargetTable().getTableName() != null) {
1625            tableName = normalizeTableName(alterStmt.getTargetTable().getTableName());
1626        }
1627        if (tableName.isEmpty()) return;
1628
1629        // Collect from ALTER TABLE options (ADD CONSTRAINT, etc.)
1630        if (alterStmt.getAlterTableOptionList() != null) {
1631            for (int i = 0; i < alterStmt.getAlterTableOptionList().size(); i++) {
1632                TAlterTableOption option = alterStmt.getAlterTableOptionList().getAlterTableOption(i);
1633                if (option == null) continue;
1634
1635                // Handle constraints
1636                if (option.getConstraintList() != null) {
1637                    for (int j = 0; j < option.getConstraintList().size(); j++) {
1638                        TConstraint constraint = option.getConstraintList().getConstraint(j);
1639                        collectColumnsFromConstraint(constraint, tableName, fields);
1640                    }
1641                }
1642
1643                // Handle index columns (for AddConstraintPK, AddConstraintUnique, AddConstraintFK)
1644                // These store columns as TPTNodeList<TColumnWithSortOrder>
1645                if (option.getIndexCols() != null) {
1646                    for (int j = 0; j < option.getIndexCols().size(); j++) {
1647                        TColumnWithSortOrder colWithSort = option.getIndexCols().getElement(j);
1648                        if (colWithSort != null && colWithSort.getColumnName() != null) {
1649                            String colName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly());
1650                            if (colName != null && !colName.isEmpty()) {
1651                                fields.add(tableName + "." + colName);
1652                            }
1653                        }
1654                    }
1655                }
1656
1657                // Note: We do NOT collect columns from ALTER COLUMN, DROP COLUMN, RENAME COLUMN,
1658                // CHANGE COLUMN operations. These are DDL targets (columns being modified),
1659                // not column references. The old resolver does not collect these either.
1660
1661                // Handle FOREIGN KEY references (for ADD CONSTRAINT FK)
1662                if (option.getReferencedObjectName() != null && option.getReferencedColumnList() != null) {
1663                    String refTableName = normalizeTableName(option.getReferencedObjectName());
1664                    for (int j = 0; j < option.getReferencedColumnList().size(); j++) {
1665                        TObjectName colObj = option.getReferencedColumnList().getObjectName(j);
1666                        if (colObj != null) {
1667                            String colName = normalizeColumnName(colObj.getColumnNameOnly());
1668                            if (colName != null && !colName.isEmpty()) {
1669                                fields.add(refTableName + "." + colName);
1670                            }
1671                        }
1672                    }
1673                }
1674            }
1675        }
1676    }
1677
1678    private void collectColumnsFromConstraint(TConstraint constraint, String tableName, Set<String> fields) {
1679        if (constraint == null) return;
1680
1681        // Collect columns from constraint's column list (TPTNodeList<TColumnWithSortOrder>)
1682        if (constraint.getColumnList() != null) {
1683            for (int i = 0; i < constraint.getColumnList().size(); i++) {
1684                TColumnWithSortOrder colWithSort = constraint.getColumnList().getElement(i);
1685                if (colWithSort != null && colWithSort.getColumnName() != null) {
1686                    String columnName = normalizeColumnName(colWithSort.getColumnName().getColumnNameOnly());
1687                    if (columnName != null && !columnName.isEmpty()) {
1688                        // Constraint columns don't include datatype
1689                        // Get location from the column if available
1690                        String locationStr = "";
1691                        if (showColumnLocation && colWithSort.getColumnName().getLocation() != null) {
1692                            locationStr = "(" + colWithSort.getColumnName().getLocation() + ")";
1693                        }
1694                        fields.add(tableName + "." + columnName + locationStr);
1695                    }
1696                }
1697            }
1698        }
1699
1700        // Collect columns from FOREIGN KEY REFERENCES
1701        if (constraint.getReferencedColumnList() != null && constraint.getReferencedObject() != null) {
1702            String refTableName = normalizeTableName(constraint.getReferencedObject());
1703            for (int i = 0; i < constraint.getReferencedColumnList().size(); i++) {
1704                TObjectName colName = constraint.getReferencedColumnList().getObjectName(i);
1705                if (colName != null) {
1706                    String columnName = normalizeColumnName(colName.getColumnNameOnly());
1707                    if (columnName != null && !columnName.isEmpty()) {
1708                        // Use location already set during parsing
1709                        String locationStr = "";
1710                        if (showColumnLocation && colName.getLocation() != null) {
1711                            locationStr = "(" + colName.getLocation() + ")";
1712                        }
1713                        fields.add(refTableName + "." + columnName + locationStr);
1714                    }
1715                }
1716            }
1717        }
1718    }
1719
1720    private void expandUnnestStarColumns(TTable unnestTable, String tablePrefix, Set<String> fields) {
1721        if (unnestTable == null || unnestTable.getTableType() != ETableSource.unnest) {
1722            return;
1723        }
1724
1725        // Get the implicit column name (alias or 'value')
1726        String alias = unnestTable.getAliasName();
1727        String implicitColumnName = (alias != null && !alias.isEmpty()) ? alias : "value";
1728        fields.add(tablePrefix + "." + implicitColumnName);
1729
1730        // Check for WITH OFFSET column
1731        TUnnestClause unnestClause = unnestTable.getUnnestClause();
1732        if (unnestClause != null && unnestClause.getWithOffset() != null) {
1733            String offsetColumnName;
1734            if (unnestClause.getWithOffsetAlais() != null &&
1735                unnestClause.getWithOffsetAlais().getAliasName() != null) {
1736                offsetColumnName = unnestClause.getWithOffsetAlais().getAliasName().toString();
1737            } else {
1738                offsetColumnName = "offset";
1739            }
1740            fields.add(tablePrefix + "." + offsetColumnName);
1741        }
1742    }
1743
1744    /**
1745     * Normalize a column name using the configured name matcher.
1746     *
1747     * <p>When a VendorNameMatcher is configured, this delegates to the vendor-specific
1748     * normalization logic (handles case folding according to vendor rules).
1749     * Falls back to simple quote stripping if no matcher is configured.</p>
1750     *
1751     * @param columnName The raw column name which may include quotes
1752     * @return The normalized column name
1753     */
1754    private String normalizeColumnName(String columnName) {
1755        if (columnName == null || columnName.isEmpty()) return columnName;
1756
1757        // Use DisplayNameNormalizer for all modes - it handles DISPLAY, SQL_RENDER, CANONICAL correctly
1758        if (displayNameNormalizer != null) {
1759            return displayNameNormalizer.normalizeIdentifier(columnName);
1760        }
1761
1762        // Fallback when no normalizer is available
1763        // Use VendorNameMatcher if available for vendor-specific normalization (includes case folding)
1764        if (nameMatcher instanceof VendorNameMatcher) {
1765            return ((VendorNameMatcher) nameMatcher).normalize(columnName, ESQLDataObjectType.dotColumn);
1766        }
1767
1768        // Use generic INameMatcher if available
1769        if (nameMatcher != null) {
1770            return nameMatcher.normalize(columnName);
1771        }
1772
1773        // Fallback: simple quote stripping
1774        return stripQuotes(columnName);
1775    }
1776
1777    /**
1778     * Strip surrounding quotes from an identifier.
1779     * This is a fallback when no name matcher is configured.
1780     *
1781     * @param name The identifier which may include quotes
1782     * @return The identifier without surrounding quotes
1783     */
1784    private String stripQuotes(String name) {
1785        if (name == null || name.isEmpty()) return name;
1786
1787        // Remove surrounding double quotes
1788        if (name.startsWith("\"") && name.endsWith("\"") && name.length() > 1) {
1789            return name.substring(1, name.length() - 1);
1790        }
1791        // Remove surrounding backticks (MySQL)
1792        if (name.startsWith("`") && name.endsWith("`") && name.length() > 1) {
1793            return name.substring(1, name.length() - 1);
1794        }
1795        // Remove surrounding square brackets (SQL Server)
1796        if (name.startsWith("[") && name.endsWith("]") && name.length() > 1) {
1797            return name.substring(1, name.length() - 1);
1798        }
1799        return name;
1800    }
1801
1802    private String getColumnDatatype(TObjectName col) {
1803        if (!showDatatype) return "";
1804
1805        if (col.getLinkedColumnDef() != null) {
1806            TTypeName datatype = col.getLinkedColumnDef().getDatatype();
1807            if (datatype != null) {
1808                String typeName = datatype.getDataTypeName();
1809                if (typeName != null && !typeName.isEmpty()) {
1810                    StringBuilder result = new StringBuilder(":" + typeName.toLowerCase());
1811                    // Add length if available (e.g., varchar:30, char:50)
1812                    if (datatype.getLength() != null) {
1813                        result.append(":").append(datatype.getLength().toString());
1814                    }
1815                    // Add precision if available (e.g., decimal:10)
1816                    else if (datatype.getPrecision() != null) {
1817                        result.append(":").append(datatype.getPrecision().toString());
1818                        // Add scale if available (e.g., decimal:10:2)
1819                        if (datatype.getScale() != null) {
1820                            result.append(":").append(datatype.getScale().toString());
1821                        }
1822                    }
1823                    // Add display length if available (e.g., int:11 for MySQL INT(11))
1824                    else if (datatype.getDisplayLength() != null) {
1825                        result.append(":").append(datatype.getDisplayLength().toString());
1826                    }
1827                    return result.toString();
1828                }
1829            }
1830        }
1831        return "";
1832    }
1833
1834    // ========== CTE Column Collection ==========
1835
1836    /**
1837     * Collect CTE columns for the Ctes: output section.
1838     * Prioritizes CTENamespace from resolver when available, falls back to AST traversal.
1839     */
1840    private void collectCTEColumns(Set<String> cteColumns) {
1841        if (buildResult == null) return;
1842
1843        // Get CTE namespaces from resolver
1844        Map<String, CTENamespace> cteNamespaces = buildResult.getCTENamespaces();
1845
1846        if (cteNamespaces != null && !cteNamespaces.isEmpty()) {
1847            // Preferred: Use CTENamespace to get columns
1848            for (Map.Entry<String, CTENamespace> entry : cteNamespaces.entrySet()) {
1849                String cteName = entry.getKey();
1850                CTENamespace ns = entry.getValue();
1851
1852                Map<String, ColumnSource> columnSources = ns.getAllColumnSources();
1853                if (columnSources != null) {
1854                    for (String colName : columnSources.keySet()) {
1855                        cteColumns.add(cteName + "." + colName);
1856                    }
1857                }
1858            }
1859        } else {
1860            // Fallback: Collect from AST when namespaces not available
1861            for (int i = 0; i < statements.size(); i++) {
1862                collectCTEColumnsFromAST(statements.get(i), cteColumns);
1863            }
1864        }
1865    }
1866
1867    /**
1868     * Fallback method to collect CTE columns from AST when CTENamespace is not available.
1869     */
1870    private void collectCTEColumnsFromAST(Object stmt, Set<String> cteColumns) {
1871        if (!(stmt instanceof TSelectSqlStatement)) {
1872            if (stmt instanceof TCustomSqlStatement) {
1873                TCustomSqlStatement customStmt = (TCustomSqlStatement) stmt;
1874                for (int i = 0; i < customStmt.getStatements().size(); i++) {
1875                    collectCTEColumnsFromAST(customStmt.getStatements().get(i), cteColumns);
1876                }
1877            }
1878            return;
1879        }
1880
1881        TSelectSqlStatement selectStmt = (TSelectSqlStatement) stmt;
1882
1883        // Handle combined queries - follow left chain iteratively
1884        if (selectStmt.isCombinedQuery()) {
1885            TSelectSqlStatement current = selectStmt;
1886            while (current.isCombinedQuery()) {
1887                collectCTEColumnsFromAST(current.getRightStmt(), cteColumns);
1888                current = current.getLeftStmt();
1889            }
1890            collectCTEColumnsFromAST(current, cteColumns);
1891            return;
1892        }
1893
1894        // Process CTEs in this statement
1895        if (selectStmt.getCteList() != null) {
1896            for (int i = 0; i < selectStmt.getCteList().size(); i++) {
1897                TCTE cte = selectStmt.getCteList().getCTE(i);
1898                String cteName = cte.getTableName().toString();
1899
1900                // Get columns from CTE definition
1901                if (cte.getColumnList() != null) {
1902                    for (int j = 0; j < cte.getColumnList().size(); j++) {
1903                        TObjectName colName = cte.getColumnList().getObjectName(j);
1904                        if (colName != null) {
1905                            cteColumns.add(cteName + "." + colName.getColumnNameOnly());
1906                        }
1907                    }
1908                } else if (cte.getSubquery() != null) {
1909                    // Infer columns from subquery result columns
1910                    TResultColumnList resultCols = cte.getSubquery().getResultColumnList();
1911                    if (resultCols != null) {
1912                        for (int j = 0; j < resultCols.size(); j++) {
1913                            TResultColumn rc = resultCols.getResultColumn(j);
1914                            String colName = inferColumnName(rc);
1915                            if (colName != null && !"*".equals(colName)) {
1916                                cteColumns.add(cteName + "." + colName);
1917                            }
1918                        }
1919                    }
1920                }
1921            }
1922        }
1923
1924        // Recursively process nested statements
1925        for (int i = 0; i < selectStmt.getStatements().size(); i++) {
1926            collectCTEColumnsFromAST(selectStmt.getStatements().get(i), cteColumns);
1927        }
1928    }
1929
1930    private String inferColumnName(TResultColumn rc) {
1931        if (rc == null) return null;
1932
1933        // Check for alias
1934        if (rc.getAliasClause() != null) {
1935            return rc.getAliasClause().toString();
1936        }
1937
1938        // Check for simple column reference
1939        if (rc.getExpr() != null && rc.getExpr().getObjectOperand() != null) {
1940            return rc.getExpr().getObjectOperand().getColumnNameOnly();
1941        }
1942
1943        return null;
1944    }
1945
1946    // ========== Static Factory Methods ==========
1947
1948    /**
1949     * Create a formatter with default settings.
1950     */
1951    public static TSQLResolver2ResultFormatter create(TSQLResolver2 resolver) {
1952        return new TSQLResolver2ResultFormatter(resolver);
1953    }
1954
1955    /**
1956     * Create a formatter configured to show CTE tables and columns.
1957     */
1958    public static TSQLResolver2ResultFormatter createWithCTE(TSQLResolver2 resolver) {
1959        return new TSQLResolver2ResultFormatter(resolver).setShowCTE(true);
1960    }
1961
1962    /**
1963     * Find the containing SQL statement for a column reference by traversing up the AST.
1964     * This is used as a fallback when ownStmt is not set (e.g., for PL/SQL package constants).
1965     *
1966     * @param col The column reference
1967     * @return The containing statement, or null if not found
1968     */
1969    private TCustomSqlStatement findContainingStatement(TObjectName col) {
1970        if (col == null) return null;
1971
1972        TParseTreeNode parent = col.getParentObjectName();
1973        while (parent != null) {
1974            if (parent instanceof TCustomSqlStatement) {
1975                return (TCustomSqlStatement) parent;
1976            }
1977            parent = parent.getParentObjectName();
1978        }
1979        return null;
1980    }
1981
1982    /**
1983     * Get the position string for a column reference, using the actual column name part's position
1984     * for multi-part identifiers (e.g., for "sch.pkg.col", use the position of "col" not "sch").
1985     * This matches TGetTableColumn behavior.
1986     *
1987     * @param col The column reference
1988     * @return Position string in format "(line,column)"
1989     */
1990    private String getColumnPositionStr(TObjectName col) {
1991        if (col == null) {
1992            return "(0,0)";
1993        }
1994        // For multi-part names, use the partToken (column name part) position if available
1995        TSourceToken partToken = col.getPartToken();
1996        if (partToken != null) {
1997            return "(" + partToken.lineNo + "," + partToken.columnNo + ")";
1998        }
1999        // Fallback to the object name's position
2000        return "(" + col.getLineNo() + "," + col.getColumnNo() + ")";
2001    }
2002
2003    /**
2004     * Create a formatter configured to show datatypes.
2005     */
2006    public static TSQLResolver2ResultFormatter createWithDatatype(TSQLResolver2 resolver) {
2007        return new TSQLResolver2ResultFormatter(resolver).setShowDatatype(true);
2008    }
2009
2010    /**
2011     * Create a formatter configured to show CTE column definitions.
2012     */
2013    public static TSQLResolver2ResultFormatter createWithCTEColumns(TSQLResolver2 resolver) {
2014        return new TSQLResolver2ResultFormatter(resolver).setShowColumnsOfCTE(true);
2015    }
2016
2017    /**
2018     * Read the expected output from a file.
2019     * Utility method for tests to load expected results.
2020     */
2021    public static String getDesiredTablesColumns(String filePath) {
2022        try {
2023            return new String(java.nio.file.Files.readAllBytes(
2024                java.nio.file.Paths.get(filePath)), java.nio.charset.StandardCharsets.UTF_8);
2025        } catch (java.io.IOException e) {
2026            return "";
2027        }
2028    }
2029}