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