001package gudusoft.gsqlparser.resolver2;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.ETableSource;
005import gudusoft.gsqlparser.TSourceToken;
006import gudusoft.gsqlparser.nodes.TObjectName;
007import gudusoft.gsqlparser.nodes.TResultColumn;
008import gudusoft.gsqlparser.nodes.TResultColumnList;
009import gudusoft.gsqlparser.nodes.TTable;
010import gudusoft.gsqlparser.nodes.TTableList;
011import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
012import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
013import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource;
014import gudusoft.gsqlparser.resolver2.model.ColumnSource;
015import gudusoft.gsqlparser.resolver2.model.FieldPath;
016import gudusoft.gsqlparser.resolver2.model.ResolutionContext;
017import gudusoft.gsqlparser.resolver2.model.ResolutionResult;
018import gudusoft.gsqlparser.resolver2.namespace.INamespace;
019import gudusoft.gsqlparser.resolver2.namespace.UnnestNamespace;
020import gudusoft.gsqlparser.resolver2.scope.IScope;
021import gudusoft.gsqlparser.resolver2.scope.ResolvedImpl;
022
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.Collections;
026import java.util.Comparator;
027import java.util.IdentityHashMap;
028import java.util.LinkedHashSet;
029import java.util.List;
030import java.util.Set;
031
032/**
033 * Core component for resolving column references to their sources.
034 *
035 * Key responsibilities:
036 * 1. Resolve TObjectName (column references) using scope tree
037 * 2. Handle qualified and unqualified names
038 * 3. Detect and report ambiguities
039 * 4. Apply GUESS_COLUMN_STRATEGY for ambiguous columns
040 * 5. Update TObjectName with resolution results
041 * 6. Update ResolutionContext for global querying
042 */
043public class NameResolver {
044
045    private final INameMatcher nameMatcher;
046    private final ResolutionContext context;
047    private final TSQLResolverConfig config;
048
049    /**
050     * Create a NameResolver with full configuration.
051     *
052     * @param config The resolver configuration (includes name matcher and strategy)
053     * @param context The resolution context for tracking results
054     */
055    public NameResolver(TSQLResolverConfig config, ResolutionContext context) {
056        this.config = config;
057        this.nameMatcher = config.getNameMatcher();
058        this.context = context;
059    }
060
061    /**
062     * Create a NameResolver with just name matcher (backward compatibility).
063     * Uses default configuration for GUESS_COLUMN_STRATEGY.
064     *
065     * @deprecated Use NameResolver(TSQLResolverConfig, ResolutionContext) instead
066     */
067    @Deprecated
068    public NameResolver(INameMatcher nameMatcher, ResolutionContext context) {
069        this.nameMatcher = nameMatcher;
070        this.context = context;
071        this.config = null; // Will use TBaseType.GUESS_COLUMN_STRATEGY
072    }
073
074    /**
075     * Get the effective GUESS_COLUMN_STRATEGY.
076     * Returns config value if available, otherwise TBaseType.GUESS_COLUMN_STRATEGY.
077     */
078    private int getGuessColumnStrategy() {
079        if (config != null) {
080            return config.getGuessColumnStrategy();
081        }
082        return gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY;
083    }
084
085    private static final boolean DEBUG_RESOLUTION = false;
086
087    /**
088     * Resolve a column reference (TObjectName) within a given scope.
089     *
090     * @param objName The column reference to resolve
091     * @param scope The scope where the reference appears
092     * @return Resolution result
093     */
094    public ResolutionResult resolve(TObjectName objName, IScope scope) {
095        if (objName == null || scope == null) {
096            return ResolutionResult.notFound("<null>");
097        }
098
099        // Extract name parts from TObjectName
100        List<String> nameParts = extractNameParts(objName);
101        if (nameParts.isEmpty()) {
102            return ResolutionResult.notFound("<empty>");
103        }
104
105        if (DEBUG_RESOLUTION) {
106            System.out.println("[DEBUG-RESOLVE] Resolving: " + objName +
107                " nameParts=" + nameParts + " scopeType=" + scope.getScopeType());
108        }
109
110        // Use scope to resolve the name
111        ResolvedImpl resolved = new ResolvedImpl();
112        scope.resolve(nameParts, nameMatcher, false, resolved);
113
114        if (DEBUG_RESOLUTION) {
115            System.out.println("[DEBUG-RESOLVE]   Resolved matches: " + resolved.getCount());
116            if (resolved.getCount() > 1) {
117                for (ResolvedImpl.Match m : resolved.getMatches()) {
118                    System.out.println("[DEBUG-RESOLVE]     Match: " + m.namespace.getDisplayName() +
119                        " type=" + m.namespace.getClass().getSimpleName() +
120                        " id=" + System.identityHashCode(m.namespace) +
121                        " remaining=" + m.remainingNames +
122                        " scope=" + m.scope.getScopeType());
123                }
124            }
125        }
126
127        // Process resolution results
128        ResolutionResult result = processResolvedMatches(objName, nameParts, resolved);
129
130        // Delta 3: Struct-field fallback for BigQuery/Snowflake
131        // If resolution failed and we have a 2-part qualified name like "customer.customer_id",
132        // try interpreting it as column.field (struct field access) instead of table.column.
133        // Only for 2-part names; 3+ part no-alias case (e.g., customer.address.city) is not
134        // handled here to avoid changing resolution paths for existing 3-part BigQuery patterns
135        // (e.g., purchases.first.msts) that are correctly handled by DataFlowAnalyzer heuristics.
136        // The alias case (o.customer.address.city) IS handled by resolveColumnPath() in
137        // processResolvedMatches() and does not depend on this fallback.
138        if (!result.isExactMatch() && nameParts.size() == 2 && isStructFieldVendor()) {
139            ResolutionResult structFieldResult = tryStructFieldFallback(objName, nameParts, scope);
140            if (structFieldResult != null && structFieldResult.isExactMatch()) {
141                result = structFieldResult;
142                if (DEBUG_RESOLUTION) {
143                    System.out.println("[DEBUG-RESOLVE]   Struct-field fallback succeeded for: " + objName);
144                }
145            }
146        }
147
148        // Delta 4: Side-channel hint for 3+ part no-alias struct access (BigQuery only).
149        // When resolution failed and we have 3+ parts (e.g., customer.address.city),
150        // set a StructFieldHint WITHOUT changing the resolution result or sourceTable.
151        // This provides struct path info to DataFlowAnalyzer without altering lineage topology.
152        if (!result.isExactMatch() && nameParts.size() >= 3 && isStructFieldHintVendor()) {
153            trySetStructFieldHint(objName, nameParts, scope);
154        }
155
156        if (DEBUG_RESOLUTION) {
157            System.out.println("[DEBUG-RESOLVE]   Result: " + result.getStatus() +
158                (result.isExactMatch() && result.getColumnSource() != null ?
159                    " source=" + result.getColumnSource().getExposedName() : ""));
160        }
161
162        // Update TObjectName and context
163        updateObjectNameWithResult(objName, result);
164
165        return result;
166    }
167
168    /**
169     * Check if the current vendor supports struct-field access syntax (column.field).
170     * Currently supported: BigQuery, Snowflake
171     */
172    private boolean isStructFieldVendor() {
173        if (config == null) {
174            return false;
175        }
176        EDbVendor vendor = config.getVendor();
177        return vendor == EDbVendor.dbvbigquery || vendor == EDbVendor.dbvsnowflake;
178    }
179
180    /**
181     * Check if the current vendor supports struct-field hint annotations.
182     * Currently BigQuery only (Snowflake uses schema.table.column for 3-part names).
183     */
184    private boolean isStructFieldHintVendor() {
185        if (config == null) {
186            return false;
187        }
188        return config.getVendor() == EDbVendor.dbvbigquery;
189    }
190
191    /**
192     * Delta 4: Try to set a StructFieldHint for 3+ part no-alias struct access.
193     *
194     * For "customer.address.city" (3 parts, no alias):
195     * - Treat the first part ("customer") as a potential base column
196     * - If found in any visible namespace, set a hint with fieldPath=["address", "city"]
197     * - Does NOT change ResolutionResult or sourceTable
198     *
199     * @param objName The TObjectName to annotate with a hint
200     * @param nameParts The extracted name parts (e.g., ["customer", "address", "city"])
201     * @param scope The scope to search in
202     */
203    private void trySetStructFieldHint(TObjectName objName, List<String> nameParts, IScope scope) {
204        String baseColumnName = nameParts.get(0);
205        List<String> fieldPathSegments = nameParts.subList(1, nameParts.size());
206
207        // Try to find the base column as an unqualified name
208        List<String> singlePartName = Collections.singletonList(baseColumnName);
209        ResolvedImpl resolved = new ResolvedImpl();
210        scope.resolve(singlePartName, nameMatcher, false, resolved);
211
212        if (resolved.isEmpty()) {
213            return; // Base column not found in any namespace
214        }
215
216        // Check if any namespace actually has this column
217        for (ResolvedImpl.Match match : resolved.getMatches()) {
218            gudusoft.gsqlparser.resolver2.model.ColumnSource baseColumnSource =
219                match.namespace.resolveColumn(baseColumnName);
220            if (baseColumnSource != null) {
221                // Found the base column - create hint
222                gudusoft.gsqlparser.resolver2.model.FieldPath fieldPath =
223                    gudusoft.gsqlparser.resolver2.model.FieldPath.of(fieldPathSegments);
224                gudusoft.gsqlparser.resolver2.model.StructFieldHint hint =
225                    new gudusoft.gsqlparser.resolver2.model.StructFieldHint(
226                        baseColumnName, fieldPath,
227                        "struct_field_hint_no_alias", 0.7);
228                objName.setStructFieldHint(hint);
229
230                if (DEBUG_RESOLUTION) {
231                    System.out.println("[DEBUG-RESOLVE]   StructFieldHint set for: " + objName +
232                        " -> base=" + baseColumnName + ", fieldPath=" + fieldPathSegments);
233                }
234                return;
235            }
236        }
237    }
238
239    /**
240     * Delta 3: Try to resolve a qualified name as struct-field access (column.field).
241     *
242     * In BigQuery/Snowflake, "customer.customer_id" might be:
243     * 1. Table "customer" with column "customer_id" (standard interpretation)
244     * 2. Column "customer" (STRUCT type) with field "customer_id" (struct-field access)
245     *
246     * If the standard interpretation failed, try the struct-field interpretation:
247     * - Treat the first part as an unqualified column name
248     * - If found, return the base column as the source (the STRUCT column)
249     * - Preserve the field path (segments beyond the base column) for downstream use
250     *
251     * @param objName The original TObjectName
252     * @param nameParts The extracted name parts (e.g., ["customer", "customer_id"])
253     * @param scope The scope to search in
254     * @return Resolution result if struct-field interpretation succeeds, null otherwise
255     */
256    private ResolutionResult tryStructFieldFallback(TObjectName objName, List<String> nameParts, IScope scope) {
257        // The base column is the first part (e.g., "customer" in "customer.customer_id")
258        String baseColumnName = nameParts.get(0);
259
260        // The field path is everything after the base column
261        // For "customer.customer_id", fieldPath = ["customer_id"]
262        // For "customer.address.city", fieldPath = ["address", "city"]
263        List<String> fieldPathSegments = nameParts.size() > 1
264            ? nameParts.subList(1, nameParts.size())
265            : Collections.emptyList();
266
267        // Try to resolve the base column as an unqualified name
268        List<String> singlePartName = Collections.singletonList(baseColumnName);
269        ResolvedImpl resolved = new ResolvedImpl();
270        scope.resolve(singlePartName, nameMatcher, false, resolved);
271
272        if (resolved.isEmpty()) {
273            return null; // Base column not found
274        }
275
276        // Found potential matches - check if any namespace has this column
277        for (ResolvedImpl.Match match : resolved.getMatches()) {
278            INamespace namespace = match.namespace;
279
280            // Try to resolve the base column name in this namespace
281            ColumnSource baseColumnSource = namespace.resolveColumn(baseColumnName);
282            if (baseColumnSource != null) {
283                // Found the base column - return it as the source with field path preserved
284                if (DEBUG_RESOLUTION) {
285                    System.out.println("[DEBUG-RESOLVE]   Struct-field: found base column '" +
286                        baseColumnName + "' in " + namespace.getDisplayName() +
287                        ", fieldPath=" + fieldPathSegments);
288                }
289
290                // Create a new ColumnSource with:
291                // 1. struct_field_access evidence marker (for backward compatibility)
292                // 2. fieldPath preserved (new in Improvement B)
293                FieldPath fieldPath = FieldPath.of(fieldPathSegments);
294                ColumnSource structFieldSource = baseColumnSource.withFieldPath(fieldPath, "struct_field_access");
295
296                return ResolutionResult.exactMatch(structFieldSource);
297            }
298        }
299
300        return null;
301    }
302
303    /**
304     * Extract name parts from TObjectName.
305     * Examples:
306     * - "col" -> ["col"]
307     * - "t.col" -> ["t", "col"]
308     * - "schema.table.col" -> ["schema", "table", "col"]
309     *
310     * For BigQuery/Snowflake struct field access like "customer.customer_id":
311     * - If schema/table tokens are not set, but toString() contains dots,
312     *   extract all parts from toString() to capture field paths.
313     */
314    private List<String> extractNameParts(TObjectName objName) {
315        List<String> parts = new ArrayList<>();
316
317        // Add schema if present, but NOT when databaseToken is also set.
318        // When databaseToken is present, the name is fully qualified (db.schema.table.column)
319        // and the schema position IS the schema, not a table alias. Including it would cause
320        // the resolver to incorrectly match the schema against table aliases. (Mantis #4268)
321        if (objName.getSchemaToken() != null && objName.getDatabaseToken() == null) {
322            parts.add(objName.getSchemaString());
323        }
324
325        // Add table/qualifier if present
326        if (objName.getTableToken() != null) {
327            parts.add(objName.getTableString());
328        }
329
330        // Add column name
331        String columnName = objName.getColumnNameOnly();
332        if (columnName != null) {
333            parts.add(columnName);
334        }
335
336        // Improvement B: Handle BigQuery/Snowflake struct field access
337        // If we only got a single part from standard extraction,
338        // but toString() contains more segments (dots), extract them
339        // This handles cases like "customer.customer_id" where:
340        // - getColumnNameOnly() returns "customer"
341        // - toString() returns "customer.customer_id"
342        if (isStructFieldVendor()) {
343            String fullName = objName.toString();
344            if (fullName != null && fullName.contains(".")) {
345                // Check if fullName has more segments than what we extracted
346                String[] fullParts = splitNameParts(fullName);
347                if (fullParts.length > parts.size() || hasConsecutiveDuplicates(parts)) {
348                    // Replace parts with full extracted segments
349                    parts.clear();
350                    Collections.addAll(parts, fullParts);
351                }
352            }
353        }
354
355        return parts;
356    }
357
358    /**
359     * Check if a list has consecutive duplicate elements.
360     * This detects parser bugs where segments are duplicated.
361     * Example: ["customer", "customer", "address"] returns true.
362     */
363    private boolean hasConsecutiveDuplicates(List<String> list) {
364        if (list == null || list.size() < 2) {
365            return false;
366        }
367        for (int i = 1; i < list.size(); i++) {
368            if (list.get(i) != null && list.get(i).equals(list.get(i - 1))) {
369                return true;
370            }
371        }
372        return false;
373    }
374
375
376    /**
377     * Split a dotted name into parts, handling quoted identifiers.
378     * Examples:
379     * - "a.b.c" -> ["a", "b", "c"]
380     * - "`a.b`.c" -> ["`a.b`", "c"]
381     * - "a.`b.c`" -> ["a", "`b.c`"]
382     *
383     * @param name The dotted name string
384     * @return Array of name parts
385     */
386    private String[] splitNameParts(String name) {
387        if (name == null || name.isEmpty()) {
388            return new String[0];
389        }
390
391        // Simple case: no quotes, just split on dots
392        if (!name.contains("`") && !name.contains("\"") && !name.contains("[")) {
393            return name.split("\\.");
394        }
395
396        // Complex case: handle quoted identifiers
397        List<String> parts = new ArrayList<>();
398        StringBuilder current = new StringBuilder();
399        char quoteChar = 0;
400
401        for (int i = 0; i < name.length(); i++) {
402            char c = name.charAt(i);
403
404            if (quoteChar != 0) {
405                // Inside a quoted identifier
406                current.append(c);
407                if (c == quoteChar) {
408                    // Check for escaped quote (doubled)
409                    if (i + 1 < name.length() && name.charAt(i + 1) == quoteChar) {
410                        current.append(name.charAt(++i));
411                    } else {
412                        // End of quoted identifier
413                        quoteChar = 0;
414                    }
415                }
416            } else if (c == '`' || c == '"' || c == '[') {
417                // Start of quoted identifier
418                quoteChar = (c == '[') ? ']' : c;
419                current.append(c);
420            } else if (c == '.') {
421                // Separator
422                if (current.length() > 0) {
423                    parts.add(current.toString());
424                    current.setLength(0);
425                }
426            } else {
427                current.append(c);
428            }
429        }
430
431        // Add last part
432        if (current.length() > 0) {
433            parts.add(current.toString());
434        }
435
436        return parts.toArray(new String[0]);
437    }
438
439    /**
440     * Process resolution matches and determine final result.
441     */
442    private ResolutionResult processResolvedMatches(TObjectName objName,
443                                                    List<String> nameParts,
444                                                    ResolvedImpl resolved) {
445        String columnName = nameParts.get(nameParts.size() - 1);
446
447        if (resolved.isEmpty()) {
448            // No matches found
449            return ResolutionResult.notFound(columnName);
450        }
451
452        // Deduplicate matches by namespace identity
453        // The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope)
454        // but it's still the same source - not truly ambiguous
455        List<ResolvedImpl.Match> uniqueMatches = deduplicateMatchesByNamespace(resolved.getMatches());
456
457        if (uniqueMatches.size() == 1) {
458            // Exactly one unique namespace - success!
459            ResolvedImpl.Match match = uniqueMatches.get(0);
460            INamespace namespace = match.namespace;
461
462            // For qualified names like "t.col", we need to resolve the column
463            // For unqualified names like "col", we need to find it in the namespace
464            ColumnSource columnSource;
465
466            if (!match.remainingNames.isEmpty()) {
467                // Still have parts to resolve (e.g., found table, need to find column)
468                // Phase B3: Support multi-segment paths (table.column.field...)
469                if (match.remainingNames.size() > 1 && isStructFieldVendor()) {
470                    // Multiple segments: use resolveColumnPath for deep field access
471                    // e.g., remainingNames = ["customer", "address", "city"]
472                    // -> base column "customer", fieldPath ["address", "city"]
473                    columnSource = namespace.resolveColumnPath(match.remainingNames);
474
475                    if (columnSource == null) {
476                        // Base column not found
477                        String baseColName = match.remainingNames.get(0);
478                        return ResolutionResult.notFound(baseColName,
479                            "Column '" + baseColName + "' not found in " + namespace.getDisplayName());
480                    }
481
482                    if (DEBUG_RESOLUTION) {
483                        System.out.println("[DEBUG-RESOLVE]   Deep path resolved: " +
484                            match.remainingNames + " -> base=" + columnSource.getExposedName() +
485                            ", fieldPath=" + (columnSource.hasFieldPath() ? columnSource.getFieldPath() : "none"));
486                    }
487                } else {
488                    // Single segment: regular column resolution
489                    String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1);
490                    columnSource = namespace.resolveColumn(remainingColName);
491
492                    if (columnSource == null) {
493                        // Column not found in the namespace
494                        return ResolutionResult.notFound(remainingColName,
495                            "Column '" + remainingColName + "' not found in " + namespace.getDisplayName());
496                    }
497                }
498            } else {
499                // Name resolved to a table/namespace directly with no remaining parts
500                // This happens when the column name matches the table alias exactly.
501                //
502                // For UNNEST tables (e.g., "UNNEST(arr) AS x"), the alias "x" is ALSO
503                // the implicit column name. When user writes "x" as a column reference,
504                // the scope resolution matches the table alias "x", leaving remainingNames empty.
505                // We should still try to resolve "x" as a column in the namespace.
506                //
507                // Example: SELECT x FROM UNNEST([1,2,3]) AS x
508                // Here "x" in SELECT refers to the implicit column, not the table.
509                if (namespace instanceof UnnestNamespace) {
510                    // For UNNEST, try to resolve the matched name as a column
511                    columnSource = namespace.resolveColumn(columnName);
512                    if (columnSource != null) {
513                        if (DEBUG_RESOLUTION) {
514                            System.out.println("[DEBUG-RESOLVE]   UNNEST implicit column resolved: " +
515                                columnName + " in " + namespace.getDisplayName());
516                        }
517                        return ResolutionResult.exactMatch(columnSource);
518                    }
519                }
520
521                // For other namespaces or if column not found, this is an error
522                return ResolutionResult.notFound(columnName,
523                    "Name '" + columnName + "' resolves to a table, not a column");
524            }
525
526            return ResolutionResult.exactMatch(columnSource);
527        }
528
529        // Multiple unique namespaces - truly ambiguous
530        List<ColumnSource> candidates = new ArrayList<>();
531        // Mantis 4550: set when we synthesize a placeholder candidate for an
532        // ambiguous-star subquery (see below). Such a candidate means the column
533        // is genuinely ambiguous across derived tables and must NOT be collapsed
534        // by GUESS_COLUMN_STRATEGY into a single guessed match.
535        boolean synthesizedAmbStar = false;
536
537        for (ResolvedImpl.Match match : uniqueMatches) {
538            INamespace namespace = match.namespace;
539
540            // Resolve column in this namespace
541            // Phase B3: Support multi-segment paths
542            ColumnSource columnSource;
543            if (!match.remainingNames.isEmpty()) {
544                if (match.remainingNames.size() > 1 && isStructFieldVendor()) {
545                    // Multi-segment: use resolveColumnPath
546                    columnSource = namespace.resolveColumnPath(match.remainingNames);
547                } else {
548                    // Single segment: use resolveColumn
549                    String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1);
550                    columnSource = namespace.resolveColumn(remainingColName);
551                }
552            } else {
553                columnSource = namespace.resolveColumn(columnName);
554            }
555
556            // Mantis 4550: an ambiguous-star subquery (SELECT * over a multi-table
557            // FROM) cannot pin an unqualified column to a single base table and,
558            // under the default NOT_PICKUP strategy, resolveColumn() returns null.
559            // SelectScope now still reports it as an outer-scope candidate. Anchor a
560            // placeholder ColumnSource to the subquery so it counts as a distinct
561            // candidate (getSourceNamespace().getSourceTable() -> the derived table).
562            // finalTable is left null; candidateTables reconciliation expands the
563            // derived table into its base tables. The placeholder is NOT cached as an
564            // inferred column on the namespace, so resolveColumn() itself is unchanged.
565            // Only single-segment column references qualify (no deep struct path);
566            // qualified single-table refs never reach this multi-namespace branch
567            // (they resolve via the uniqueMatches.size()==1 path above), so this
568            // cannot make "T0_2.col" falsely resolve.
569            boolean singleSegment = match.remainingNames.isEmpty()
570                    || match.remainingNames.size() == 1;
571            if (columnSource == null
572                    && singleSegment
573                    && namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace
574                    && ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).hasAmbiguousStar()) {
575                String placeholderName = match.remainingNames.isEmpty()
576                        ? columnName
577                        : match.remainingNames.get(match.remainingNames.size() - 1);
578                columnSource = new ColumnSource(namespace, placeholderName, null, 0.3,
579                        "ambiguous_star_outer_candidate");
580                synthesizedAmbStar = true;
581            }
582
583            if (columnSource != null) {
584                candidates.add(columnSource);
585            }
586        }
587
588        if (candidates.isEmpty()) {
589            // Resolved to tables, but none have the column
590            return ResolutionResult.notFound(columnName,
591                "Column '" + columnName + "' not found in any of " + resolved.getCount() + " tables");
592        }
593
594        if (candidates.size() == 1) {
595            // Only one table actually has the column
596            return ResolutionResult.exactMatch(candidates.get(0));
597        }
598
599        // Mantis 4550: when a placeholder ambiguous-star candidate was synthesized,
600        // the column is genuinely ambiguous across the derived tables. Force an
601        // AMBIGUOUS result regardless of GUESS_COLUMN_STRATEGY (nearest/farthest),
602        // which would otherwise collapse a definite sibling + maybe-star sibling
603        // back into a single EXACT match.
604        if (synthesizedAmbStar) {
605            sortCandidatesByTablePosition(candidates);
606            AmbiguousColumnSource ambiguousResult = new AmbiguousColumnSource(columnName, candidates);
607            return ResolutionResult.ambiguous(ambiguousResult);
608        }
609
610        // Sort candidates by their table's position in the SQL text (FROM clause order)
611        // This ensures consistent ordering for both ambiguous results and GUESS_COLUMN_STRATEGY
612        sortCandidatesByTablePosition(candidates);
613
614        // Check if all candidates are "inferred" (from tables without DDL metadata)
615        // If so, return AMBIGUOUS regardless of GUESS_COLUMN_STRATEGY
616        // Only apply GUESS_COLUMN_STRATEGY when we have definite knowledge about the columns
617        //
618        // The determination uses configurable thresholds:
619        // - minDefiniteConfidence: minimum confidence to be considered "definite" (default 0.9)
620        // - allowGuessWhenAllInferred: if true, allow guessing even when all candidates are inferred
621        boolean hasDefiniteCandidate = false;
622        double highestConfidence = 0.0;
623        double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9;
624
625        for (ColumnSource candidate : candidates) {
626            // A candidate is "definite" if it has high confidence and is not just inferred from usage
627            String evidence = candidate.getEvidence();
628            double confidence = candidate.getConfidence();
629
630            // Track highest confidence among candidates
631            if (confidence > highestConfidence) {
632                highestConfidence = confidence;
633            }
634
635            // Use structured evidence if available, otherwise check legacy evidence
636            boolean isInferred;
637            if (candidate.getEvidenceDetail() != null) {
638                // Use the structured evidence's own determination
639                isInferred = !candidate.getEvidenceDetail().isHighConfidence() ||
640                             candidate.getEvidenceDetail().isInferred();
641            } else {
642                // Fallback to legacy logic
643                // "inferred_from_usage" means table without DDL metadata - not definite
644                // Low confidence (< minDefiniteConf) means we're guessing - not definite
645                // Null evidence with high confidence is also considered inferred
646                isInferred = (evidence == null || evidence.equals("inferred_from_usage")) ||
647                             confidence < minDefiniteConf;
648            }
649
650            if (!isInferred) {
651                hasDefiniteCandidate = true;
652                break;
653            }
654        }
655
656        // Check if we should allow guessing when all candidates are inferred
657        boolean allowGuessInferred = config != null && config.isAllowGuessWhenAllInferred();
658
659        if (!hasDefiniteCandidate && !allowGuessInferred) {
660            // All candidates are from tables without DDL metadata (all inferred)
661            // Don't guess - return as ambiguous so formatter can handle appropriately
662            if (DEBUG_RESOLUTION) {
663                System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (all inferred): " + columnName + " with " + candidates.size() + " candidates");
664                for (ColumnSource c : candidates) {
665                    System.out.println("[DEBUG-RESOLVE]     - " + (c.getSourceNamespace() != null ? c.getSourceNamespace().getDisplayName() : "null") +
666                        " evidence=" + c.getEvidence() + " confidence=" + c.getConfidence());
667                }
668            }
669            AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
670            return ResolutionResult.ambiguous(ambiguous);
671        }
672
673        // Additional check: even if we have definite candidates or allow inferred guessing,
674        // require at least one candidate to meet the minConfidenceToGuess threshold
675        double minConfToGuess = config != null ? config.getMinConfidenceToGuess() : 0.95;
676        if (highestConfidence < minConfToGuess && !allowGuessInferred) {
677            // No candidate has sufficient confidence for guessing
678            if (DEBUG_RESOLUTION) {
679                System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (confidence too low): " + columnName +
680                    " highest=" + highestConfidence + " required=" + minConfToGuess);
681            }
682            AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
683            return ResolutionResult.ambiguous(ambiguous);
684        }
685
686        // Multiple tables have the column - apply GUESS_COLUMN_STRATEGY
687        // Candidates are already sorted by table position (done earlier)
688        int strategy = getGuessColumnStrategy();
689
690        if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_NEAREST) {
691            // Pick the first candidate (nearest table in FROM clause order)
692            return ResolutionResult.exactMatch(candidates.get(0));
693        } else if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_FARTHEST) {
694            // Pick the last candidate (farthest table in FROM clause order)
695            return ResolutionResult.exactMatch(candidates.get(candidates.size() - 1));
696        }
697
698        // GUESS_COLUMN_STRATEGY_NOT_PICKUP: leave as ambiguous
699        if (DEBUG_RESOLUTION) {
700            System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (NOT_PICKUP strategy): " + columnName + " with " + candidates.size() + " candidates");
701        }
702        AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
703        return ResolutionResult.ambiguous(ambiguous);
704    }
705
706    /**
707     * Deduplicate matches by namespace identity.
708     * The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope)
709     * but it's still the same source - not truly ambiguous.
710     *
711     * @param matches All matches from scope resolution
712     * @return List of unique matches by namespace identity
713     */
714    private List<ResolvedImpl.Match> deduplicateMatchesByNamespace(List<ResolvedImpl.Match> matches) {
715        if (matches == null || matches.size() <= 1) {
716            return matches;
717        }
718
719        // Use identity-based set to track unique namespaces
720        java.util.IdentityHashMap<INamespace, ResolvedImpl.Match> uniqueByNamespace =
721            new java.util.IdentityHashMap<>();
722
723        for (ResolvedImpl.Match match : matches) {
724            if (match.namespace != null && !uniqueByNamespace.containsKey(match.namespace)) {
725                uniqueByNamespace.put(match.namespace, match);
726            }
727        }
728
729        return new ArrayList<>(uniqueByNamespace.values());
730    }
731
732    /**
733     * Sort candidates by their source table's position in the SQL text.
734     * This ensures that when GUESS_COLUMN_STRATEGY_NEAREST or FARTHEST is applied,
735     * the candidates are ordered according to their actual position in the FROM clause.
736     *
737     * Uses the table's start token (lineNo, columnNo) to determine position.
738     *
739     * @param candidates List of ColumnSource candidates to sort in place
740     */
741    private void sortCandidatesByTablePosition(List<ColumnSource> candidates) {
742        if (candidates == null || candidates.size() <= 1) {
743            return;
744        }
745
746        candidates.sort(new Comparator<ColumnSource>() {
747            @Override
748            public int compare(ColumnSource c1, ColumnSource c2) {
749                TTable t1 = c1.getFinalTable();
750                TTable t2 = c2.getFinalTable();
751
752                // If either table is null, maintain relative order
753                if (t1 == null && t2 == null) return 0;
754                if (t1 == null) return 1;  // null tables go to the end
755                if (t2 == null) return -1;
756
757                TSourceToken token1 = t1.getStartToken();
758                TSourceToken token2 = t2.getStartToken();
759
760                // If either token is null, maintain relative order
761                if (token1 == null && token2 == null) return 0;
762                if (token1 == null) return 1;
763                if (token2 == null) return -1;
764
765                // Compare by line number first
766                int lineCmp = Long.compare(token1.lineNo, token2.lineNo);
767                if (lineCmp != 0) {
768                    return lineCmp;
769                }
770
771                // If same line, compare by column number
772                return Long.compare(token1.columnNo, token2.columnNo);
773            }
774        });
775    }
776
777    /**
778     * Update TObjectName with the resolution result.
779     * Also registers with ResolutionContext.
780     *
781     * For ambiguous columns (multiple candidate tables), this also populates
782     * TObjectName.candidateTables with all possible source tables.
783     *
784     * IMPORTANT: When resolution fails (notFound) and the column's sourceTable
785     * was set during Phase 1 to an UNNEST table, we clear the sourceTable.
786     * This is because UNNEST tables have a fixed set of columns (implicit column,
787     * offset, struct fields) and should NOT have arbitrary columns inferred.
788     * Clearing sourceTable allows the formatter to treat these as "missed" columns.
789     */
790    private void updateObjectNameWithResult(TObjectName objName, ResolutionResult result) {
791        // Update TObjectName with resolution result
792        objName.setResolution(result);
793
794        // For notFound results, check if Phase 1 incorrectly linked to UNNEST table
795        // UNNEST tables have a fixed column set - don't allow inferred columns
796        if (!result.isExactMatch() && !result.isAmbiguous()) {
797            TTable currentSourceTable = objName.getSourceTable();
798            if (currentSourceTable != null &&
799                currentSourceTable.getTableType() == ETableSource.unnest) {
800                // Clear the incorrectly set sourceTable from Phase 1
801                // This column wasn't found in the UNNEST namespace, so it shouldn't
802                // be attributed to the UNNEST table
803                objName.setSourceTable(null);
804                if (DEBUG_RESOLUTION) {
805                    System.out.println("[DEBUG-RESOLVE]   Cleared incorrect UNNEST sourceTable for: " +
806                        objName + " (not found in UNNEST namespace)");
807                }
808            }
809        }
810
811        // For ambiguous results, populate candidateTables with all candidate tables
812        // IMPORTANT: Clear existing candidateTables first, as Phase 1 (linkColumnToTable)
813        // may have added candidates from incorrect scopes (e.g., MERGE target table for
814        // columns inside USING subquery). Phase 2 (NameResolver) has proper scope awareness
815        // and produces the authoritative candidate list.
816        if (result.isAmbiguous() && result.getAmbiguousSource() != null) {
817            AmbiguousColumnSource ambiguous = result.getAmbiguousSource();
818            // Clear Phase 1 candidates before adding Phase 2's scope-aware candidates
819            objName.getCandidateTables().clear();
820            for (ColumnSource candidate : ambiguous.getCandidates()) {
821                gudusoft.gsqlparser.nodes.TTable candidateTable = candidate.getFinalTable();
822                if (candidateTable != null) {
823                    objName.getCandidateTables().addTable(candidateTable);
824                    continue;
825                }
826                // Mantis 4550: an ambiguous-star subquery placeholder has no single
827                // finalTable. Add its immediate derived table (the subquery) so the
828                // reconcile pass below expands it into the physical base tables that
829                // can actually supply the column (e.g. T0_2 -> A.A2, A.A3).
830                gudusoft.gsqlparser.nodes.TTable derivedTable =
831                    candidate.getSourceNamespace() != null
832                        ? candidate.getSourceNamespace().getSourceTable() : null;
833                if (derivedTable != null) {
834                    objName.getCandidateTables().addTable(derivedTable);
835                    continue;
836                }
837                // A candidate that cannot collapse to a single physical table
838                // (e.g. a UNION-backed derived table) would otherwise be dropped
839                // here. Trace its set-operation branches projection-aware, by the
840                // requested column, so only branches that actually supply the
841                // column contribute base tables (a constant branch like
842                // "SELECT 1 AS a" adds nothing).
843                addBranchBaseTablesForColumn(objName, candidate);
844            }
845        }
846
847        // Mantis 4550: reconcile candidateTables so they point to the physical
848        // base tables that could actually supply the column, rather than the
849        // intermediate derived tables. Phase 1 (linkColumnToTable) may leave
850        // stale candidates that include derived tables which cannot supply the
851        // column (e.g. a subquery with an explicit column list that omits it),
852        // and the EXACT/guessed-match path above does not clear them. See
853        // reconcileCandidateTablesToBaseTables() for the full rationale.
854        reconcileCandidateTablesToBaseTables(objName);
855
856        // Register with context for global querying
857        context.registerResolution(objName, result);
858    }
859
860    /**
861     * Mantis 4550: rewrite {@code candidateTables} so each entry is a physical
862     * base table that could supply the column, expanding any intermediate
863     * derived table (subquery / CTE) into the underlying tables it exposes.
864     *
865     * <p>Background: for an unqualified column whose source is ambiguous across
866     * several derived tables in the FROM clause, Phase 1 ({@code linkColumnToTable})
867     * may populate {@code candidateTables} with the derived tables themselves.
868     * When Phase 2 resolves the column to a guessed EXACT match (rather than a
869     * truly AMBIGUOUS result) the scope-aware ambiguous branch that normally
870     * clears and rebuilds {@code candidateTables} is skipped, leaving those
871     * stale derived-table candidates in place. Some of them cannot even supply
872     * the column (e.g. a {@code SELECT x, ds FROM ...} subquery for column
873     * {@code S311}).</p>
874     *
875     * <p>This pass walks each candidate: physical tables are kept as-is, while
876     * a derived table is replaced by the set of physical base tables reachable
877     * through its projection that could supply the column. A derived table whose
878     * closed projection does not expose the column contributes nothing. Results
879     * are de-duplicated by qualified table name, preserving first-seen order, so
880     * the same physical table reached via two derived paths (or two aliases of a
881     * self-join) collapses to a single candidate.</p>
882     */
883    private void reconcileCandidateTablesToBaseTables(TObjectName objName) {
884        TTableList candidates = objName.getCandidateTables();
885        if (candidates == null || candidates.size() == 0) {
886            return;
887        }
888
889        String columnName = objName.getColumnNameOnly();
890        if (columnName == null || columnName.isEmpty()) {
891            return;
892        }
893
894        List<TTable> expanded = new ArrayList<TTable>();
895        Set<String> seenKeys = new LinkedHashSet<String>();
896        IdentityHashMap<TSelectSqlStatement, Boolean> visiting =
897            new IdentityHashMap<TSelectSqlStatement, Boolean>();
898        for (int i = 0; i < candidates.size(); i++) {
899            collectBaseTablesSupplyingColumn(candidates.getTable(i), columnName,
900                expanded, seenKeys, visiting);
901        }
902
903        // Only replace when expansion produced concrete base tables; otherwise
904        // keep the existing list rather than wiping resolution information.
905        if (expanded.isEmpty()) {
906            return;
907        }
908
909        candidates.clear();
910        for (TTable t : expanded) {
911            candidates.addTable(t);
912        }
913    }
914
915    /**
916     * Add, to {@code objName}'s candidate tables, the base tables that supply the
917     * column for an ambiguous candidate whose {@link ColumnSource#getFinalTable()}
918     * is {@code null} (typically a set-operation/UNION-backed namespace). The
919     * namespace's SELECT statement is traced projection-aware by the requested
920     * column, so a branch whose projection at that position is a constant or
921     * expression contributes no base table. Falls back to every reachable final
922     * table only when projection-aware tracing yields nothing.
923     */
924    private void addBranchBaseTablesForColumn(TObjectName objName, ColumnSource candidate) {
925        INamespace ns = candidate.getSourceNamespace();
926        if (ns == null) {
927            return;
928        }
929
930        List<TTable> base = new ArrayList<TTable>();
931        Set<String> seenKeys = new LinkedHashSet<String>();
932        IdentityHashMap<TSelectSqlStatement, Boolean> visiting =
933            new IdentityHashMap<TSelectSqlStatement, Boolean>();
934
935        String columnName = objName.getColumnNameOnly();
936        TSelectSqlStatement nsSelect = ns.getSelectStatement();
937        boolean traced = false;
938        if (nsSelect != null && columnName != null && !columnName.isEmpty()) {
939            collectFromSelectBody(nsSelect, columnName, -1, base, seenKeys, visiting);
940            traced = true;
941        }
942
943        if (!traced) {
944            // We could not trace the namespace projection-aware (no SELECT body to
945            // inspect): report every reachable final table rather than dropping
946            // the candidate entirely. When tracing DID run, an empty result means
947            // "no physical table supplies this column" (e.g. all branches project
948            // a constant), so we must not fall back and re-add those tables.
949            List<TTable> all = ns.getAllFinalTables();
950            if (all != null) {
951                for (TTable t : all) {
952                    if (t != null) {
953                        base.add(t);
954                    }
955                }
956            }
957        }
958
959        for (TTable t : base) {
960            objName.getCandidateTables().addTable(t);
961        }
962    }
963
964    /**
965     * Collect, into {@code out}, the physical base tables reachable from
966     * {@code table} that could supply {@code columnName}. Derived tables
967     * (subqueries / CTEs) are expanded through their projection; physical
968     * tables are added directly. De-duplicated by qualified table name via
969     * {@code seenKeys}. {@code visiting} guards against cyclic CTE/subquery
970     * references.
971     */
972    private void collectBaseTablesSupplyingColumn(TTable table, String columnName,
973            List<TTable> out, Set<String> seenKeys,
974            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
975        if (table == null) {
976            return;
977        }
978
979        TSelectSqlStatement body = getDerivedBody(table);
980        if (body == null) {
981            // Physical base table (or a reference we cannot trace further).
982            addBaseTable(table, out, seenKeys);
983            return;
984        }
985
986        if (visiting.containsKey(body)) {
987            return;
988        }
989        visiting.put(body, Boolean.TRUE);
990        try {
991            // A CTE with an explicit column list (e.g. WITH cte(a) AS (SELECT x
992            // FROM t)) renames its outputs, so the requested name must be mapped
993            // positionally onto the body's projection rather than matched by the
994            // body's own display names.
995            int forcedIndex = -1;
996            gudusoft.gsqlparser.nodes.TObjectNameList cteColumns =
997                (table.isCTEName() && table.getCTE() != null) ? table.getCTE().getColumnList() : null;
998            if (cteColumns != null && cteColumns.size() > 0) {
999                forcedIndex = indexOfMatchingName(cteColumns, columnName);
1000                if (forcedIndex < 0) {
1001                    // The CTE's column list does not expose this name.
1002                    return;
1003                }
1004            }
1005
1006            collectFromSelectBody(body, columnName, forcedIndex, out, seenKeys, visiting);
1007        } finally {
1008            visiting.remove(body);
1009        }
1010    }
1011
1012    /**
1013     * Collect base tables from a derived table's SELECT body. Handles set
1014     * operations (UNION / EXCEPT / INTERSECT) by flattening to leaf SELECTs and
1015     * mapping the column onto each leaf positionally, and plain SELECTs via
1016     * {@link #collectFromSimpleSelect}.
1017     *
1018     * @param forcedIndex when {@code >= 0}, the column is identified by position
1019     *     (e.g. a CTE explicit column list); when {@code -1}, by output name.
1020     */
1021    private void collectFromSelectBody(TSelectSqlStatement body, String columnName,
1022            int forcedIndex, List<TTable> out, Set<String> seenKeys,
1023            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1024        if (body == null) {
1025            return;
1026        }
1027
1028        if (body.isCombinedQuery()) {
1029            List<TSelectSqlStatement> leaves = new ArrayList<TSelectSqlStatement>();
1030            flattenSetOperationLeaves(body, leaves);
1031            if (leaves.isEmpty()) {
1032                return;
1033            }
1034            // All set operands share one output shape by position. Determine the
1035            // position from the first leaf (or the forced/CTE index) and trace
1036            // that ordinal in every operand.
1037            int idx = forcedIndex;
1038            if (idx < 0) {
1039                idx = indexOfNamedProjection(leaves.get(0).getResultColumnList(), columnName);
1040            }
1041            if (idx < 0) {
1042                // The output ordinal is unknown (e.g. the leftmost operand is
1043                // SELECT *), so the column cannot be mapped onto each operand by
1044                // position. Matching later operands by name would silently drop a
1045                // renamed branch, so over-report every FROM table of every operand
1046                // instead.
1047                for (TSelectSqlStatement leaf : leaves) {
1048                    expandAllFromTables(leaf, columnName, out, seenKeys, visiting);
1049                }
1050                return;
1051            }
1052            for (TSelectSqlStatement leaf : leaves) {
1053                collectFromSimpleSelect(leaf, columnName, idx, out, seenKeys, visiting);
1054            }
1055            return;
1056        }
1057
1058        collectFromSimpleSelect(body, columnName, forcedIndex, out, seenKeys, visiting);
1059    }
1060
1061    /**
1062     * Collect base tables from a single (non-combined) SELECT.
1063     *
1064     * <p>When {@code forcedIndex >= 0} the column is resolved by position onto
1065     * the projection (used for CTE column lists and set-operation operands);
1066     * otherwise it is matched by output name. A {@code SELECT *} or an
1067     * out-of-range/star position falls back to expanding every FROM table, since
1068     * any of them may supply the column.</p>
1069     */
1070    private void collectFromSimpleSelect(TSelectSqlStatement select, String columnName,
1071            int forcedIndex, List<TTable> out, Set<String> seenKeys,
1072            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1073        if (select == null) {
1074            return;
1075        }
1076        TResultColumnList rcl = select.getResultColumnList();
1077        if (rcl == null) {
1078            return;
1079        }
1080
1081        if (forcedIndex >= 0) {
1082            boolean hasStar = false;
1083            for (int i = 0; i < rcl.size(); i++) {
1084                if (starQualifier(rcl.getResultColumn(i)) != null) {
1085                    hasStar = true;
1086                    break;
1087                }
1088            }
1089            if (hasStar || forcedIndex >= rcl.size()) {
1090                // Positional mapping is unreliable: any FROM table may supply it.
1091                expandAllFromTables(select, columnName, out, seenKeys, visiting);
1092                return;
1093            }
1094            TResultColumn rc = rcl.getResultColumn(forcedIndex);
1095            TObjectName underlying = rc != null ? rc.getColumnFullname() : null;
1096            if (underlying != null) {
1097                traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting);
1098            }
1099            // A computed projection at this position has no physical base table.
1100            return;
1101        }
1102
1103        // Match by output name. Collect base tables from EVERY projection that
1104        // could expose the column: each matching named projection (there may be
1105        // several with the same output name, e.g. "SELECT t1.c, t2.c") AND every
1106        // star projection. We do not early-return on the first match, so
1107        // genuinely ambiguous derived tables surface all of their suppliers.
1108        boolean unqualifiedStar = false;
1109        List<String> qualifiedStarPrefixes = new ArrayList<String>();
1110        for (int i = 0; i < rcl.size(); i++) {
1111            TResultColumn rc = rcl.getResultColumn(i);
1112            if (rc == null) {
1113                continue;
1114            }
1115            String starPrefix = starQualifier(rc);
1116            if (starPrefix != null) {
1117                if (starPrefix.isEmpty()) {
1118                    unqualifiedStar = true;
1119                } else {
1120                    qualifiedStarPrefixes.add(starPrefix);
1121                }
1122                continue;
1123            }
1124
1125            String outName = rc.getDisplayName();
1126            if (outName != null && nameMatcher.matches(outName, columnName)) {
1127                TObjectName underlying = rc.getColumnFullname();
1128                if (underlying != null) {
1129                    traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting);
1130                }
1131                // A computed/expression column has no physical base table.
1132            }
1133        }
1134
1135        if (unqualifiedStar) {
1136            expandAllFromTables(select, columnName, out, seenKeys, visiting);
1137        }
1138        for (String prefix : qualifiedStarPrefixes) {
1139            TTable matched = findTableByAliasOrName(select, prefix);
1140            if (matched != null) {
1141                collectBaseTablesSupplyingColumn(matched, columnName, out, seenKeys, visiting);
1142            }
1143        }
1144        // No star and no named match: this SELECT cannot supply the column.
1145    }
1146
1147    /**
1148     * Expand every table in {@code select}'s FROM clause as a possible supplier
1149     * of {@code columnName}.
1150     */
1151    private void expandAllFromTables(TSelectSqlStatement select, String columnName,
1152            List<TTable> out, Set<String> seenKeys,
1153            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1154        if (select == null || select.tables == null) {
1155            return;
1156        }
1157        for (int i = 0; i < select.tables.size(); i++) {
1158            collectBaseTablesSupplyingColumn(select.tables.getTable(i),
1159                columnName, out, seenKeys, visiting);
1160        }
1161    }
1162
1163    /**
1164     * Iteratively flatten a set-operation tree into the leaf SELECTs that
1165     * actually supply output column values, left to right. Uses an explicit
1166     * stack to avoid StackOverflowError on deeply nested set-operation chains.
1167     *
1168     * <p>The operator matters: UNION and INTERSECT take values from both
1169     * operands, but EXCEPT / MINUS take output values from the LEFT operand only
1170     * (the right operand merely filters rows), so its right branch is not a
1171     * source of the selected column and is skipped.</p>
1172     */
1173    private void flattenSetOperationLeaves(TSelectSqlStatement stmt, List<TSelectSqlStatement> leaves) {
1174        java.util.Deque<TSelectSqlStatement> stack = new java.util.ArrayDeque<TSelectSqlStatement>();
1175        stack.push(stmt);
1176        while (!stack.isEmpty()) {
1177            TSelectSqlStatement cur = stack.pop();
1178            if (cur == null) {
1179                continue;
1180            }
1181            if (cur.isCombinedQuery()) {
1182                gudusoft.gsqlparser.ESetOperatorType op = cur.getSetOperatorType();
1183                boolean rightSuppliesValues =
1184                    op != gudusoft.gsqlparser.ESetOperatorType.except
1185                    && op != gudusoft.gsqlparser.ESetOperatorType.minus;
1186                // Push right first (when it supplies values) so the left operand
1187                // is processed first.
1188                if (rightSuppliesValues && cur.getRightStmt() != null) {
1189                    stack.push(cur.getRightStmt());
1190                }
1191                if (cur.getLeftStmt() != null) {
1192                    stack.push(cur.getLeftStmt());
1193                }
1194            } else {
1195                leaves.add(cur);
1196            }
1197        }
1198    }
1199
1200    /**
1201     * Index of the first projection in {@code rcl} whose output name matches
1202     * {@code columnName}, or {@code -1} if none matches or a star projection
1203     * makes positional indexing unreliable.
1204     */
1205    private int indexOfNamedProjection(TResultColumnList rcl, String columnName) {
1206        if (rcl == null) {
1207            return -1;
1208        }
1209        for (int i = 0; i < rcl.size(); i++) {
1210            TResultColumn rc = rcl.getResultColumn(i);
1211            if (rc == null) {
1212                continue;
1213            }
1214            if (starQualifier(rc) != null) {
1215                // A star shifts subsequent positions unpredictably.
1216                return -1;
1217            }
1218            String name = rc.getDisplayName();
1219            if (name != null && nameMatcher.matches(name, columnName)) {
1220                return i;
1221            }
1222        }
1223        return -1;
1224    }
1225
1226    /**
1227     * Index of the first name in {@code names} that matches {@code columnName}.
1228     */
1229    private int indexOfMatchingName(gudusoft.gsqlparser.nodes.TObjectNameList names, String columnName) {
1230        if (names == null) {
1231            return -1;
1232        }
1233        for (int i = 0; i < names.size(); i++) {
1234            TObjectName name = names.getObjectName(i);
1235            String n = name != null ? name.getColumnNameOnly() : null;
1236            if (n != null && nameMatcher.matches(n, columnName)) {
1237                return i;
1238            }
1239        }
1240        return -1;
1241    }
1242
1243    /**
1244     * Trace a named projection column (e.g. {@code t.col} or {@code col}) into
1245     * the FROM clause of {@code body} and collect the base tables that supply
1246     * the referenced column.
1247     */
1248    private void traceNamedColumnIntoFrom(TSelectSqlStatement body, TObjectName underlying,
1249            List<TTable> out, Set<String> seenKeys,
1250            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1251        if (body == null || body.tables == null || underlying == null) {
1252            return;
1253        }
1254        String underlyingName = underlying.getColumnNameOnly();
1255        if (underlyingName == null || underlyingName.isEmpty()) {
1256            return;
1257        }
1258        String tableQualifier = columnTableQualifier(underlying);
1259        if (tableQualifier != null && !tableQualifier.isEmpty()) {
1260            TTable matched = findTableByAliasOrName(body, tableQualifier);
1261            if (matched != null) {
1262                collectBaseTablesSupplyingColumn(matched, underlyingName, out,
1263                    seenKeys, visiting);
1264                return;
1265            }
1266        }
1267
1268        // If resolver2 already resolved this inner projection column to a single
1269        // source (e.g. via DDL/sqlenv metadata), trust that source rather than
1270        // broadening to every FROM table. This keeps candidateTables accurate
1271        // for "SELECT c FROM t1 JOIN t2" when only t1 actually exposes c.
1272        TTable resolvedSource = underlying.getSourceTable();
1273        if (resolvedSource != null && isInFromClause(body, resolvedSource)) {
1274            collectBaseTablesSupplyingColumn(resolvedSource, underlyingName, out,
1275                seenKeys, visiting);
1276            return;
1277        }
1278
1279        // Unqualified and unresolved: the underlying column may originate from
1280        // any FROM table.
1281        for (int i = 0; i < body.tables.size(); i++) {
1282            collectBaseTablesSupplyingColumn(body.tables.getTable(i),
1283                underlyingName, out, seenKeys, visiting);
1284        }
1285    }
1286
1287    /**
1288     * Whether {@code target} is one of the tables in {@code body}'s FROM clause
1289     * (by object identity). Used to ensure a resolved {@code sourceTable} really
1290     * belongs to this derived table's scope before trusting it.
1291     */
1292    private boolean isInFromClause(TSelectSqlStatement body, TTable target) {
1293        if (body == null || body.tables == null || target == null) {
1294            return false;
1295        }
1296        for (int i = 0; i < body.tables.size(); i++) {
1297            if (body.tables.getTable(i) == target) {
1298                return true;
1299            }
1300        }
1301        return false;
1302    }
1303
1304    /**
1305     * Return the SELECT body of a derived table (inline subquery or CTE
1306     * reference), or {@code null} if {@code table} is a physical table.
1307     */
1308    private TSelectSqlStatement getDerivedBody(TTable table) {
1309        if (table == null) {
1310            return null;
1311        }
1312        if (table.getSubquery() != null) {
1313            return table.getSubquery();
1314        }
1315        if (table.isCTEName() && table.getCTE() != null) {
1316            return table.getCTE().getSubquery();
1317        }
1318        return null;
1319    }
1320
1321    /**
1322     * Add a physical base table to {@code out}, de-duplicated by qualified name.
1323     */
1324    private void addBaseTable(TTable table, List<TTable> out, Set<String> seenKeys) {
1325        if (table == null) {
1326            return;
1327        }
1328        String key = baseTableKey(table);
1329        if (seenKeys.add(key)) {
1330            out.add(table);
1331        }
1332    }
1333
1334    private String baseTableKey(TTable table) {
1335        String name = table.getFullName();
1336        if (name == null || name.isEmpty()) {
1337            name = table.getName();
1338        }
1339        if (name == null) {
1340            name = table.toString();
1341        }
1342        return name == null ? "" : name.toLowerCase();
1343    }
1344
1345    /**
1346     * If {@code rc} is a star result column, return its qualifier: an empty
1347     * string for an unqualified {@code *}, or the table prefix for {@code q.*}.
1348     * Returns {@code null} when {@code rc} is not a star column.
1349     */
1350    private String starQualifier(TResultColumn rc) {
1351        if (rc == null || rc.getExpr() == null) {
1352            return null;
1353        }
1354        TObjectName operand = rc.getExpr().getObjectOperand();
1355        String text = operand != null ? operand.toString() : rc.toString();
1356        if (text == null) {
1357            return null;
1358        }
1359        text = text.trim();
1360        if ("*".equals(text)) {
1361            return "";
1362        }
1363        if (text.endsWith(".*")) {
1364            String prefix = text.substring(0, text.length() - 2).trim();
1365            // Keep only the last segment of a multi-part prefix (e.g. a.b.* -> b).
1366            int dot = prefix.lastIndexOf('.');
1367            if (dot >= 0) {
1368                prefix = prefix.substring(dot + 1).trim();
1369            }
1370            return prefix;
1371        }
1372        return null;
1373    }
1374
1375    /**
1376     * Return the table qualifier of a (possibly) qualified column reference,
1377     * e.g. {@code "t"} for {@code t.col} or {@code "t"} for {@code s.t.col}.
1378     * Returns {@code null} for an unqualified column.
1379     */
1380    private String columnTableQualifier(TObjectName column) {
1381        if (column == null) {
1382            return null;
1383        }
1384        String full = column.toString();
1385        if (full == null) {
1386            return null;
1387        }
1388        int lastDot = full.lastIndexOf('.');
1389        if (lastDot < 0) {
1390            return null;
1391        }
1392        String prefix = full.substring(0, lastDot).trim();
1393        int prevDot = prefix.lastIndexOf('.');
1394        if (prevDot >= 0) {
1395            prefix = prefix.substring(prevDot + 1).trim();
1396        }
1397        return prefix.isEmpty() ? null : prefix;
1398    }
1399
1400    /**
1401     * Find a table in {@code body}'s FROM clause by alias or (last segment of)
1402     * name, matching case-insensitively.
1403     */
1404    private TTable findTableByAliasOrName(TSelectSqlStatement body, String qualifier) {
1405        if (body == null || body.tables == null || qualifier == null) {
1406            return null;
1407        }
1408        for (int i = 0; i < body.tables.size(); i++) {
1409            TTable t = body.tables.getTable(i);
1410            if (t == null) {
1411                continue;
1412            }
1413            String alias = t.getAliasName();
1414            if (alias != null && alias.equalsIgnoreCase(qualifier)) {
1415                return t;
1416            }
1417            String name = t.getName();
1418            if (name != null) {
1419                int dot = name.lastIndexOf('.');
1420                String shortName = dot >= 0 ? name.substring(dot + 1) : name;
1421                if (shortName.equalsIgnoreCase(qualifier)) {
1422                    return t;
1423                }
1424            }
1425        }
1426        return null;
1427    }
1428
1429    /**
1430     * Resolve a column within a specific namespace (for direct lookups).
1431     */
1432    public ResolutionResult resolveInNamespace(String columnName, INamespace namespace) {
1433        if (columnName == null || namespace == null) {
1434            return ResolutionResult.notFound("<null>");
1435        }
1436
1437        ColumnSource source = namespace.resolveColumn(columnName);
1438        if (source != null) {
1439            return ResolutionResult.exactMatch(source);
1440        }
1441
1442        return ResolutionResult.notFound(columnName);
1443    }
1444
1445    /**
1446     * Find all namespaces that contain a given column.
1447     * Used for implementing full candidate collection in ambiguous scenarios.
1448     */
1449    public List<INamespace> findNamespacesWithColumn(String columnName, IScope scope) {
1450        List<INamespace> result = new ArrayList<>();
1451
1452        for (INamespace ns : scope.getVisibleNamespaces()) {
1453            if (ns.hasColumn(columnName) == ColumnLevel.EXISTS) {
1454                result.add(ns);
1455            }
1456        }
1457
1458        return result;
1459    }
1460
1461    public INameMatcher getNameMatcher() {
1462        return nameMatcher;
1463    }
1464
1465    public ResolutionContext getContext() {
1466        return context;
1467    }
1468}