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