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