001package gudusoft.gsqlparser.resolver2.namespace;
002
003import gudusoft.gsqlparser.EExpressionType;
004import gudusoft.gsqlparser.nodes.TResultColumn;
005import gudusoft.gsqlparser.nodes.TObjectNameList;
006import gudusoft.gsqlparser.nodes.TResultColumnList;
007import gudusoft.gsqlparser.nodes.TTable;
008import gudusoft.gsqlparser.resolver2.ColumnLevel;
009import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
010import gudusoft.gsqlparser.resolver2.model.ColumnSource;
011import gudusoft.gsqlparser.sqlenv.TSQLEnv;
012import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
013
014import java.util.ArrayDeque;
015import java.util.ArrayList;
016import java.util.Collections;
017import java.util.Deque;
018import java.util.HashSet;
019import java.util.LinkedHashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Set;
023
024/**
025 * Namespace representing a subquery.
026 * Provides columns from the subquery's SELECT list.
027 *
028 * Example:
029 * FROM (SELECT id, name FROM users) AS t
030 *       ^^^^^^^^^^^^^^^^^^^^^^^^
031 *       SubqueryNamespace exposes columns: id, name
032 */
033public class SubqueryNamespace extends AbstractNamespace {
034
035    private final TSelectSqlStatement subquery;
036    private final String alias;
037
038    /** Inferred columns from star push-down */
039    private Map<String, ColumnSource> inferredColumns;
040
041    /** Track inferred column names for getInferredColumns() */
042    private Set<String> inferredColumnNames;
043
044    /** Traced columns from qualified references (e.g., table1.x in ON conditions) */
045    private Map<String, TTable> tracedColumnTables;
046
047    /** Whether this namespace was created from a TABLE function */
048    private final boolean fromTableFunction;
049
050    /** The derived table's alias column list, {@code FROM (SELECT z FROM t3) d(y)}: renames the columns by position
051     *  (Mantis 4814, 4832) and names the cells of a VALUES derived table (Mantis 4819). Null when absent. */
052    private TObjectNameList aliasColumns;
053
054    public void setAliasColumns(TObjectNameList aliasColumns) {
055        this.aliasColumns = (aliasColumns != null && aliasColumns.size() > 0) ? aliasColumns : null;
056    }
057
058    public TObjectNameList getAliasColumns() {
059        return aliasColumns;
060    }
061
062    /** The alias column list's name for position {@code i}, or null when the list does not cover it. */
063    private String aliasColumnName(int i) {
064        if (aliasColumns == null || i >= aliasColumns.size() || aliasColumns.getObjectName(i) == null) {
065            return null;
066        }
067        return aliasColumns.getObjectName(i).getColumnNameOnly();
068    }
069
070    /**
071     * Cache for nested SubqueryNamespace objects to avoid repeated creation.
072     * Key: TSelectSqlStatement, Value: validated SubqueryNamespace
073     */
074    private Map<TSelectSqlStatement, SubqueryNamespace> nestedSubqueryCache;
075
076    /**
077     * Cache for nested TableNamespace objects to avoid repeated creation.
078     * Key: TTable, Value: validated TableNamespace
079     */
080    private Map<TTable, TableNamespace> nestedTableCache;
081
082    /** The TTable that wraps this subquery (for legacy sync) */
083    private TTable sourceTable;
084
085    /** TSQLEnv for looking up table metadata during star column resolution */
086    private TSQLEnv sqlEnv;
087
088    /**
089     * Memoized result of the qualified-star SELECT-list scan (see
090     * {@link #scanQualifiedStarMatchTable()}). Both a found table and a null
091     * result are cached, and the cache never needs invalidation because the
092     * scanned value is a pure function of parse-time-immutable state.
093     */
094    private boolean qualifiedStarScanned;
095    private TTable qualifiedStarMatchTable;
096
097    public SubqueryNamespace(TSelectSqlStatement subquery,
098                            String alias,
099                            INameMatcher nameMatcher) {
100        this(subquery, alias, nameMatcher, false);
101    }
102
103    public SubqueryNamespace(TSelectSqlStatement subquery,
104                            String alias,
105                            INameMatcher nameMatcher,
106                            boolean fromTableFunction) {
107        super(subquery, nameMatcher);
108        this.subquery = subquery;
109        this.alias = alias;
110        this.fromTableFunction = fromTableFunction;
111    }
112
113    public SubqueryNamespace(TSelectSqlStatement subquery, String alias) {
114        super(subquery);
115        this.subquery = subquery;
116        this.alias = alias;
117        this.fromTableFunction = false;
118    }
119
120    /**
121     * Gets or creates a cached nested SubqueryNamespace that inherits the guessColumnStrategy from this namespace.
122     * This ensures config-based isolation propagates through nested resolution.
123     * The namespace is cached to avoid repeated creation and validation for the same subquery.
124     * Also propagates sqlEnv for metadata lookup in star column resolution.
125     *
126     * @param subquery The subquery statement
127     * @param alias The alias for the subquery (not used as cache key, only for display)
128     * @return A validated SubqueryNamespace (cached or newly created)
129     */
130    private SubqueryNamespace getOrCreateNestedNamespace(TSelectSqlStatement subquery, String alias) {
131        return getOrCreateNestedNamespace(subquery, alias, null);
132    }
133
134    /** The alias column list of a derived table, {@code d(y)}, or null (Mantis 4814). */
135    static TObjectNameList aliasColumnsOf(TTable table) {
136        return (table != null && table.getAliasClause() != null) ? table.getAliasClause().getColumns() : null;
137    }
138
139    /**
140     * As {@link #getOrCreateNestedNamespace(TSelectSqlStatement, String)}, with the derived table's alias column list
141     * applied before validation, so a nested {@code (SELECT z FROM t1) d(y)} exposes y here too (Mantis 4814, Codex
142     * review).
143     */
144    private SubqueryNamespace getOrCreateNestedNamespace(TSelectSqlStatement subquery, String alias,
145                                                         TObjectNameList nestedAliasColumns) {
146        if (subquery == null) {
147            return null;
148        }
149        // Initialize cache lazily
150        if (nestedSubqueryCache == null) {
151            nestedSubqueryCache = new java.util.HashMap<>();
152        }
153        // Check cache first
154        SubqueryNamespace cached = nestedSubqueryCache.get(subquery);
155        if (cached != null) {
156            return cached;
157        }
158        // Create new namespace
159        SubqueryNamespace nested = new SubqueryNamespace(subquery, alias, nameMatcher);
160        nested.setAliasColumns(nestedAliasColumns);
161        nested.setStatementMetadata(statementMetadata);
162        // Propagate guessColumnStrategy for config-based isolation
163        if (this.guessColumnStrategy >= 0) {
164            nested.setGuessColumnStrategy(this.guessColumnStrategy);
165        }
166        // Propagate sqlEnv for metadata lookup during star column resolution
167        if (this.sqlEnv != null) {
168            nested.setSqlEnv(this.sqlEnv);
169        }
170        // Validate and cache
171        nested.validate();
172        nestedSubqueryCache.put(subquery, nested);
173        return nested;
174    }
175
176    /**
177     * Gets or creates a cached TableNamespace.
178     * The namespace is cached to avoid repeated creation and validation for the same table.
179     * Passes sqlEnv to enable metadata lookup for star column resolution.
180     *
181     * @param table The table
182     * @return A validated TableNamespace (cached or newly created)
183     */
184    private TableNamespace getOrCreateTableNamespace(TTable table) {
185        if (table == null) {
186            return null;
187        }
188        // Initialize cache lazily
189        if (nestedTableCache == null) {
190            nestedTableCache = new java.util.HashMap<>();
191        }
192        // Check cache first
193        TableNamespace cached = nestedTableCache.get(table);
194        if (cached != null) {
195            return cached;
196        }
197        // Create new namespace with sqlEnv for metadata lookup
198        TableNamespace tableNs = new TableNamespace(table, nameMatcher, sqlEnv, table.dbvendor);
199        if (statementMetadata != null) statementMetadata.configure(tableNs, table);
200        // Validate and cache
201        tableNs.validate();
202        nestedTableCache.put(table, tableNs);
203        return tableNs;
204    }
205
206    /**
207     * Creates a nested SubqueryNamespace that inherits the guessColumnStrategy from this namespace.
208     * This method is deprecated - use getOrCreateNestedNamespace for caching support.
209     *
210     * @param subquery The subquery statement
211     * @param alias The alias for the subquery
212     * @return A new SubqueryNamespace with inherited guessColumnStrategy and sqlEnv
213     * @deprecated Use getOrCreateNestedNamespace instead
214     */
215    @Deprecated
216    private SubqueryNamespace createNestedNamespace(TSelectSqlStatement subquery, String alias) {
217        SubqueryNamespace nested = new SubqueryNamespace(subquery, alias, nameMatcher);
218        nested.setStatementMetadata(statementMetadata);
219        // Propagate guessColumnStrategy for config-based isolation
220        if (this.guessColumnStrategy >= 0) {
221            nested.setGuessColumnStrategy(this.guessColumnStrategy);
222        }
223        // Propagate sqlEnv for metadata lookup
224        if (this.sqlEnv != null) {
225            nested.setSqlEnv(this.sqlEnv);
226        }
227        return nested;
228    }
229
230    /**
231     * Returns true if this namespace was created from a TABLE function's subquery.
232     */
233    public boolean isFromTableFunction() {
234        return fromTableFunction;
235    }
236
237    /**
238     * Set the TTable that wraps this subquery.
239     * Used by ScopeBuilder for legacy sync support.
240     *
241     * @param sourceTable the TTable that contains this subquery
242     */
243    public void setSourceTable(TTable sourceTable) {
244        this.sourceTable = sourceTable;
245    }
246
247    @Override
248    public TTable getSourceTable() {
249        return sourceTable;
250    }
251
252    /**
253     * Set the TSQLEnv for metadata lookup.
254     * Used when resolving star columns to find which columns exist in underlying tables.
255     *
256     * @param sqlEnv the SQL environment containing table metadata
257     */
258    private gudusoft.gsqlparser.resolver2.metadata.StatementMetadata statementMetadata;
259
260    public void setStatementMetadata(gudusoft.gsqlparser.resolver2.metadata.StatementMetadata metadata) {
261        this.statementMetadata = metadata;
262    }
263
264    public void setSqlEnv(TSQLEnv sqlEnv) {
265        this.sqlEnv = sqlEnv;
266    }
267
268    /**
269     * Get the TSQLEnv used for metadata lookup.
270     *
271     * @return the SQL environment, or null if not set
272     */
273    public TSQLEnv getSqlEnv() {
274        return sqlEnv;
275    }
276
277    @Override
278    public String getDisplayName() {
279        return alias != null ? alias : "<subquery>";
280    }
281
282    /** Cache-after-completion memo of {@link #computeFinalTable()} (see that method's proof). */
283    private boolean finalTableComputed;
284    private TTable finalTableValue;
285
286    @Override
287    public TTable getFinalTable() {
288        // Memoized. The flag is set ONLY after computeFinalTable() returns, so a
289        // re-entrant call during computation (e.g. a cyclic recursive CTE) still
290        // recomputes exactly as baseline did — no new guard, no behavior change;
291        // if baseline would not terminate, this does not terminate on the first
292        // call either. No invalidation is needed because the result is a pure
293        // function of parse-time-immutable state (see computeFinalTable()).
294        if (finalTableComputed) {
295            return finalTableValue;
296        }
297        TTable result = computeFinalTable();
298        finalTableValue = result;
299        finalTableComputed = true;
300        return result;
301    }
302
303    /**
304     * Determine the underlying physical table a subquery resolves to.
305     *
306     * <p><b>Purity theorem (why {@link #getFinalTable()} may memoize this with no
307     * invalidation).</b> Under resolver2's operating contract — single-threaded
308     * resolution over a parser-produced AST that is frozen for the duration of the
309     * run, with a matcher that is a pure function of its arguments — this method is
310     * a pure function of parse-time-immutable state, so its result never changes
311     * across resolution passes.</p>
312     *
313     * <p>Proof sketch (verified by adversarial review):</p>
314     * <ul>
315     *   <li>This method and its whole call graph ({@link #findTableFromQualifiedStar},
316     *       {@link #scanQualifiedStarMatchTable}, {@link #traceTableThroughCTE},
317     *       {@link #findFirstPhysicalTableFromJoin}, {@link #findTableByAliasOrName},
318     *       and nested {@link #getFinalTable}) read ONLY immutable AST:
319     *       {@code subquery.tables}/{@code getRelations()}, {@code getTableType()},
320     *       {@code isCTEName()}/{@code getCTE()}, {@code getSubquery()},
321     *       {@code getJoinExpr()}/{@code getLeftTable()}, alias/name. They read NONE
322     *       of the mutable namespace fields (inferredColumns, tracedColumnTables,
323     *       columnSources, sourceTable, sqlEnv).</li>
324     *   <li>Resolver2 never mutates those AST properties during a run: it does not
325     *       call setCTE/setCTEName/setTableType/setSubquery, nor add/remove/reorder
326     *       {@code tables}/{@code relations}/result columns. Pass 2+ mutates only
327     *       resolution/inference state, which this method never reads.</li>
328     *   <li>The qualified-star scan's only side effect (marking redundant newline
329     *       tokens deleted in toString) is idempotent after the first call.</li>
330     * </ul>
331     *
332     * <p>The contract's preconditions (frozen AST, deterministic matcher, single
333     * thread) are NOT new: resolver2 already depends on them — an impure matcher or
334     * a mid-resolution AST mutation would corrupt resolution regardless of this
335     * cache. Because {@code TTable}/{@code TResultColumnList} remain publicly
336     * mutable, this is a resolver-internal optimization, not an API-wide theorem.</p>
337     */
338    private TTable computeFinalTable() {
339        // Try to find the underlying table from the FROM clause
340        // This works for both star subqueries (SELECT *) and explicit column subqueries
341        if (subquery != null && subquery.tables != null && subquery.tables.size() > 0) {
342            // First check if there's a qualified star column (e.g., ta.* or tb.*)
343            // If so, we should return the table matching that alias
344            TTable qualifiedStarTable = findTableFromQualifiedStar();
345            if (qualifiedStarTable != null) {
346                return qualifiedStarTable;
347            }
348
349            // For single-table subqueries, return that table
350            // For multi-table (JOIN/comma) subqueries, only return if we can determine source
351            TTable firstTable = subquery.tables.getTable(0);
352            if (firstTable != null) {
353                // If it's a physical table, return it - BUT only if we can identify the source
354                if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
355                    // For multi-table subqueries without qualified star, we can't determine
356                    // which table the column comes from. Don't blindly return first table.
357                    if (subquery.tables.size() > 1 && qualifiedStarTable == null) {
358                        return null;
359                    }
360                    // Check if it's a CTE reference - if so, trace through to physical table
361                    if (firstTable.isCTEName() && firstTable.getCTE() != null) {
362                        return traceTableThroughCTE(firstTable.getCTE());
363                    }
364                    return firstTable;
365                }
366                // If it's a subquery, recursively trace (use cached namespace)
367                if (firstTable.getSubquery() != null) {
368                    SubqueryNamespace nestedNs = getOrCreateNestedNamespace(
369                        firstTable.getSubquery(),
370                        firstTable.getAliasName()
371                    );
372                    return nestedNs != null ? nestedNs.getFinalTable() : null;
373                }
374                // If it's a join, get the first base table from the join
375                if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
376                    return findFirstPhysicalTableFromJoin(firstTable);
377                }
378            }
379        }
380        return null;
381    }
382
383    /**
384     * Find the table referenced by a qualified star column (e.g., ta.* -> table_a).
385     * Returns null if there's no qualified star or if the star is unqualified (*).
386     */
387    private TTable findTableFromQualifiedStar() {
388        // The winning table (first qualified-star column whose prefix resolves to
389        // a table) is a pure function of parse-time-immutable state, so it is
390        // memoized in scanQualifiedStarMatchTable(). The subsequent CTE/subquery
391        // TRACING stays dynamic (it may go through nested namespaces whose final
392        // table can depend on accumulated resolution state), so it is NOT cached.
393        TTable matchingTable = scanQualifiedStarMatchTable();
394        if (matchingTable != null) {
395            // Check for CTE reference first - trace through to physical table
396            if (matchingTable.isCTEName() && matchingTable.getCTE() != null) {
397                return traceTableThroughCTE(matchingTable.getCTE());
398            }
399            // If the matching table is itself a subquery, trace through it (use cached namespace)
400            if (matchingTable.getSubquery() != null) {
401                SubqueryNamespace nestedNs = getOrCreateNestedNamespace(
402                    matchingTable.getSubquery(),
403                    matchingTable.getAliasName()
404                );
405                return nestedNs != null ? nestedNs.getFinalTable() : null;
406            }
407            return matchingTable;
408        }
409        return null;
410    }
411
412    /**
413     * Scan the SELECT list for the first qualified star (e.g. {@code ta.*}) whose
414     * prefix resolves to a table, and return that table (the value baseline acted
415     * on before tracing). Memoized: baseline re-ran this scan on every
416     * getFinalTable() call across every resolution pass — O(passes x columns x
417     * toString) — but the result depends only on immutable state, so it is
418     * computed once per namespace.
419     *
420     * <p>Provably identical to the original inline loop:</p>
421     * <ul>
422     *   <li>Same predicate: the EXACT {@code resultCol.toString().trim()} test
423     *       (ends with {@code "*"} and contains {@code "."}), same prefix
424     *       extraction, same {@link #findTableByAliasOrName}.</li>
425     *   <li>"First non-null match wins" reproduces SubqueryNamespace's baseline,
426     *       which returned unconditionally at the first non-null match. (This does
427     *       NOT hold for CTENamespace, which only stops at a terminal-eligible
428     *       match; CTENamespace is intentionally left unmemoized.)</li>
429     *   <li>Purity holds under resolver2's operating contract: it never mutates a
430     *       subquery's result-column list or a table's alias/name; INameMatcher.matches
431     *       is a pure function of its arguments (all shipped matchers are); and
432     *       TParseTreeNode.toString()'s only side effect (marking redundant newline
433     *       tokens deleted) is idempotent after the first call, so computing the
434     *       scan once leaves the same token state as baseline's repeated calls.</li>
435     * </ul>
436     */
437    private TTable scanQualifiedStarMatchTable() {
438        if (qualifiedStarScanned) {
439            return qualifiedStarMatchTable;
440        }
441        TTable found = null;
442        if (subquery != null && subquery.getResultColumnList() != null) {
443            TResultColumnList selectList = subquery.getResultColumnList();
444            for (int i = 0; i < selectList.size(); i++) {
445                TResultColumn resultCol = selectList.getResultColumn(i);
446                if (resultCol == null) continue;
447
448                String colStr = resultCol.toString().trim();
449                // Check if it's a qualified star (contains . before *)
450                if (colStr.endsWith("*") && colStr.contains(".")) {
451                    // Extract the table alias/name prefix (e.g., "ta" from "ta.*")
452                    int dotIndex = colStr.lastIndexOf('.');
453                    if (dotIndex > 0) {
454                        String tablePrefix = colStr.substring(0, dotIndex).trim();
455                        TTable matchingTable = findTableByAliasOrName(tablePrefix);
456                        if (matchingTable != null) {
457                            found = matchingTable;
458                            break;
459                        }
460                    }
461                }
462            }
463        }
464        qualifiedStarMatchTable = found;
465        qualifiedStarScanned = true;
466        return found;
467    }
468
469    /**
470     * Find a table in the FROM clause by alias or table name.
471     * Also searches through JOIN expressions to find tables like PRD2_ODW.USI
472     * that are joined via LEFT OUTER JOIN.
473     */
474    private TTable findTableByAliasOrName(String nameOrAlias) {
475        if (subquery == null || nameOrAlias == null) {
476            return null;
477        }
478
479        // Use getRelations() to get ALL tables including those in JOINs
480        java.util.ArrayList<TTable> relations = subquery.getRelations();
481        if (relations != null) {
482            for (TTable table : relations) {
483                if (table == null) continue;
484
485                // For JOIN type tables, search inside the join expression
486                if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
487                    TTable found = findTableInJoinExpr(table.getJoinExpr(), nameOrAlias);
488                    if (found != null) {
489                        return found;
490                    }
491                    continue;
492                }
493
494                // Check alias first
495                String alias = table.getAliasName();
496                if (alias != null && nameMatcher.matches(alias, nameOrAlias)) {
497                    return table;
498                }
499
500                // Check table name (for physical tables)
501                if (table.getTableName() != null) {
502                    String tableName = table.getTableName().toString();
503                    if (nameMatcher.matches(tableName, nameOrAlias)) {
504                        return table;
505                    }
506                    // Also check just the table name part (without schema)
507                    String shortName = table.getName();
508                    if (shortName != null && nameMatcher.matches(shortName, nameOrAlias)) {
509                        return table;
510                    }
511                }
512            }
513        }
514
515        // Fallback to subquery.tables if getRelations() didn't find it
516        if (subquery.tables != null) {
517            for (int i = 0; i < subquery.tables.size(); i++) {
518                TTable table = subquery.tables.getTable(i);
519                if (table == null) continue;
520
521                // Check alias first
522                String alias = table.getAliasName();
523                if (alias != null && nameMatcher.matches(alias, nameOrAlias)) {
524                    return table;
525                }
526
527                // Check table name
528                if (table.getTableName() != null) {
529                    String tableName = table.getTableName().toString();
530                    if (nameMatcher.matches(tableName, nameOrAlias)) {
531                        return table;
532                    }
533                }
534            }
535        }
536        return null;
537    }
538
539    /**
540     * Find a table within a JOIN expression by alias or table name.
541     * Recursively searches both left and right sides of the join.
542     */
543    private TTable findTableInJoinExpr(gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, String nameOrAlias) {
544        if (joinExpr == null || nameOrAlias == null) {
545            return null;
546        }
547
548        // Check left table
549        TTable leftTable = joinExpr.getLeftTable();
550        if (leftTable != null) {
551            TTable found = matchTableByAliasOrName(leftTable, nameOrAlias);
552            if (found != null) {
553                return found;
554            }
555            // Recursively check if left is also a join
556            if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && leftTable.getJoinExpr() != null) {
557                found = findTableInJoinExpr(leftTable.getJoinExpr(), nameOrAlias);
558                if (found != null) {
559                    return found;
560                }
561            }
562        }
563
564        // Check right table
565        TTable rightTable = joinExpr.getRightTable();
566        if (rightTable != null) {
567            TTable found = matchTableByAliasOrName(rightTable, nameOrAlias);
568            if (found != null) {
569                return found;
570            }
571            // Recursively check if right is also a join
572            if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && rightTable.getJoinExpr() != null) {
573                found = findTableInJoinExpr(rightTable.getJoinExpr(), nameOrAlias);
574                if (found != null) {
575                    return found;
576                }
577            }
578        }
579
580        return null;
581    }
582
583    /**
584     * Check if a table matches the given alias or name.
585     */
586    private TTable matchTableByAliasOrName(TTable table, String nameOrAlias) {
587        if (table == null || nameOrAlias == null) {
588            return null;
589        }
590
591        // Check alias first
592        String alias = table.getAliasName();
593        if (alias != null && nameMatcher.matches(alias, nameOrAlias)) {
594            return table;
595        }
596
597        // Check table name (for physical tables)
598        if (table.getTableName() != null) {
599            String tableName = table.getTableName().toString();
600            if (nameMatcher.matches(tableName, nameOrAlias)) {
601                return table;
602            }
603            // Also check just the table name part (without schema)
604            // For "PRD2_ODW.USI", getName() returns "USI"
605            String shortName = table.getName();
606            if (shortName != null && nameMatcher.matches(shortName, nameOrAlias)) {
607                return table;
608            }
609        }
610
611        return null;
612    }
613
614    /**
615     * Find the first physical table from a JOIN expression.
616     * Recursively traverses left side of joins.
617     */
618    private TTable findFirstPhysicalTableFromJoin(TTable joinTable) {
619        if (joinTable == null) {
620            return null;
621        }
622
623        gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = joinTable.getJoinExpr();
624        if (joinExpr != null && joinExpr.getLeftTable() != null) {
625            TTable leftTable = joinExpr.getLeftTable();
626
627            if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
628                // Check if it's a CTE reference - trace through if so
629                if (leftTable.isCTEName() && leftTable.getCTE() != null) {
630                    return traceTableThroughCTE(leftTable.getCTE());
631                }
632                return leftTable;
633            }
634            if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
635                return findFirstPhysicalTableFromJoin(leftTable);
636            }
637            // If it's a subquery, trace through it (use cached namespace)
638            if (leftTable.getSubquery() != null) {
639                SubqueryNamespace nestedNs = getOrCreateNestedNamespace(
640                    leftTable.getSubquery(),
641                    leftTable.getAliasName()
642                );
643                return nestedNs != null ? nestedNs.getFinalTable() : null;
644            }
645        }
646        return null;
647    }
648
649    /**
650     * Trace through a CTE to find its underlying physical table.
651     * This handles CTE chains like: CTE1 -> CTE2 -> CTE3 -> physical_table
652     */
653    private TTable traceTableThroughCTE(gudusoft.gsqlparser.nodes.TCTE cteNode) {
654        if (cteNode == null || cteNode.getSubquery() == null) {
655            return null;
656        }
657
658        gudusoft.gsqlparser.stmt.TSelectSqlStatement cteSubquery = cteNode.getSubquery();
659
660        // Handle UNION in the CTE
661        if (cteSubquery.isCombinedQuery()) {
662            // For UNION, trace the left branch
663            gudusoft.gsqlparser.stmt.TSelectSqlStatement leftStmt = cteSubquery.getLeftStmt();
664            if (leftStmt != null && leftStmt.tables != null && leftStmt.tables.size() > 0) {
665                cteSubquery = leftStmt;
666            }
667        }
668
669        if (cteSubquery.tables == null || cteSubquery.tables.size() == 0) {
670            return null;
671        }
672
673        TTable firstTable = cteSubquery.tables.getTable(0);
674        if (firstTable == null) {
675            return null;
676        }
677
678        // If it's a physical table (not CTE), we found it
679        if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname && !firstTable.isCTEName()) {
680            return firstTable;
681        }
682
683        // If it's another CTE reference, continue tracing
684        if (firstTable.isCTEName() && firstTable.getCTE() != null) {
685            return traceTableThroughCTE(firstTable.getCTE());
686        }
687
688        // If it's a subquery, trace through it (use cached namespace)
689        if (firstTable.getSubquery() != null) {
690            SubqueryNamespace nestedNs = getOrCreateNestedNamespace(
691                firstTable.getSubquery(),
692                firstTable.getAliasName()
693            );
694            return nestedNs != null ? nestedNs.getFinalTable() : null;
695        }
696
697        // If it's a join, get the first base table
698        if (firstTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
699            return findFirstPhysicalTableFromJoin(firstTable);
700        }
701
702        return null;
703    }
704
705    @Override
706    public List<TTable> getAllFinalTables() {
707        // For non-UNION subqueries, return the single final table
708        // Multiple tables only make sense for UNION queries where a column
709        // can come from multiple branches (handled by UnionNamespace)
710        TTable finalTable = getFinalTable();
711        if (finalTable != null) {
712            return Collections.singletonList(finalTable);
713        }
714
715        return Collections.emptyList();
716    }
717
718    @Override
719    protected void doValidate() {
720        // Extract columns from SELECT list
721        columnSources = new LinkedHashMap<>();
722
723        TResultColumnList selectList = subquery.getResultColumnList();
724        if (selectList == null) {
725            // A VALUES derived table has no select list; its alias column list names the cells of each row
726            // (Mantis 4819): FROM (VALUES (0, 'G'), (1, 'I')) AS r(rank, type) exposes rank and type.
727            if (aliasColumns != null && subquery.getValueClause() != null && subquery.getValueClause().getRows() != null
728                    && !subquery.getValueClause().getRows().isEmpty()) {
729                TResultColumnList firstRow = subquery.getValueClause().getRows().get(0);
730                for (int i = 0; i < aliasColumns.size() && i < firstRow.size(); i++) {
731                    String colName = aliasColumnName(i);
732                    if (colName != null) {
733                        columnSources.put(colName, new ColumnSource(this, colName, firstRow.getResultColumn(i), 1.0,
734                                "subquery_alias_column_list"));
735                    }
736                }
737            } else if (subquery.getValueClause() != null && subquery.getValueClause().getRows() != null
738                    && !subquery.getValueClause().getRows().isEmpty()) {
739                // Without a column list the vendor names the columns: PostgreSQL column1, column2, ..., Spark SQL and
740                // Databricks col1, col2, ... (Mantis 4823)
741                TResultColumnList firstRow = subquery.getValueClause().getRows().get(0);
742                for (int i = 0; firstRow != null && i < firstRow.size(); i++) {
743                    String colName = gudusoft.gsqlparser.nodes.TValueClause.defaultColumnName(subquery.dbvendor, i + 1);
744                    if (colName == null) {
745                        break;
746                    }
747                    columnSources.put(colName, new ColumnSource(this, colName, firstRow.getResultColumn(i), 1.0,
748                            "values_default_column"));
749                }
750            }
751            return;
752        }
753
754        renamedAwayColumns = new ArrayList<>();
755        int firstStar = -1;
756        for (int i = 0; i < selectList.size() && firstStar < 0; i++) {
757            if (isStarResultColumn(selectList.getResultColumn(i))) {
758                firstStar = i;
759            }
760        }
761
762        for (int i = 0; i < selectList.size(); i++) {
763            TResultColumn resultCol = selectList.getResultColumn(i);
764
765            // Determine column name
766            String colName = selectListColumnName(resultCol, i);
767            // The alias column list renames the derived table's columns by position (Mantis 4814): in
768            // FROM (SELECT z FROM t3) d(y) the exposed column is y, and z is not visible outside. From the first
769            // star on, the positions are the star's expansion: applyAliasColumnsOverStar names them (Mantis 4843).
770            boolean renamed = false;
771            if (!isStarResultColumn(resultCol) && (firstStar < 0 || i < firstStar)) {
772                String aliasName = aliasColumnName(i);
773                if (aliasName != null) {
774                    colName = aliasName;
775                    renamed = true;
776                }
777            }
778
779            ColumnSource source = selectListSource(resultCol, colName);
780            if (renamed) {
781                source.markRenamedByAliasList();
782            }
783            columnSources.put(colName, source);
784        }
785
786        if (aliasColumns != null && firstStar >= 0) {
787            applyAliasColumnsOverStar(selectList, firstStar);
788        }
789    }
790
791    /** The name a SELECT list item exposes before any alias column list: its own name, or col_n. */
792    private String selectListColumnName(TResultColumn resultCol, int i) {
793        String colName = getColumnName(resultCol);
794        // Unnamed expression, use ordinal
795        return colName != null ? colName : "col_" + (i + 1);
796    }
797
798    /** The column source of a SELECT list item exposed as {@code colName}. */
799    private ColumnSource selectListSource(TResultColumn resultCol, String colName) {
800        // Find the source table for this column (if it's a qualified column reference)
801        TTable sourceTable = findSourceTableForResultColumn(resultCol);
802        if (sourceTable != null) {
803            // Definite - from SELECT list, with the override table for proper tracing
804            return new ColumnSource(this, colName, resultCol, 1.0, "subquery_select_list_qualified", sourceTable);
805        }
806        return new ColumnSource(this, colName, resultCol, 1.0, "subquery_select_list");
807    }
808
809    /**
810     * The columns the alias column list renamed out of a star's expansion (Mantis 4843): c1 and c2 in
811     * FROM (SELECT * FROM t3) d(a, b) over t3(c1, c2, c3). They are not columns of the derived table any more. Only
812     * when every star's columns are known: a star that is not may pass on a column of the same name.
813     */
814    private List<String> renamedAwayColumns;
815
816    /**
817     * Applies the alias column list from the first star on (Mantis 4843). The list renames output positions, and the
818     * positions of a star are the columns of the tables it reads, which only metadata orders: over t3(c1, c2, c3),
819     * FROM (SELECT * FROM t3) d(a, b) exposes a (t3.c1), b (t3.c2) and c3, and not c1 or c2. From a star whose
820     * columns are not known on, each remaining alias name is a column of the derived table with no known column
821     * behind it, like a CTE column list over a star, rather than a column of the star's table named like it.
822     */
823    private void applyAliasColumnsOverStar(TResultColumnList selectList, int firstStar) {
824        List<String> renamedMembers = new ArrayList<>();
825        List<String> keptNames = new ArrayList<>();
826        boolean unknownStar = false;
827        int position = firstStar;
828        for (int i = firstStar; i < selectList.size(); i++) {
829            TResultColumn resultCol = selectList.getResultColumn(i);
830            if (!isStarResultColumn(resultCol)) {
831                String aliasName = unknownStar ? null : aliasColumnName(position++);
832                if (aliasName != null) {
833                    columnSources.remove(selectListColumnName(resultCol, i));
834                    ColumnSource renamed = selectListSource(resultCol, aliasName);
835                    renamed.markRenamedByAliasList();
836                    columnSources.put(aliasName, renamed);
837                } else {
838                    keptNames.add(selectListColumnName(resultCol, i));
839                }
840                continue;
841            }
842            List<String> members = new ArrayList<>();
843            List<TTable> memberTables = new ArrayList<>();
844            if (unknownStar || !starMembers(resultCol, members, memberTables)) {
845                for (; !unknownStar && position < aliasColumns.size(); position++) {
846                    String aliasName = aliasColumnName(position);
847                    if (aliasName != null) {
848                        columnSources.put(aliasName, new ColumnSource(this, aliasName, resultCol, 1.0,
849                                ColumnSource.EVIDENCE_ALIAS_COLUMN_VIA_STAR));
850                    }
851                }
852                unknownStar = true;
853                continue;
854            }
855            for (int m = 0; m < members.size(); m++, position++) {
856                String aliasName = aliasColumnName(position);
857                if (aliasName == null) {
858                    keptNames.add(members.get(m));
859                    continue;
860                }
861                ColumnSource renamed = new ColumnSource(this, aliasName, null, 1.0, "subquery_alias_column_list_star",
862                        memberTables.get(m));
863                renamed.markRenamedByAliasList();
864                renamed.setStarMemberName(members.get(m));
865                columnSources.put(aliasName, renamed);
866                renamedMembers.add(members.get(m));
867            }
868        }
869        if (unknownStar) {
870            return;
871        }
872        // t1.c1 renamed in (SELECT t1.*, t2.* FROM t1, t2) d(a) leaves t2's c1 exposed
873        for (String member : renamedMembers) {
874            boolean kept = false;
875            for (String name : keptNames) {
876                kept |= nameMatcher.matches(name, member);
877            }
878            if (!kept) {
879                renamedAwayColumns.add(member);
880            }
881        }
882    }
883
884    /** The joins whose result is the columns of the left side followed by those of the right side. */
885    private static final Set<gudusoft.gsqlparser.EJoinType> CONCATENATING_JOINS = java.util.EnumSet.of(
886            gudusoft.gsqlparser.EJoinType.join, gudusoft.gsqlparser.EJoinType.inner,
887            gudusoft.gsqlparser.EJoinType.cross, gudusoft.gsqlparser.EJoinType.straight,
888            gudusoft.gsqlparser.EJoinType.left, gudusoft.gsqlparser.EJoinType.leftouter,
889            gudusoft.gsqlparser.EJoinType.right, gudusoft.gsqlparser.EJoinType.rightouter,
890            gudusoft.gsqlparser.EJoinType.full, gudusoft.gsqlparser.EJoinType.fullouter);
891
892    /**
893     * The columns a star expands to, in order, each with the table it comes from (Mantis 4843): the columns of every
894     * table the star reads (only the named one for t.*), in FROM order. Known only when each of them is a table whose
895     * metadata lists every column and every join puts the columns of both sides side by side (not USING, NATURAL, a
896     * semi or anti join); false otherwise.
897     */
898    private boolean starMembers(TResultColumn star, List<String> names, List<TTable> tables) {
899        gudusoft.gsqlparser.nodes.TObjectName operand = star.getExpr() == null ? null : star.getExpr().getObjectOperand();
900        if (operand == null || operand.getPartToken() == null
901                || operand.getPartToken().tokentype != gudusoft.gsqlparser.ETokenType.ttasterisk) {
902            return false; // a dialect-specific expansion
903        }
904        if (star.getExceptColumnList() != null
905                || (star.getReplaceExprAsIdentifiers() != null && !star.getReplaceExprAsIdentifiers().isEmpty())
906                || (star.getExprAsIdentifiers() != null && !star.getExprAsIdentifiers().isEmpty())) {
907            return false; // * EXCEPT (...), * REPLACE (...): not the tables' columns as they are
908        }
909        String qualifier = operand.getTableToken() == null ? null : operand.getTableToken().getAstext();
910        List<TTable> inputs = new ArrayList<>();
911        if (subquery.getRelations() == null) {
912            return false;
913        }
914        Deque<TTable> pending = new ArrayDeque<>();
915        for (int i = subquery.getRelations().size() - 1; i >= 0; i--) {
916            pending.push(subquery.getRelations().get(i));
917        }
918        while (!pending.isEmpty()) {
919            TTable input = pending.pop();
920            gudusoft.gsqlparser.nodes.TJoinExpr join = input.getJoinExpr();
921            if (join == null) {
922                inputs.add(input);
923                continue;
924            }
925            if ((join.getUsingColumns() != null && join.getUsingColumns().size() > 0)
926                    || !CONCATENATING_JOINS.contains(join.getJointype())) {
927                return false;
928            }
929            if (join.getRightTable() != null) {
930                pending.push(join.getRightTable());
931            }
932            if (join.getLeftTable() != null) {
933                pending.push(join.getLeftTable());
934            }
935        }
936        boolean matched = false;
937        for (TTable input : inputs) {
938            if (qualifier != null && matchTableByAliasOrName(input, qualifier) == null) {
939                continue;
940            }
941            matched = true;
942            if (input.getTableType() != gudusoft.gsqlparser.ETableSource.objectname || input.getCTE() != null) {
943                return false;
944            }
945            TableNamespace columns = getOrCreateTableNamespace(input);
946            if (columns == null || columns.getMetadataState() != MetadataState.FOUND
947                    || (columns.getResolvedTable() != null && !columns.getResolvedTable().hasAllColumnsKnown())) {
948                return false;
949            }
950            for (ColumnSource column : columns.getAllColumnSources().values()) {
951                names.add(column.getExposedName());
952                tables.add(input);
953            }
954        }
955        return matched;
956    }
957
958    /** Whether the alias column list renamed {@code columnName} out of a star's expansion (Mantis 4843). */
959    private boolean isRenamedAway(String columnName) {
960        ensureValidated();
961        if (renamedAwayColumns == null || columnName == null) {
962            return false;
963        }
964        for (String name : renamedAwayColumns) {
965            if (nameMatcher.matches(name, columnName)) {
966                return true;
967            }
968        }
969        return false;
970    }
971
972    /**
973     * Find the source table for a result column's expression.
974     * For qualified column references like SUBS_CUST.FIRST_TP_ID, this returns
975     * the SUBS_CUST table from the FROM clause.
976     * For star columns like SUBSCR.*, this returns the SUBSCR table.
977     *
978     * <p>IMPORTANT: For subquery aliases, we return the subquery's TTable directly
979     * and do NOT trace through. The column should be attributed to the subquery
980     * alias (e.g., SUBS_CUST.FIRST_TP_ID), not to tables inside the subquery.</p>
981     *
982     * @param resultCol The result column to analyze
983     * @return The source table, or null if not determinable
984     */
985    private TTable findSourceTableForResultColumn(TResultColumn resultCol) {
986        if (resultCol == null || resultCol.getExpr() == null) {
987            return null;
988        }
989
990        gudusoft.gsqlparser.nodes.TExpression expr = resultCol.getExpr();
991
992        // Handle simple column reference (e.g., SUBS_CUST.FIRST_TP_ID or FIRST_TP_ID)
993        if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
994            gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand();
995            if (objName != null) {
996                String tablePrefix = objName.getTableString();
997                if (tablePrefix != null && !tablePrefix.isEmpty()) {
998                    // Qualified reference - find the table
999                    TTable table = findTableByAliasOrName(tablePrefix);
1000                    if (table != null) {
1001                        // Return the table directly - don't trace through subqueries
1002                        // This keeps columns attributed to their immediate source
1003                        // (e.g., SUBS_CUST.FIRST_TP_ID stays as SUBS_CUST.FIRST_TP_ID)
1004                        return table;
1005                    }
1006                }
1007            }
1008        }
1009
1010        // Handle star column (e.g., SUBSCR.* or *)
1011        String colStr = resultCol.toString().trim();
1012        if (colStr.endsWith("*") && colStr.contains(".")) {
1013            // Qualified star - find the table
1014            int dotIndex = colStr.lastIndexOf('.');
1015            if (dotIndex > 0) {
1016                String tablePrefix = colStr.substring(0, dotIndex).trim();
1017                TTable table = findTableByAliasOrName(tablePrefix);
1018                if (table != null) {
1019                    // For star columns, we DO trace through to get all underlying columns
1020                    // This is different from regular columns because star needs to expand (use cached namespace)
1021                    if (table.getSubquery() != null) {
1022                        SubqueryNamespace nestedNs = getOrCreateNestedNamespace(table.getSubquery(), table.getAliasName(), aliasColumnsOf(table));
1023                        return nestedNs != null ? nestedNs.getFinalTable() : null;
1024                    }
1025                    return table;
1026                }
1027            }
1028        }
1029
1030        return null;
1031    }
1032
1033    /**
1034     * Extract column name from TResultColumn.
1035     * Handles aliases and expression columns.
1036     */
1037    private String getColumnName(TResultColumn resultCol) {
1038        // Check for alias
1039        if (resultCol.getAliasClause() != null &&
1040            resultCol.getAliasClause().getAliasName() != null) {
1041            return resultCol.getAliasClause().getAliasName().toString();
1042        }
1043
1044        // Check for simple column reference
1045        if (resultCol.getExpr() != null) {
1046            gudusoft.gsqlparser.nodes.TExpression expr = resultCol.getExpr();
1047            // Check if it's a simple object reference
1048            if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
1049                gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand();
1050                if (objName != null) {
1051                    return objName.getColumnNameOnly();
1052                }
1053            }
1054        }
1055
1056        // Complex expression - no name
1057        return null;
1058    }
1059
1060    @Override
1061    public ColumnLevel hasColumn(String columnName) {
1062        ensureValidated();
1063
1064        // Check in SELECT list (explicit columns) — matcher-aware.
1065        if (containsColumnByMatcher(columnSources, columnName)) {
1066            return ColumnLevel.EXISTS;
1067        }
1068
1069        // Check in inferred columns (from star push-down). The map is raw-
1070        // keyed (= ColumnSource.exposedName); the matcher-aware helper
1071        // applies per-dialect rules including SQL Server COLLATION_BASED.
1072        if (containsColumnByMatcher(inferredColumns, columnName)) {
1073            return ColumnLevel.EXISTS;
1074        }
1075
1076        // If subquery has SELECT *, unknown columns MAYBE exist
1077        // They need to be resolved through star push-down
1078        if (hasStarColumn()) {
1079            return starColumnAvailability(columnName);
1080        }
1081
1082        return ColumnLevel.NOT_EXISTS;
1083    }
1084
1085    public TSelectSqlStatement getSubquery() {
1086        return subquery;
1087    }
1088
1089    @Override
1090    public TSelectSqlStatement getSelectStatement() {
1091        return subquery;
1092    }
1093
1094    @Override
1095    public boolean hasStarColumn() {
1096        if (subquery == null || subquery.getResultColumnList() == null) {
1097            return false;
1098        }
1099
1100        TResultColumnList selectList = subquery.getResultColumnList();
1101        for (int i = 0; i < selectList.size(); i++) {
1102            TResultColumn resultCol = selectList.getResultColumn(i);
1103            if (isStarResultColumn(resultCol)) {
1104                return true;
1105            }
1106        }
1107        return false;
1108    }
1109
1110    static boolean isStarResultColumn(TResultColumn resultCol) {
1111        if (resultCol == null) {
1112            return false;
1113        }
1114        if (resultCol.getExceptColumnList() != null
1115                || (resultCol.getReplaceExprAsIdentifiers() != null
1116                    && !resultCol.getReplaceExprAsIdentifiers().isEmpty())
1117                || (resultCol.getExprAsIdentifiers() != null
1118                    && !resultCol.getExprAsIdentifiers().isEmpty())) {
1119            return true;
1120        }
1121        if (resultCol.getExpr() != null
1122                && resultCol.getExpr().getExpressionType() == EExpressionType.simple_object_name_t
1123                && resultCol.getExpr().getObjectOperand() != null) {
1124            String starText = resultCol.getExpr().getObjectOperand().toString();
1125            if (starText != null) {
1126                starText = starText.trim();
1127                if ("*".equals(starText) || starText.endsWith(".*")) {
1128                    return true;
1129                }
1130            }
1131        }
1132        String text = resultCol.toString();
1133        if (text == null) {
1134            return false;
1135        }
1136        text = text.trim();
1137        return "*".equals(text) || text.endsWith(".*");
1138    }
1139
1140    /**
1141     * Slice S4 (plan §5.5): a derived subquery's projection is authoritative
1142     * once validated AND at least one named (non-star) column exists. A
1143     * subquery whose only projection is {@code SELECT *} is reported as
1144     * METADATA_UNAVAILABLE here — S10 refines this when the underlying tables'
1145     * star expansion fully resolves into named columns.
1146     */
1147    @Override
1148    public MetadataState getMetadataState() {
1149        ensureValidated();
1150        if (columnSources != null && !columnSources.isEmpty() && !hasStarColumn()) {
1151            return MetadataState.FOUND;
1152        }
1153        return MetadataState.METADATA_UNAVAILABLE;
1154    }
1155
1156    /**
1157     * Binding-diagnostic view of the derived-table output schema.
1158     *
1159     * <p>This deliberately avoids the legacy resolution fallbacks in
1160     * {@link #resolveColumn(String)}. Binding diagnostics need the SQL-visible
1161     * derived projection: named SELECT-list columns and already inferred star
1162     * output columns are visible; hidden base-table columns are not. When the
1163     * projection includes a star and the requested column is not already known,
1164     * the output is not fully authoritative, so callers must not report a
1165     * missing-output error.</p>
1166     */
1167    public ColumnLevel hasAuthoritativeOutputColumn(String columnName) {
1168        ensureValidated();
1169
1170        if (columnName == null || columnName.isEmpty()) {
1171            return ColumnLevel.MAYBE;
1172        }
1173
1174        if (containsColumnByMatcher(columnSources, columnName)) {
1175            return ColumnLevel.EXISTS;
1176        }
1177
1178        if (containsColumnByMatcher(inferredColumns, columnName)) {
1179            return ColumnLevel.EXISTS;
1180        }
1181
1182        if (hasStarColumn()) {
1183            return starColumnAvailability(columnName);
1184        }
1185
1186        if (columnSources != null && !columnSources.isEmpty()) {
1187            return ColumnLevel.NOT_EXISTS;
1188        }
1189
1190        return ColumnLevel.MAYBE;
1191    }
1192
1193    /**
1194     * Get the first star column (TResultColumn) from this subquery's SELECT list.
1195     * Used to track the definition node for columns inferred from the star.
1196     *
1197     * @return The star column, or null if no star column exists
1198     */
1199    public TResultColumn getStarColumn() {
1200        if (subquery == null || subquery.getResultColumnList() == null) {
1201            return null;
1202        }
1203
1204        TResultColumnList selectList = subquery.getResultColumnList();
1205        for (int i = 0; i < selectList.size(); i++) {
1206            TResultColumn resultCol = selectList.getResultColumn(i);
1207            if (isStarResultColumn(resultCol)) {
1208                return resultCol;
1209            }
1210        }
1211        return null;
1212    }
1213
1214    /**
1215     * Check if this subquery has an unqualified star with multiple tables.
1216     * In this case, columns are ambiguous and should NOT be auto-resolved.
1217     *
1218     * Example:
1219     * SELECT * FROM table_a, table_c  -- ambiguous, columns could come from either table
1220     * SELECT ta.* FROM table_a ta, table_c tc  -- NOT ambiguous, star is qualified
1221     * SELECT * FROM table_a JOIN table_c ON ... -- ambiguous, columns from both tables
1222     *
1223     * Uses getRelations() and TJoinExpr to properly count tables in JOINs.
1224     */
1225    public boolean hasAmbiguousStar() {
1226        if (subquery == null || subquery.getResultColumnList() == null) {
1227            return false;
1228        }
1229
1230        // Count all sources (tables, subqueries, joins) using getRelations()
1231        int sourceCount = 0;
1232        java.util.ArrayList<TTable> relations = subquery.getRelations();
1233        if (relations != null) {
1234            for (TTable table : relations) {
1235                if (table == null) continue;
1236                if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1237                    // For JOIN type, count tables from the JoinExpr
1238                    List<TTable> joinTables = new ArrayList<>();
1239                    collectPhysicalTablesFromJoinExpr(table.getJoinExpr(), joinTables);
1240                    sourceCount += joinTables.size();
1241                } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1242                    sourceCount++;
1243                } else if (table.getSubquery() != null) {
1244                    // Subqueries also count as sources
1245                    sourceCount++;
1246                }
1247            }
1248        }
1249
1250        // If only one source, not ambiguous
1251        if (sourceCount <= 1) {
1252            return false;
1253        }
1254
1255        // Check for unqualified star (just "*" without table prefix)
1256        TResultColumnList selectList = subquery.getResultColumnList();
1257        for (int i = 0; i < selectList.size(); i++) {
1258            TResultColumn resultCol = selectList.getResultColumn(i);
1259            if (resultCol == null) continue;
1260
1261            String colStr = resultCol.toString().trim();
1262            // Check if it's an unqualified star (just "*", not "ta.*")
1263            if (colStr.equals("*")) {
1264                return true;
1265            }
1266        }
1267
1268        return false;
1269    }
1270
1271    /**
1272     * Count the number of "real" tables in the FROM clause, excluding implicit lateral
1273     * derived tables (Teradata feature where references to undeclared tables in WHERE
1274     * clause create implicit table references).
1275     *
1276     * @return The count of real tables (excluding implicit lateral derived tables)
1277     */
1278    private int countRealTablesInFromClause() {
1279        if (subquery == null || subquery.tables == null) {
1280            return 0;
1281        }
1282        int count = 0;
1283        for (int i = 0; i < subquery.tables.size(); i++) {
1284            TTable table = subquery.tables.getTable(i);
1285            if (table != null &&
1286                table.getEffectType() != gudusoft.gsqlparser.ETableEffectType.tetImplicitLateralDerivedTable) {
1287                count++;
1288            }
1289        }
1290        return count;
1291    }
1292
1293    /**
1294     * Get the single real table from the FROM clause when there's exactly one.
1295     * This is used as a fallback for star column push-down when resolveColumnInFromScope()
1296     * can't find the column (e.g., no SQLEnv metadata).
1297     *
1298     * @return The single real table, or null if there's not exactly one
1299     */
1300    private TTable getSingleRealTableFromFromClause() {
1301        if (subquery == null || subquery.tables == null) {
1302            return null;
1303        }
1304        TTable result = null;
1305        for (int i = 0; i < subquery.tables.size(); i++) {
1306            TTable table = subquery.tables.getTable(i);
1307            if (table != null &&
1308                table.getEffectType() != gudusoft.gsqlparser.ETableEffectType.tetImplicitLateralDerivedTable) {
1309                if (result != null) {
1310                    // Multiple tables - can't determine which one
1311                    return null;
1312                }
1313                result = table;
1314            }
1315        }
1316        // If the single table is itself a subquery, trace through it (use cached namespace)
1317        if (result != null && result.getSubquery() != null) {
1318            SubqueryNamespace nestedNs = getOrCreateNestedNamespace(result.getSubquery(), result.getAliasName(), aliasColumnsOf(result));
1319            return nestedNs != null ? nestedNs.getFinalTable() : null;
1320        }
1321
1322        // If the single table is a CTE reference, trace through the CTE to find the physical table
1323        // This is critical for star column push-down when SELECT * FROM cte_name
1324        if (result != null && result.isCTEName() && result.getCTE() != null) {
1325            gudusoft.gsqlparser.nodes.TCTE cte = result.getCTE();
1326            if (cte.getSubquery() != null) {
1327                // Create a temporary CTENamespace to trace through
1328                CTENamespace cteNs = new CTENamespace(
1329                    cte,
1330                    cte.getTableName() != null ? cte.getTableName().toString() : result.getName(),
1331                    cte.getSubquery(),
1332                    nameMatcher
1333                );
1334                cteNs.setReferencingTable(result);
1335                cteNs.validate();
1336                TTable tracedTable = cteNs.getFinalTable();
1337                // Return the physical table if found, otherwise return the CTE table as fallback
1338                return tracedTable != null ? tracedTable : result;
1339            }
1340        }
1341
1342        return result;
1343    }
1344
1345    @Override
1346    public boolean supportsDynamicInference() {
1347        // Support dynamic inference if this subquery has SELECT *
1348        return hasStarColumn();
1349    }
1350
1351    @Override
1352    public boolean addInferredColumn(String columnName, double confidence, String evidence) {
1353        if (columnName == null || columnName.isEmpty()) {
1354            return false;
1355        }
1356        if (hasStarColumn() && starColumnAvailability(columnName) == ColumnLevel.NOT_EXISTS) {
1357            return false;
1358        }
1359
1360        // Initialize maps if needed
1361        if (inferredColumns == null) {
1362            inferredColumns = new LinkedHashMap<>();
1363        }
1364        if (inferredColumnNames == null) {
1365            inferredColumnNames = new HashSet<>();
1366        }
1367
1368        // Slice S1: dedupe through the matcher-aware helper so per-vendor
1369        // identifier rules govern whether two case-only-different inputs are
1370        // the same column. Without this, BigQuery / MySQL / SQL Server would
1371        // accept "MyCol" and "MYCOL" as two separate inferred entries and
1372        // downstream lookups would non-deterministically pick one of them.
1373        // Codex round 2: storage uses the raw (exposedName) key — two
1374        // matcher-distinct identifiers that happen to normalize equally
1375        // (Postgres "mycol" vs unquoted MYCOL) keep separate entries.
1376        if (containsColumnByMatcher(columnSources, columnName)) {
1377            return false;
1378        }
1379        if (containsColumnByMatcher(inferredColumns, columnName)) {
1380            return false;
1381        }
1382
1383        // For star columns (SELECT *), trace the column to its underlying table.
1384        // This is critical for Teradata UPDATE...FROM...SET syntax where columns
1385        // reference subqueries with SELECT *.
1386        //
1387        // IMPORTANT: Only trace when there's exactly ONE real table (excluding implicit
1388        // lateral derived tables). If there are multiple tables, the column source
1389        // is ambiguous unless the star is qualified (handled elsewhere).
1390        TTable overrideTable = null;
1391        if (hasStarColumn() && !hasAmbiguousStar()) {
1392            // Count real tables (excluding implicit lateral derived tables)
1393            int realTableCount = countRealTablesInFromClause();
1394            if (realTableCount == 1) {
1395                ColumnSource fromSource = resolveColumnInFromScope(columnName);
1396                if (fromSource != null) {
1397                    // Get the table from the underlying source
1398                    overrideTable = fromSource.getFinalTable();
1399                    if (overrideTable == null && fromSource.getSourceNamespace() instanceof TableNamespace) {
1400                        // Try to get table directly from TableNamespace
1401                        overrideTable = ((TableNamespace) fromSource.getSourceNamespace()).getTable();
1402                    }
1403                }
1404
1405                // Fallback: If resolveColumnInFromScope couldn't find the column (no SQLEnv metadata),
1406                // but we have exactly one table with SELECT *, assume the column comes from that table.
1407                // This is the expected behavior for star column push-down in most real-world scenarios
1408                // where the column name is not provable but is inferred from the context.
1409                if (overrideTable == null) {
1410                    overrideTable = getSingleRealTableFromFromClause();
1411                }
1412            }
1413        }
1414
1415        // Create inferred column source with overrideTable if found
1416        // Note: Do NOT set the star column as definitionNode here.
1417        // The definitionNode is used by the formatter to attribute columns to tables.
1418        // Star-inferred columns should be attributed based on the overrideTable, not the star column.
1419        // The legacy sourceColumn for star-inferred columns should remain null since they don't
1420        // have a direct 1:1 relationship with a specific TResultColumn.
1421        ColumnSource source = new ColumnSource(
1422            this,
1423            columnName,
1424            null,  // No definition node for inferred columns
1425            confidence,
1426            evidence,
1427            overrideTable
1428        );
1429
1430        inferredColumns.put(columnName, source);
1431        inferredColumnNames.add(columnName);
1432        return true;
1433    }
1434
1435    @Override
1436    public Set<String> getInferredColumns() {
1437        if (inferredColumnNames == null) {
1438            return java.util.Collections.emptySet();
1439        }
1440        return java.util.Collections.unmodifiableSet(inferredColumnNames);
1441    }
1442
1443    @Override
1444    public ColumnSource resolveColumn(String columnName) {
1445        ensureValidated();
1446
1447        // First check explicit columns from this subquery's SELECT list
1448        ColumnSource source = super.resolveColumn(columnName);
1449        if (source != null) {
1450            return source;
1451        }
1452
1453        // Then check inferred columns. Slice S1 + codex round 2: the map is
1454        // raw-keyed (= ColumnSource.exposedName), so the exact-match probe
1455        // is O(1) for the same identifier queried again. Matcher loop walks
1456        // values via getExposedName() so quote state is preserved.
1457        if (inferredColumns != null) {
1458            ColumnSource exact = inferredColumns.get(columnName);
1459            if (exact != null) {
1460                return exact;
1461            }
1462            for (ColumnSource entry : inferredColumns.values()) {
1463                String exposed = entry != null ? entry.getExposedName() : null;
1464                if (exposed != null && nameMatcher.matches(exposed, columnName)) {
1465                    return entry;
1466                }
1467            }
1468        }
1469
1470        // A star cannot introduce a column absent from every authoritative input.
1471        // Check before any inference/guess fallback, including multi-table stars.
1472        if (hasStarColumn() && starColumnAvailability(columnName) == ColumnLevel.NOT_EXISTS) {
1473            return null;
1474        }
1475
1476        // For ambiguous star cases, try to find the column in explicit subquery sources
1477        // This handles cases like: SELECT * FROM table_a ta, (SELECT col1 FROM table_c) tc
1478        // where col1 can be uniquely traced to tc -> table_c
1479        if (hasAmbiguousStar()) {
1480            ColumnSource explicitSource = findColumnInExplicitSources(columnName);
1481            if (explicitSource != null) {
1482                return explicitSource;
1483            }
1484
1485            // Try to trace the column through qualified references in the FROM clause
1486            // This handles JOINs where ON condition uses qualified names like table1.x
1487            ColumnSource tracedSource = traceColumnThroughQualifiedReferences(columnName);
1488            if (tracedSource != null) {
1489                return tracedSource;
1490            }
1491
1492            // Try to resolve the column using sqlenv metadata from the FROM clause tables
1493            // This uses TableNamespace.resolveColumn() which has access to sqlenv
1494            ColumnSource fromScopeSource = resolveColumnInFromScope(columnName);
1495            if (fromScopeSource != null) {
1496                return fromScopeSource;
1497            }
1498
1499            // Column not found in any explicit source, and we have multiple physical tables
1500            // Apply GUESS_COLUMN_STRATEGY to pick a table or leave unresolved
1501            return applyGuessColumnStrategy(columnName);
1502        }
1503
1504        // If hasColumn returns MAYBE (we have SELECT *), auto-infer this column
1505        // This enables on-demand inference during resolution
1506        if (hasStarColumn()) {
1507            // Add as inferred column with moderate confidence
1508            // The confidence is lower because we're inferring from outer reference
1509            boolean added = addInferredColumn(columnName, 0.8, "auto_inferred_from_outer_reference");
1510            if (added && inferredColumns != null) {
1511                ColumnSource inferredSource = inferredColumns.get(columnName);
1512                if (inferredSource != null) {
1513                    return inferredSource;
1514                }
1515            }
1516        }
1517
1518        return null;
1519    }
1520
1521    /**
1522     * Negative evidence from the inputs actually projected by each star. Unknown
1523     * inputs still permit inference; a qualified star does not expose other inputs.
1524     * This deliberately does not turn a multi-input match into a unique binding.
1525     */
1526    private ColumnLevel starColumnAvailability(String columnName) {
1527        if (subquery == null || subquery.getResultColumnList() == null) return ColumnLevel.MAYBE;
1528        if (isRenamedAway(columnName)) return ColumnLevel.NOT_EXISTS; // renamed by the alias column list (Mantis 4843)
1529        List<TTable> inputs = new ArrayList<>();
1530        Deque<TTable> pending = new ArrayDeque<>();
1531        if (subquery.getRelations() != null) pending.addAll(subquery.getRelations());
1532        while (!pending.isEmpty()) {
1533            TTable input = pending.removeFirst();
1534            if (input.getJoinExpr() != null) {
1535                if (input.getJoinExpr().getLeftTable() != null) pending.add(input.getJoinExpr().getLeftTable());
1536                if (input.getJoinExpr().getRightTable() != null) pending.add(input.getJoinExpr().getRightTable());
1537            } else {
1538                inputs.add(input);
1539            }
1540        }
1541        boolean projected = false;
1542        for (int i = 0; i < subquery.getResultColumnList().size(); i++) {
1543            TResultColumn result = subquery.getResultColumnList().getResultColumn(i);
1544            if (!isStarResultColumn(result)) continue;
1545            gudusoft.gsqlparser.nodes.TObjectName star = result.getExpr() == null
1546                    ? null : result.getExpr().getObjectOperand();
1547            if (star == null || star.getPartToken() == null
1548                    || star.getPartToken().tokentype != gudusoft.gsqlparser.ETokenType.ttasterisk) {
1549                return ColumnLevel.MAYBE; // dialect-specific expansion, not a simple star
1550            }
1551            String qualifier = star.getTableToken() == null ? null : star.getTableToken().getAstext();
1552            boolean matched = false;
1553            for (TTable input : inputs) {
1554                if (qualifier != null && matchTableByAliasOrName(input, qualifier) == null) continue;
1555                matched = true;
1556                projected = true;
1557                ColumnLevel level = ColumnLevel.MAYBE;
1558                if (input.getSubquery() != null) {
1559                    SubqueryNamespace nested = getOrCreateNestedNamespace(input.getSubquery(), input.getAliasName(), aliasColumnsOf(input));
1560                    if (nested != null) level = nested.hasColumn(columnName);
1561                } else if (input.getTableType() == gudusoft.gsqlparser.ETableSource.objectname
1562                        && input.getCTE() == null) {
1563                    TableNamespace nested = getOrCreateTableNamespace(input);
1564                    if (nested != null && nested.hasMetadata()) level = nested.hasColumn(columnName);
1565                }
1566                if (level != ColumnLevel.NOT_EXISTS) return ColumnLevel.MAYBE;
1567            }
1568            if (!matched) return ColumnLevel.MAYBE;
1569        }
1570        return projected ? ColumnLevel.NOT_EXISTS : ColumnLevel.MAYBE;
1571    }
1572
1573    /**
1574     * Resolve a column in the FROM scope (child namespaces).
1575     * This finds where a column originates from in the FROM clause tables/subqueries.
1576     *
1577     * <p>Unlike resolveColumn() which returns columns from THIS subquery's SELECT list,
1578     * this method looks into the FROM clause to find the underlying definition.</p>
1579     *
1580     * <p>IMPORTANT: For TableNamespace, this only returns columns that exist in
1581     * actual metadata (from DDL or SQLEnv). It does NOT use inferred columns.
1582     * This is critical for multi-table star resolution where we need to know
1583     * which table actually has the column based on metadata, not inference.</p>
1584     *
1585     * @param columnName The column name to find
1586     * @return ColumnSource from the FROM clause, or null if not found
1587     */
1588    public ColumnSource resolveColumnInFromScope(String columnName) {
1589        if (subquery == null || subquery.tables == null || columnName == null) {
1590            return null;
1591        }
1592
1593        // Search through tables in the FROM clause
1594        for (int i = 0; i < subquery.tables.size(); i++) {
1595            TTable table = subquery.tables.getTable(i);
1596            if (table == null) continue;
1597
1598            // For subqueries, look in their column sources (use cached namespace)
1599            if (table.getSubquery() != null) {
1600                SubqueryNamespace nestedNs = getOrCreateNestedNamespace(table.getSubquery(), table.getAliasName(), aliasColumnsOf(table));
1601                if (nestedNs != null) {
1602                    ColumnSource source = nestedNs.resolveColumn(columnName);
1603                    if (source != null) {
1604                        return source;
1605                    }
1606                }
1607            }
1608
1609            // For physical tables, create a TableNamespace and resolve (use cached namespace)
1610            // IMPORTANT: Only return column if the table has actual metadata (DDL or SQLEnv)
1611            // This prevents inferring columns for ambiguous star resolution
1612            if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1613                TableNamespace tableNs = getOrCreateTableNamespace(table);
1614                if (tableNs != null && tableNs.hasMetadata()) {
1615                    ColumnSource source = tableNs.resolveColumn(columnName);
1616                    if (source != null) {
1617                        return source;
1618                    }
1619                }
1620            }
1621
1622            // For joins, recursively search
1623            if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1624                ColumnSource source = resolveColumnInJoin(table.getJoinExpr(), columnName);
1625                if (source != null) {
1626                    return source;
1627                }
1628            }
1629        }
1630
1631        return null;
1632    }
1633
1634    /**
1635     * Resolve a column within a JOIN expression.
1636     * Only returns columns from tables with actual metadata (DDL or SQLEnv).
1637     */
1638    private ColumnSource resolveColumnInJoin(gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, String columnName) {
1639        if (joinExpr == null) return null;
1640
1641        // Check left table (use cached namespaces)
1642        TTable leftTable = joinExpr.getLeftTable();
1643        if (leftTable != null) {
1644            if (leftTable.getSubquery() != null) {
1645                SubqueryNamespace nestedNs = getOrCreateNestedNamespace(leftTable.getSubquery(), leftTable.getAliasName(), aliasColumnsOf(leftTable));
1646                if (nestedNs != null) {
1647                    ColumnSource source = nestedNs.resolveColumn(columnName);
1648                    if (source != null) {
1649                        return source;
1650                    }
1651                }
1652            } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1653                TableNamespace tableNs = getOrCreateTableNamespace(leftTable);
1654                // Only return if table has metadata - don't use inferred columns
1655                if (tableNs != null && tableNs.hasMetadata()) {
1656                    ColumnSource source = tableNs.resolveColumn(columnName);
1657                    if (source != null) {
1658                        return source;
1659                    }
1660                }
1661            } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1662                ColumnSource source = resolveColumnInJoin(leftTable.getJoinExpr(), columnName);
1663                if (source != null) {
1664                    return source;
1665                }
1666            }
1667        }
1668
1669        // Check right table (use cached namespaces)
1670        TTable rightTable = joinExpr.getRightTable();
1671        if (rightTable != null) {
1672            if (rightTable.getSubquery() != null) {
1673                SubqueryNamespace nestedNs = getOrCreateNestedNamespace(rightTable.getSubquery(), rightTable.getAliasName(), aliasColumnsOf(rightTable));
1674                if (nestedNs != null) {
1675                    ColumnSource source = nestedNs.resolveColumn(columnName);
1676                    if (source != null) {
1677                        return source;
1678                    }
1679                }
1680            } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1681                TableNamespace tableNs = getOrCreateTableNamespace(rightTable);
1682                // Only return if table has metadata - don't use inferred columns
1683                if (tableNs != null && tableNs.hasMetadata()) {
1684                    ColumnSource source = tableNs.resolveColumn(columnName);
1685                    if (source != null) {
1686                        return source;
1687                    }
1688                }
1689            } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1690                ColumnSource source = resolveColumnInJoin(rightTable.getJoinExpr(), columnName);
1691                if (source != null) {
1692                    return source;
1693                }
1694            }
1695        }
1696
1697        return null;
1698    }
1699
1700    /**
1701     * Apply GUESS_COLUMN_STRATEGY to pick a table for an ambiguous column.
1702     * Used when a star column could come from multiple tables.
1703     *
1704     * For NOT_PICKUP strategy, returns a ColumnSource with all candidate tables
1705     * stored so end users can access them via getCandidateTables().
1706     *
1707     * @param columnName The column name to resolve
1708     * @return ColumnSource (may have multiple candidate tables for NOT_PICKUP)
1709     */
1710    private ColumnSource applyGuessColumnStrategy(String columnName) {
1711        // Get the strategy from instance field (which falls back to TBaseType if not set)
1712        int strategy = getGuessColumnStrategy();
1713
1714        // Collect all physical tables from the FROM clause
1715        List<TTable> physicalTables = new ArrayList<>();
1716        java.util.ArrayList<TTable> relations = subquery.getRelations();
1717        if (relations != null) {
1718            for (TTable table : relations) {
1719                if (table == null) continue;
1720                if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1721                    collectPhysicalTablesFromJoinExpr(table.getJoinExpr(), physicalTables);
1722                } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1723                    physicalTables.add(table);
1724                }
1725            }
1726        }
1727
1728        if (physicalTables.isEmpty()) {
1729            return null;
1730        }
1731
1732        if (strategy == gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY_NOT_PICKUP) {
1733            // Don't pick any table - return null to treat column as "missed"
1734            // This is the expected behavior for ambiguous star columns with NOT_PICKUP strategy
1735            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
1736                System.out.println("[GUESS_COLUMN_STRATEGY] Column '" + columnName +
1737                    "' is ambiguous across " + physicalTables.size() + " tables (NOT_PICKUP strategy - treating as missed)");
1738            }
1739            return null;
1740        }
1741
1742        // Pick the table based on strategy
1743        TTable pickedTable;
1744        String evidence;
1745        if (strategy == gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY_NEAREST) {
1746            // Pick the first table (nearest in FROM clause order)
1747            pickedTable = physicalTables.get(0);
1748            evidence = "guess_strategy_nearest";
1749        } else if (strategy == gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY_FARTHEST) {
1750            // Pick the last table (farthest in FROM clause order)
1751            pickedTable = physicalTables.get(physicalTables.size() - 1);
1752            evidence = "guess_strategy_farthest";
1753        } else {
1754            // Unknown strategy - don't pick
1755            return null;
1756        }
1757
1758        if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
1759            System.out.println("[GUESS_COLUMN_STRATEGY] Column '" + columnName +
1760                "' picked from table '" + pickedTable.getName() + "' using " + evidence);
1761        }
1762
1763        // Create ColumnSource with the picked table
1764        return new ColumnSource(
1765            this,
1766            columnName,
1767            null,
1768            0.7,  // Lower confidence since it's a guess
1769            evidence,
1770            pickedTable  // Override table for getFinalTable()
1771        );
1772    }
1773
1774    /**
1775     * Find a column in explicit subquery sources within the FROM clause.
1776     * This is used for ambiguous star cases where we try to find the column
1777     * in subqueries that have explicit columns before giving up.
1778     */
1779    private ColumnSource findColumnInExplicitSources(String columnName) {
1780        if (subquery == null || subquery.tables == null) {
1781            return null;
1782        }
1783
1784        ColumnSource foundSource = null;
1785        List<TTable> physicalTables = new ArrayList<>();
1786
1787        for (int i = 0; i < subquery.tables.size(); i++) {
1788            TTable table = subquery.tables.getTable(i);
1789            if (table == null) continue;
1790
1791            // For join tables, collect physical tables from within the join
1792            if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1793                collectPhysicalTablesFromJoin(table, physicalTables);
1794                continue;
1795            }
1796
1797            // Check if this is a subquery with explicit columns
1798            if (table.getSubquery() != null) {
1799                TResultColumnList resultCols = table.getSubquery().getResultColumnList();
1800                if (resultCols != null) {
1801                    TObjectNameList nestedAliases = aliasColumnsOf(table);
1802                    for (int j = 0; j < resultCols.size(); j++) {
1803                        TResultColumn rc = resultCols.getResultColumn(j);
1804                        String rcName = getResultColumnName(rc);
1805                        // the derived table's alias column list renames position j (Mantis 4814, Codex review)
1806                        if (nestedAliases != null && j < nestedAliases.size() && nestedAliases.getObjectName(j) != null
1807                                && !isStarResultColumn(rc)) {
1808                            rcName = nestedAliases.getObjectName(j).getColumnNameOnly();
1809                        }
1810                        if (rcName != null && nameMatcher.matches(rcName, columnName)) {
1811                            // Found in this subquery - trace to its final table (use cached namespace)
1812                            SubqueryNamespace nestedNs = getOrCreateNestedNamespace(table.getSubquery(), table.getAliasName(), nestedAliases);
1813                            if (nestedNs != null) {
1814                                ColumnSource nestedSource = nestedNs.resolveColumn(columnName);
1815                                if (nestedSource != null) {
1816                                    if (foundSource != null) {
1817                                        // Found in multiple sources - ambiguous
1818                                        return null;
1819                                    }
1820                                    foundSource = nestedSource;
1821                                }
1822                            }
1823                        }
1824                    }
1825                }
1826            } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1827                // Physical table - add to list
1828                physicalTables.add(table);
1829            }
1830        }
1831
1832        // If found in an explicit subquery source, return it
1833        if (foundSource != null) {
1834            return foundSource;
1835        }
1836
1837        // If column not found in any explicit source, and there's exactly one physical table,
1838        // infer from that table (use cached namespace)
1839        if (physicalTables.size() == 1) {
1840            // Create inferred column source from the single physical table
1841            TableNamespace tableNs = getOrCreateTableNamespace(physicalTables.get(0));
1842            return tableNs != null ? tableNs.resolveColumn(columnName) : null;
1843        }
1844
1845        return null;
1846    }
1847
1848    /**
1849     * Trace a column through qualified references in the subquery.
1850     * This looks for qualified column references (like table1.x) in the subquery
1851     * that match the requested column name, and returns a ColumnSource from the
1852     * corresponding table.
1853     *
1854     * Uses getRelations() and TJoinExpr to properly traverse JOIN structures.
1855     */
1856    private ColumnSource traceColumnThroughQualifiedReferences(String columnName) {
1857        if (subquery == null || columnName == null) {
1858            return null;
1859        }
1860
1861        // Debug logging
1862        if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
1863            System.out.println("TRACE: traceColumnThroughQualifiedReferences called for column: " + columnName);
1864        }
1865
1866        // Collect all physical tables using getRelations() - the proper way to get all tables
1867        List<TTable> physicalTables = new ArrayList<>();
1868        java.util.ArrayList<TTable> relations = subquery.getRelations();
1869        if (relations != null) {
1870            for (TTable table : relations) {
1871                if (table == null) continue;
1872                if (table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1873                    // For JOIN type, collect tables from the JoinExpr
1874                    collectPhysicalTablesFromJoinExpr(table.getJoinExpr(), physicalTables);
1875                } else if (table.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1876                    physicalTables.add(table);
1877                }
1878            }
1879        }
1880
1881        if (physicalTables.isEmpty()) {
1882            return null;
1883        }
1884
1885        // Look for qualified column references in JOIN ON conditions using TJoinExpr
1886        TTable matchedTable = null;
1887        int matchCount = 0;
1888
1889        // Check WHERE clause
1890        if (subquery.getWhereClause() != null && subquery.getWhereClause().getCondition() != null) {
1891            TTable found = findTableFromQualifiedColumnInExpression(
1892                subquery.getWhereClause().getCondition(), columnName, physicalTables);
1893            if (found != null) {
1894                matchedTable = found;
1895                matchCount++;
1896            }
1897        }
1898
1899        // Check JOIN conditions using getRelations() and TJoinExpr
1900        if (relations != null) {
1901            for (TTable table : relations) {
1902                if (table != null && table.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
1903                    gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = table.getJoinExpr();
1904                    if (joinExpr != null) {
1905                        TTable found = findTableFromQualifiedColumnInJoinExpr(joinExpr, columnName, physicalTables);
1906                        if (found != null && (matchedTable == null || found != matchedTable)) {
1907                            if (matchedTable != null && found != matchedTable) {
1908                                matchCount++;
1909                            } else {
1910                                matchedTable = found;
1911                                matchCount = 1;
1912                            }
1913                        }
1914                    }
1915                }
1916            }
1917        }
1918
1919        // If found in exactly one table, return column source with SubqueryNamespace as source
1920        // The overrideTable ensures getFinalTable() returns the traced table
1921        if (matchCount == 1 && matchedTable != null) {
1922            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
1923                System.out.println("TRACE: Found column " + columnName + " in table: " + matchedTable.getName());
1924            }
1925            // Store the traced table mapping for reference
1926            if (tracedColumnTables == null) {
1927                tracedColumnTables = new java.util.LinkedHashMap<>();
1928            }
1929            tracedColumnTables.put(columnName, matchedTable);
1930
1931            // Return a ColumnSource with this SubqueryNamespace as source (for priority)
1932            // and the traced table as overrideTable (for correct getFinalTable())
1933            ColumnSource source = new ColumnSource(
1934                this,
1935                columnName,
1936                null,
1937                0.95,  // High confidence - traced through qualified reference
1938                "traced_through_qualified_ref",
1939                matchedTable  // Override table for getFinalTable()
1940            );
1941            return source;
1942        }
1943
1944        if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
1945            System.out.println("TRACE: Column " + columnName + " NOT found in qualified references, matchCount=" + matchCount);
1946        }
1947        return null;
1948    }
1949
1950    /**
1951     * Collect physical tables from a TJoinExpr recursively.
1952     */
1953    private void collectPhysicalTablesFromJoinExpr(gudusoft.gsqlparser.nodes.TJoinExpr joinExpr, List<TTable> physicalTables) {
1954        if (joinExpr == null) return;
1955
1956        // Process left table
1957        TTable leftTable = joinExpr.getLeftTable();
1958        if (leftTable != null) {
1959            if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && leftTable.getJoinExpr() != null) {
1960                collectPhysicalTablesFromJoinExpr(leftTable.getJoinExpr(), physicalTables);
1961            } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1962                physicalTables.add(leftTable);
1963            }
1964        }
1965
1966        // Process right table
1967        TTable rightTable = joinExpr.getRightTable();
1968        if (rightTable != null) {
1969            if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join && rightTable.getJoinExpr() != null) {
1970                collectPhysicalTablesFromJoinExpr(rightTable.getJoinExpr(), physicalTables);
1971            } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
1972                physicalTables.add(rightTable);
1973            }
1974        }
1975    }
1976
1977    /**
1978     * Find table from qualified column references in a TJoinExpr and its nested joins.
1979     */
1980    private TTable findTableFromQualifiedColumnInJoinExpr(
1981            gudusoft.gsqlparser.nodes.TJoinExpr joinExpr,
1982            String columnName,
1983            List<TTable> physicalTables) {
1984        if (joinExpr == null) return null;
1985
1986        // Check ON condition of this join
1987        if (joinExpr.getOnCondition() != null) {
1988            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
1989                System.out.println("TRACE: Checking TJoinExpr ON condition: " + joinExpr.getOnCondition());
1990            }
1991            TTable found = findTableFromQualifiedColumnInExpression(
1992                joinExpr.getOnCondition(), columnName, physicalTables);
1993            if (found != null) return found;
1994        }
1995
1996        // Recursively check nested joins on the left side
1997        TTable leftTable = joinExpr.getLeftTable();
1998        if (leftTable != null && leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join
1999            && leftTable.getJoinExpr() != null) {
2000            TTable found = findTableFromQualifiedColumnInJoinExpr(leftTable.getJoinExpr(), columnName, physicalTables);
2001            if (found != null) return found;
2002        }
2003
2004        // Recursively check nested joins on the right side
2005        TTable rightTable = joinExpr.getRightTable();
2006        if (rightTable != null && rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join
2007            && rightTable.getJoinExpr() != null) {
2008            TTable found = findTableFromQualifiedColumnInJoinExpr(rightTable.getJoinExpr(), columnName, physicalTables);
2009            if (found != null) return found;
2010        }
2011
2012        return null;
2013    }
2014
2015    /**
2016     * Find table from qualified column references in an expression.
2017     * Looks for patterns like table1.column_name and returns the matching table.
2018     * Uses iterative DFS to avoid StackOverflowError for deeply nested expression chains.
2019     */
2020    private TTable findTableFromQualifiedColumnInExpression(
2021            gudusoft.gsqlparser.nodes.TExpression expr,
2022            String columnName,
2023            List<TTable> physicalTables) {
2024        if (expr == null) return null;
2025
2026        Deque<gudusoft.gsqlparser.nodes.TExpression> stack = new ArrayDeque<>();
2027        stack.push(expr);
2028        while (!stack.isEmpty()) {
2029            gudusoft.gsqlparser.nodes.TExpression current = stack.pop();
2030            if (current == null) continue;
2031
2032            // Check if this expression is a qualified column reference
2033            if (current.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
2034                gudusoft.gsqlparser.nodes.TObjectName objName = current.getObjectOperand();
2035                if (objName != null) {
2036                    String colName = objName.getColumnNameOnly();
2037                    String tablePrefix = objName.getTableString();
2038
2039                    if (colName != null && nameMatcher.matches(colName, columnName) &&
2040                        tablePrefix != null && !tablePrefix.isEmpty()) {
2041                        // Found a qualified reference to this column - find the matching table
2042                        for (TTable table : physicalTables) {
2043                            String tableName = table.getName();
2044                            String tableAlias = table.getAliasName();
2045
2046                            if ((tableName != null && nameMatcher.matches(tableName, tablePrefix)) ||
2047                                (tableAlias != null && nameMatcher.matches(tableAlias, tablePrefix))) {
2048                                return table;
2049                            }
2050                        }
2051                    }
2052                }
2053            }
2054
2055            // Push sub-expressions onto stack (right first so left is processed first)
2056            if (current.getRightOperand() != null) stack.push(current.getRightOperand());
2057            if (current.getLeftOperand() != null) stack.push(current.getLeftOperand());
2058        }
2059
2060        return null;
2061    }
2062
2063    /**
2064     * Find table from qualified column references in JOIN conditions.
2065     */
2066    private TTable findTableFromQualifiedColumnInJoin(
2067            TTable joinTable,
2068            String columnName,
2069            List<TTable> physicalTables) {
2070        if (joinTable == null) return null;
2071
2072        gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = joinTable.getJoinExpr();
2073        if (joinExpr == null) {
2074            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
2075                System.out.println("TRACE: findTableFromQualifiedColumnInJoin - joinExpr is null");
2076            }
2077            return null;
2078        }
2079
2080        // Check the ON condition of this join
2081        if (joinExpr.getOnCondition() != null) {
2082            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
2083                System.out.println("TRACE: Checking ON condition for column " + columnName + ": " + joinExpr.getOnCondition());
2084            }
2085            TTable found = findTableFromQualifiedColumnInExpression(
2086                joinExpr.getOnCondition(), columnName, physicalTables);
2087            if (found != null) return found;
2088        } else {
2089            if (gudusoft.gsqlparser.TBaseType.DUMP_RESOLVER_LOG_TO_CONSOLE) {
2090                System.out.println("TRACE: findTableFromQualifiedColumnInJoin - ON condition is null");
2091            }
2092        }
2093
2094        // Recursively check nested joins on the left side
2095        if (joinExpr.getLeftTable() != null &&
2096            joinExpr.getLeftTable().getTableType() == gudusoft.gsqlparser.ETableSource.join) {
2097            TTable found = findTableFromQualifiedColumnInJoin(
2098                joinExpr.getLeftTable(), columnName, physicalTables);
2099            if (found != null) return found;
2100        }
2101
2102        // Recursively check nested joins on the right side
2103        if (joinExpr.getRightTable() != null &&
2104            joinExpr.getRightTable().getTableType() == gudusoft.gsqlparser.ETableSource.join) {
2105            TTable found = findTableFromQualifiedColumnInJoin(
2106                joinExpr.getRightTable(), columnName, physicalTables);
2107            if (found != null) return found;
2108        }
2109
2110        return null;
2111    }
2112
2113    /**
2114     * Collect all physical tables from a JOIN expression recursively.
2115     */
2116    private void collectPhysicalTablesFromJoin(TTable joinTable, List<TTable> physicalTables) {
2117        if (joinTable == null) return;
2118
2119        gudusoft.gsqlparser.nodes.TJoinExpr joinExpr = joinTable.getJoinExpr();
2120        if (joinExpr == null) return;
2121
2122        // Process left side
2123        TTable leftTable = joinExpr.getLeftTable();
2124        if (leftTable != null) {
2125            if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
2126                collectPhysicalTablesFromJoin(leftTable, physicalTables);
2127            } else if (leftTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
2128                physicalTables.add(leftTable);
2129            } else if (leftTable.getSubquery() != null) {
2130                // For subqueries within joins, we could trace through but for now just note it exists
2131                physicalTables.add(leftTable);
2132            }
2133        }
2134
2135        // Process right side
2136        TTable rightTable = joinExpr.getRightTable();
2137        if (rightTable != null) {
2138            if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.join) {
2139                collectPhysicalTablesFromJoin(rightTable, physicalTables);
2140            } else if (rightTable.getTableType() == gudusoft.gsqlparser.ETableSource.objectname) {
2141                physicalTables.add(rightTable);
2142            } else if (rightTable.getSubquery() != null) {
2143                physicalTables.add(rightTable);
2144            }
2145        }
2146    }
2147
2148    /**
2149     * Get the name of a result column (either alias or column name)
2150     */
2151    private String getResultColumnName(TResultColumn rc) {
2152        if (rc == null) return null;
2153
2154        // Check for alias
2155        if (rc.getAliasClause() != null && rc.getAliasClause().getAliasName() != null) {
2156            return rc.getAliasClause().getAliasName().toString();
2157        }
2158
2159        // Check for star - not a named column
2160        String colStr = rc.toString().trim();
2161        if (colStr.endsWith("*")) {
2162            return null;
2163        }
2164
2165        // Check for simple column reference
2166        if (rc.getExpr() != null) {
2167            gudusoft.gsqlparser.nodes.TExpression expr = rc.getExpr();
2168            if (expr.getExpressionType() == gudusoft.gsqlparser.EExpressionType.simple_object_name_t) {
2169                gudusoft.gsqlparser.nodes.TObjectName objName = expr.getObjectOperand();
2170                if (objName != null) {
2171                    return objName.getColumnNameOnly();
2172                }
2173            }
2174        }
2175
2176        return null;
2177    }
2178
2179    @Override
2180    public String toString() {
2181        int totalColumns = (columnSources != null ? columnSources.size() : 0) +
2182                          (inferredColumns != null ? inferredColumns.size() : 0);
2183        return "SubqueryNamespace(" + getDisplayName() + ", columns=" + totalColumns +
2184               ", inferred=" + (inferredColumns != null ? inferredColumns.size() : 0) + ")";
2185    }
2186}