001package gudusoft.gsqlparser.resolver2;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.ETableSource;
005import gudusoft.gsqlparser.ETokenType;
006import gudusoft.gsqlparser.TSourceToken;
007import gudusoft.gsqlparser.nodes.TObjectName;
008import gudusoft.gsqlparser.nodes.TResultColumn;
009import gudusoft.gsqlparser.nodes.TResultColumnList;
010import gudusoft.gsqlparser.nodes.TTable;
011import gudusoft.gsqlparser.nodes.TTableList;
012import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
013import gudusoft.gsqlparser.resolver2.matcher.INameMatcher;
014import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource;
015import gudusoft.gsqlparser.resolver2.model.ColumnSource;
016import gudusoft.gsqlparser.resolver2.model.FieldPath;
017import gudusoft.gsqlparser.resolver2.model.ResolutionContext;
018import gudusoft.gsqlparser.resolver2.model.ResolutionResult;
019import gudusoft.gsqlparser.resolver2.namespace.CTENamespace;
020import gudusoft.gsqlparser.resolver2.namespace.INamespace;
021import gudusoft.gsqlparser.resolver2.namespace.TableFunctionLayout;
022import gudusoft.gsqlparser.resolver2.namespace.TableNamespace;
023import gudusoft.gsqlparser.resolver2.namespace.UnnestNamespace;
024import gudusoft.gsqlparser.resolver2.scope.FromScope;
025import gudusoft.gsqlparser.resolver2.scope.IScope;
026import gudusoft.gsqlparser.resolver2.scope.ResolvedImpl;
027import gudusoft.gsqlparser.resolver2.scope.SelectScope;
028import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
029import gudusoft.gsqlparser.util.SQLUtil;
030
031import java.util.ArrayList;
032import java.util.Arrays;
033import java.util.Collections;
034import java.util.Comparator;
035import java.util.IdentityHashMap;
036import java.util.LinkedHashSet;
037import java.util.List;
038import java.util.Set;
039
040/**
041 * Core component for resolving column references to their sources.
042 *
043 * Key responsibilities:
044 * 1. Resolve TObjectName (column references) using scope tree
045 * 2. Handle qualified and unqualified names
046 * 3. Detect and report ambiguities
047 * 4. Apply GUESS_COLUMN_STRATEGY for ambiguous columns
048 * 5. Update TObjectName with resolution results
049 * 6. Update ResolutionContext for global querying
050 */
051public class NameResolver {
052
053    private final INameMatcher nameMatcher;
054    private final ResolutionContext context;
055    private final TSQLResolverConfig config;
056
057    /**
058     * Create a NameResolver with full configuration.
059     *
060     * @param config The resolver configuration (includes name matcher and strategy)
061     * @param context The resolution context for tracking results
062     */
063    public NameResolver(TSQLResolverConfig config, ResolutionContext context) {
064        this.config = config;
065        this.nameMatcher = config.getNameMatcher();
066        this.context = context;
067    }
068
069    /**
070     * Binding-trace capture sink (dynamic-SQL publication proof, R4 step 3);
071     * {@code null} unless {@code TSQLResolverConfig.captureBindingTrace} —
072     * the hook below is then a single null check. Observation-only.
073     */
074    private gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry bindingTraceRegistry;
075
076    public void setBindingTraceRegistry(
077            gudusoft.gsqlparser.resolver2.binding.BindingTraceRegistry registry) {
078        this.bindingTraceRegistry = registry;
079    }
080
081    /**
082     * Record how {@code objName} was bound. The winning match's namespace,
083     * scope, path and remaining names are exactly the information
084     * {@code processResolvedMatches} discards when constructing
085     * {@link ResolutionResult}; the trace preserves them for the P3 proof.
086     * When the result did not come from the recorded matches (e.g. the
087     * struct-field fallback), the match data no longer corresponds and the
088     * trace comes out incomplete — fail-closed by construction.
089     */
090    private void captureBindingTrace(TObjectName objName, IScope startingScope,
091            ResolvedImpl resolved, ResolutionResult result, boolean fallbackUsed) {
092        ResolvedImpl.Match winning = resolved.getCount() > 0 ? resolved.getMatches().get(0) : null;
093        int climb = -1;
094        if (winning != null) {
095            int hops = 0;
096            for (IScope s = startingScope; s != null; s = s.getParent(), hops++) {
097                if (s == winning.scope) {
098                    climb = hops;
099                    break;
100                }
101            }
102        }
103        bindingTraceRegistry.register(gudusoft.gsqlparser.resolver2.binding.BindingTrace
104                .scopeResolution(objName,
105                        result.getStatus(),
106                        winning != null ? winning.namespace : null,
107                        winning != null ? definitionNodeOf(winning.namespace) : null,
108                        objName.getSourceTable(),
109                        String.valueOf(startingScope.getScopeType()),
110                        winning != null ? String.valueOf(winning.scope.getScopeType()) : null,
111                        winning != null ? String.valueOf(winning.path) : null,
112                        resolved.getCount(),
113                        climb,
114                        // A column reference matched via its container namespace
115                        // legitimately leaves ONE remaining segment (the column
116                        // itself); more than one means a partially-consumed path
117                        // (struct access) whose tail binding we did not see.
118                        winning != null && winning.remainingNames.size() <= 1,
119                        result.isExactMatch(),
120                        fallbackUsed));
121    }
122
123    /**
124     * The AST node that defines a namespace's binder — what the publication
125     * proof checks spans against. Unknown namespace kinds return null and the
126     * trace comes out incomplete (fail-closed).
127     */
128    private static Object definitionNodeOf(INamespace ns) {
129        if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.TableNamespace) {
130            return ((gudusoft.gsqlparser.resolver2.namespace.TableNamespace) ns).getTable();
131        }
132        if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.CTENamespace) {
133            return ((gudusoft.gsqlparser.resolver2.namespace.CTENamespace) ns).getCTE();
134        }
135        if (ns instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) {
136            return ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) ns).getSubquery();
137        }
138        return null;
139    }
140
141    /**
142     * Create a NameResolver with just name matcher (backward compatibility).
143     * Uses default configuration for GUESS_COLUMN_STRATEGY.
144     *
145     * @deprecated Use NameResolver(TSQLResolverConfig, ResolutionContext) instead
146     */
147    @Deprecated
148    public NameResolver(INameMatcher nameMatcher, ResolutionContext context) {
149        this.nameMatcher = nameMatcher;
150        this.context = context;
151        this.config = null; // Will use TBaseType.GUESS_COLUMN_STRATEGY
152    }
153
154    /**
155     * Get the effective GUESS_COLUMN_STRATEGY.
156     * Returns config value if available, otherwise TBaseType.GUESS_COLUMN_STRATEGY.
157     */
158    private int getGuessColumnStrategy() {
159        if (config != null) {
160            return config.getGuessColumnStrategy();
161        }
162        return gudusoft.gsqlparser.TBaseType.GUESS_COLUMN_STRATEGY;
163    }
164
165    private static final boolean DEBUG_RESOLUTION = false;
166
167    /**
168     * Resolve a column reference (TObjectName) within a given scope.
169     *
170     * @param objName The column reference to resolve
171     * @param scope The scope where the reference appears
172     * @return Resolution result
173     */
174    /**
175     * A subquery's unqualified name compared with a column of its own tables is the enclosing query's when its own
176     * tables do not prove it and that query does, by the rule the parse applies too
177     * ({@link TSelectSqlStatement#outerCorrelationTable}): bind it to that table there (Mantis 4797).
178     *
179     * @return true when the name was bound
180     */
181    private boolean bindToOuterCorrelationTable(TObjectName objName, IScope scope, List<String> nameParts,
182            ResolvedImpl resolved) {
183        if ((nameParts.size() != 1) || !(scope instanceof SelectScope)
184                || !(scope.getNode() instanceof TSelectSqlStatement)) {
185            return false;
186        }
187        TTable outer = TSelectSqlStatement.outerCorrelationTable(objName, (TSelectSqlStatement) scope.getNode(), null);
188        if (outer == null) {
189            return false;
190        }
191        for (IScope enclosing = ((SelectScope) scope).getLookupParent(); enclosing instanceof SelectScope;
192                enclosing = ((SelectScope) enclosing).getLookupParent()) {
193            FromScope from = ((SelectScope) enclosing).getFromScope();
194            if (from == null) {
195                continue;
196            }
197            for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : from.getChildren()) {
198                if (child.getNamespace().getSourceTable() == outer) {
199                    resolved.found(child.getNamespace(), child.isNullable(), enclosing,
200                            new gudusoft.gsqlparser.resolver2.model.ResolvePath(), nameParts);
201                    return true;
202                }
203            }
204        }
205        return false;
206    }
207
208    public ResolutionResult resolve(TObjectName objName, IScope scope) {
209        if (objName == null || scope == null) {
210            return ResolutionResult.notFound("<null>");
211        }
212
213        // ClickHouse expression-CTE alias (WITH <expr> AS ident): phase 1
214        // resolved it to a scalar symbol (expressionCteRef). It is not a
215        // column of any relation — namespace matching would fabricate a
216        // binding to whichever FROM table happens to be in scope.
217        if (objName.getExpressionCteRef() != null && objName.getSourceTable() == null) {
218            return ResolutionResult.notFound(objName.toString(),
219                    "expression-CTE alias, resolved as a scalar symbol");
220        }
221
222        // Extract name parts from TObjectName
223        List<String> nameParts = extractNameParts(objName);
224        if (nameParts.isEmpty()) {
225            return ResolutionResult.notFound("<empty>");
226        }
227
228        // Trino family: the dotted identifiers of the name decide what it reads, not the name slots the grammar
229        // filled (see readsRowFields), and the longest qualifier that names a relation in scope wins, as in Trino.
230        // A qualified table name (sch.events.id, sch.events.payload.address) is that table's column, followed by
231        // fields when parts remain; otherwise a head that names a relation makes a name of three or more parts that
232        // relation's column followed by fields: e.payload.address.city is the column payload of e, whose slots read
233        // database e, schema payload, table address (Mantis 4822).
234        //
235        // When neither applies and no part names a relation, the head is a column and every later part a field
236        // (payload.address.city; Mantis 4811 did this for two parts). That reading is decided from the identifiers,
237        // not the slots, so a name already re-slotted reads the same on resolver2's later passes. A later part that is
238        // the name of a table in scope (x.events.id FROM events) keeps the grammar's table reading, as GSP accepts a
239        // qualifier it does not know before a table name; an alias cannot be qualified, so it does not count.
240        List<String> chain = readsRowFields(objName) ? identifierChain(objName) : Collections.<String>emptyList();
241        ResolvedImpl resolved = new ResolvedImpl();
242        boolean qualifiedRelation = false;
243        boolean relationHead = false;
244        ResolutionResult columnHeadResult = null;
245        if (chain.size() >= 2) {
246            boolean headNamesRelation = qualifierNamesVisibleRelation(chain.get(0), scope);
247            if (chain.size() >= 3 && findQualifiedRelation(chain, scope, resolved)) {
248                nameParts = chain;
249                qualifiedRelation = true;
250            } else if (chain.size() >= 3 && headNamesRelation) {
251                nameParts = chain;
252                relationHead = true;
253            } else if (!headNamesRelation && (!namesVisibleTable(chain.subList(1, chain.size() - 1), scope)
254                    || declaresColumn(chain.get(0), scope))) {
255                columnHeadResult = tryStructFieldFallback(objName, chain, scope);
256            }
257        }
258        // Redshift SUPER and Oracle object columns: o.customer.address.city is the column customer of the relation o
259        // with the attribute path address.city. The grammar slots of a four-part name put o in the database slot,
260        // which the slot reading drops, so the name read as address.city and was NOT_FOUND. When the written head
261        // names a relation in scope and no qualified table name matches, the written parts are read instead
262        // (Mantis 4839, 4840).
263        if (chain.isEmpty() && readsAttributePaths()) {
264            List<String> attributeChain = identifierChain(objName);
265            if (attributeChain.size() >= 3 && nameParts.size() < attributeChain.size()
266                    && qualifierNamesVisibleRelation(attributeChain.get(0), scope)
267                    && !findQualifiedRelation(attributeChain, scope, new ResolvedImpl())) {
268                nameParts = attributeChain;
269            }
270        }
271        // A wildcard below a column, payload.* or payload.address.*, reads every field of it, as BigQuery customer.*
272        // does: the same reading when no part before the wildcard names a relation, and the same owner rule.
273        boolean columnStar = false;
274        if (readsRowFields(objName) && chain.isEmpty()) {
275            List<String> starChain = new ArrayList<String>();
276            for (TSourceToken token : objName.getStarQualifierChain()) {
277                starChain.add(token.toString());
278            }
279            if (!starChain.isEmpty() && !qualifierNamesVisibleRelation(starChain.get(0), scope)
280                    && (!namesVisibleTable(starChain.subList(1, starChain.size()), scope)
281                            || declaresColumn(starChain.get(0), scope))) {
282                List<String> parts = new ArrayList<String>(starChain);
283                parts.add(objName.getColumnNameOnly());
284                columnHeadResult = tryStructFieldFallback(objName, parts, scope);
285                columnStar = columnHeadResult != null;
286            } else if (starChain.size() >= 3 && qualifierNamesVisibleRelation(starChain.get(0), scope)) {
287                // e.payload.address.*: the column payload of e, then fields, then the wildcard
288                ResolvedImpl relationColumn = new ResolvedImpl();
289                List<String> columnParts = starChain.subList(0, 2);
290                scope.resolve(columnParts, nameMatcher, false, relationColumn);
291                ResolutionResult column = processResolvedMatches(objName, columnParts, relationColumn, scope);
292                if (column.isExactMatch() && column.getColumnSource() != null) {
293                    List<String> fields = new ArrayList<String>(starChain.subList(2, starChain.size()));
294                    fields.add(objName.getColumnNameOnly());
295                    columnHeadResult = ResolutionResult.exactMatch(column.getColumnSource().withFieldPath(
296                            FieldPath.of(fields), "struct_field_access"));
297                    columnStar = true;
298                }
299            }
300        }
301
302        if (DEBUG_RESOLUTION) {
303            System.out.println("[DEBUG-RESOLVE] Resolving: " + objName +
304                " nameParts=" + nameParts + " scopeType=" + scope.getScopeType());
305        }
306
307        boolean fallbackUsed = false;
308        ResolutionResult result;
309        if (columnHeadResult != null && columnHeadResult.isExactMatch()) {
310            result = columnHeadResult;
311            fallbackUsed = true;
312        } else {
313            // Use scope to resolve the name
314            if (!qualifiedRelation && !bindToOuterCorrelationTable(objName, scope, nameParts, resolved)) {
315                scope.resolve(nameParts, nameMatcher, false, resolved);
316            }
317
318            if (DEBUG_RESOLUTION) {
319                System.out.println("[DEBUG-RESOLVE]   Resolved matches: " + resolved.getCount());
320                if (resolved.getCount() > 1) {
321                    for (ResolvedImpl.Match m : resolved.getMatches()) {
322                        System.out.println("[DEBUG-RESOLVE]     Match: " + m.namespace.getDisplayName() +
323                            " type=" + m.namespace.getClass().getSimpleName() +
324                            " id=" + System.identityHashCode(m.namespace) +
325                            " remaining=" + m.remainingNames +
326                            " scope=" + m.scope.getScopeType());
327                    }
328                }
329            }
330
331            // Process resolution results
332            result = processResolvedMatches(objName, nameParts, resolved, scope);
333
334            // Delta 3: Struct-field fallback for BigQuery/Snowflake
335            // If resolution failed and we have a 2-part qualified name like "customer.customer_id",
336            // try interpreting it as column.field (struct field access) instead of table.column.
337            // Only for 2-part names; 3+ part no-alias case (e.g., customer.address.city) is not
338            // handled here to avoid changing resolution paths for existing 3-part BigQuery patterns
339            // (e.g., purchases.first.msts) that are correctly handled by DataFlowAnalyzer heuristics.
340            // The alias case (o.customer.address.city) IS handled by resolveColumnPath() in
341            // processResolvedMatches() and does not depend on this fallback.
342            if (!result.isExactMatch() && nameParts.size() == 2 && isStructFieldVendor()) {
343                ResolutionResult structFieldResult = tryStructFieldFallback(objName, nameParts, scope);
344                if (structFieldResult != null && structFieldResult.isExactMatch()) {
345                    result = structFieldResult;
346                    fallbackUsed = true;
347                    if (DEBUG_RESOLUTION) {
348                        System.out.println("[DEBUG-RESOLVE]   Struct-field fallback succeeded for: " + objName);
349                    }
350                } else if (structFieldResult != null && structFieldResult.isAmbiguous()
351                        && config != null && config.getVendor() == EDbVendor.dbvbigquery) {
352                    // BigQuery: customer.name, with no relation customer in scope, reads the field name of a column
353                    // customer that several relations provide. Recording that, rather than a table customer that no
354                    // FROM clause names, lets lineage publish no owner (STRUCT tree plan rule R3).
355                    result = structFieldResult;
356                    objName.columnToProperty();
357                }
358            }
359
360            if (!result.isExactMatch() && columnHeadResult != null && columnHeadResult.isAmbiguous() && columnStar) {
361                // payload.* where several relations could provide payload: undecided, not a relation payload
362                result = columnHeadResult;
363            } else if (!result.isExactMatch() && columnHeadResult != null && columnHeadResult.isAmbiguous()
364                    && chain.size() >= 3) {
365                // The reading settles the role but not the owner: payload is a column of one of the relations
366                // in scope, not a schema payload with a table address that no FROM clause names, which lineage
367                // would publish. (Two parts read as an unknown table payload leave an orphan column, as before.)
368                result = columnHeadResult;
369                objName.columnToFieldPath(chain.size() - 1);
370            }
371        }
372        if (relationHead && !result.isExactMatch()) {
373            // Likewise when the scope cannot bind the relation the head names (the name of an aliased table, which
374            // GSP accepts as a qualifier): its second part is still the column, not the last one.
375            objName.columnToFieldPath(chain.size() - 2);
376        }
377
378        // Delta 4: Side-channel hint for 3+ part no-alias struct access (BigQuery only).
379        // When resolution failed and we have 3+ parts (e.g., customer.address.city),
380        // set a StructFieldHint WITHOUT changing the resolution result or sourceTable.
381        // This provides struct path info to DataFlowAnalyzer without altering lineage topology.
382        if (!result.isExactMatch() && nameParts.size() >= 3 && isStructFieldHintVendor()) {
383            ResolutionResult undecided = trySetStructFieldHint(objName, nameParts, scope);
384            if (undecided != null) {
385                // customer.address.city where several relations could provide customer: the reading is a field
386                // path, not a table customer.address that no FROM clause names, and the owner is undecided
387                // (STRUCT tree plan rule R3). The name keeps its slots: its name slots cannot hold a column with two
388                // fields, and the candidates carry the path.
389                result = undecided;
390            }
391        }
392
393        if (DEBUG_RESOLUTION) {
394            System.out.println("[DEBUG-RESOLVE]   Result: " + result.getStatus() +
395                (result.isExactMatch() && result.getColumnSource() != null ?
396                    " source=" + result.getColumnSource().getExposedName() : ""));
397        }
398
399        // Update TObjectName and context
400        updateObjectNameWithResult(objName, result);
401
402        // Binding-trace capture (observation-only; null unless opted in).
403        // Runs AFTER the update so the trace observes the post-resolution
404        // sourceTable — the identity the registry's staleness check compares
405        // against when later correction handlers change or clear it.
406        if (bindingTraceRegistry != null) {
407            captureBindingTrace(objName, scope, resolved, result, fallbackUsed);
408        }
409
410        return result;
411    }
412
413    /**
414     * Check if the current vendor supports struct-field access syntax (column.field).
415     * Currently supported: BigQuery, Snowflake
416     */
417    /**
418     * The single source a {@code JOIN ... USING} merge leaves for this column, or null when the
419     * candidates are not one merged column.
420     *
421     * <p>SQL exposes a merged column ONCE, so an unqualified reference to it is not ambiguous
422     * across the sides that merged it — {@code SELECT c FROM a JOIN b USING (c)} names one column.
423     * Three things have to hold before that can be said, and each of them is a way this went wrong
424     * in review:
425     * <ul>
426     *   <li>the merge is declared on the scope that produced these candidates, not on any ancestor.
427     *       An outer {@code USING (c)} says nothing about an inner query's own two providers of
428     *       {@code c}, and consulting ancestors turned that inner ambiguity into an exact match.
429     *   <li>the merge covers EVERY candidate. {@code SELECT c FROM a JOIN b USING(c), d} has three
430     *       providers and only two of them merged, so it stays ambiguous.
431     *   <li>the join type says which side the value comes from. INNER and LEFT keep the left side,
432     *       RIGHT keeps the right; FULL is {@code COALESCE(a.c, b.c)} and cannot be represented by
433     *       one source, so it is left ambiguous rather than answered with half of it.
434     * </ul>
435     */
436    private ColumnSource mergedUsingSource(ResolvedImpl resolved, String columnName,
437                                           List<ColumnSource> candidates) {
438        EDbVendor vendor = config != null ? config.getVendor() : null;
439        if (vendor == null || columnName == null || candidates.size() < 2) {
440            return null;
441        }
442        List<gudusoft.gsqlparser.nodes.TTable> candidateTables =
443                new ArrayList<gudusoft.gsqlparser.nodes.TTable>();
444        for (ColumnSource candidate : candidates) {
445            // The RELATION the candidate came from, which for a derived table or CTE is the FROM
446            // item itself — not getFinalTable(), which follows through to the underlying base table
447            // and so never matches the join's participants.
448            gudusoft.gsqlparser.nodes.TTable table = relationOf(candidate.getSourceNamespace());
449            if (table == null) {
450                return null;   // cannot prove the candidate took part in any merge
451            }
452            candidateTables.add(table);
453        }
454
455        for (ResolvedImpl.Match match : resolved.getMatches()) {
456            if (!(match.scope instanceof gudusoft.gsqlparser.resolver2.scope.SelectScope)) {
457                continue;
458            }
459            gudusoft.gsqlparser.resolver2.scope.SelectScope.UsingMerge merge =
460                    ((gudusoft.gsqlparser.resolver2.scope.SelectScope) match.scope)
461                            .findCoveringMerge(config.identifierService(), columnName, candidateTables);
462            if (merge == null) {
463                continue;
464            }
465            gudusoft.gsqlparser.nodes.TTable owner = mergedValueOwner(merge);
466            if (owner == null) {
467                return null;
468            }
469            for (ColumnSource candidate : candidates) {
470                if (relationOf(candidate.getSourceNamespace()) == owner) {
471                    return candidate;
472                }
473            }
474            return null;
475        }
476        return null;
477    }
478
479    /**
480     * The FROM item a namespace stands for, or null when it does not stand for one.
481     *
482     * <p>Deliberately a switch over the namespace kinds rather than a call to {@code
483     * getFinalTable()}: that method resolves THROUGH a derived table to the base table underneath,
484     * which is the right answer for lineage and the wrong one here, where the question is which
485     * side of the join this candidate is.
486     */
487    private static gudusoft.gsqlparser.nodes.TTable relationOf(INamespace namespace) {
488        if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.TableNamespace) {
489            return ((gudusoft.gsqlparser.resolver2.namespace.TableNamespace) namespace).getTable();
490        }
491        if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) {
492            return ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).getSourceTable();
493        }
494        if (namespace instanceof gudusoft.gsqlparser.resolver2.namespace.CTENamespace) {
495            return ((gudusoft.gsqlparser.resolver2.namespace.CTENamespace) namespace).getReferencingTable();
496        }
497        return null;
498    }
499
500    /**
501     * Which participant a merged column's value comes from, or null when no single one does.
502     * Participants are held in join order, left side first.
503     */
504    private static gudusoft.gsqlparser.nodes.TTable mergedValueOwner(
505            gudusoft.gsqlparser.resolver2.scope.SelectScope.UsingMerge merge) {
506        List<gudusoft.gsqlparser.nodes.TTable> participants = merge.getParticipants();
507        if (participants.isEmpty()) {
508            return null;
509        }
510        gudusoft.gsqlparser.EJoinType joinType = merge.getJoinType();
511        if (joinType == gudusoft.gsqlparser.EJoinType.full
512                || joinType == gudusoft.gsqlparser.EJoinType.fullouter) {
513            // COALESCE of both sides: naming one would publish half a dependency as if it were all
514            // of it, so this stays ambiguous.
515            return null;
516        }
517        if (joinType == gudusoft.gsqlparser.EJoinType.right
518                || joinType == gudusoft.gsqlparser.EJoinType.rightouter) {
519            return participants.get(participants.size() - 1);
520        }
521        return participants.get(0);
522    }
523
524    private boolean isStructFieldVendor() {
525        if (config == null) {
526            return false;
527        }
528        return gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(config.getVendor())
529                .isStructFieldVendor();
530    }
531
532    /**
533     * Whether a relation followed by two or more parts ({@code o.customer.address.city}) is a column of that
534     * relation with a field path, rather than its last part as the column: the struct-field vendors, and the Trino
535     * family, whose relation-headed names reach here with all their dotted identifiers (Mantis 4822).
536     */
537    /** Redshift SUPER and Oracle object columns: an attribute path after a relation-qualified column (Mantis 4839). */
538    private boolean readsAttributePaths() {
539        return config != null && gudusoft.gsqlparser.nodes.structured.NestedAccessProfile
540                .forVendor(config.getVendor()).readsAttributePaths();
541    }
542
543    private boolean readsColumnPaths() {
544        // Redshift SUPER and Oracle object columns: o.customer.address.city is the column customer of the
545        // relation o with the attribute path address.city, not a four-part name (Mantis 4839, 4840)
546        return isStructFieldVendor() || (config != null && (gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(config.getVendor()).readsFieldChains()
547                || readsAttributePaths()));
548    }
549
550    /**
551     * The Trino family: Athena, Presto, and Trino, which this library parses with the Presto grammar. Trino reads a
552     * dotted name whose qualifier names no relation in scope as a column followed by fields of its ROW value, while
553     * these PostgreSQL-style grammars fill the name slots as if every qualifier were a table, schema or database:
554     * {@code payload.items[1].name} as table {@code payload}, {@code payload.address.city} as schema {@code payload},
555     * table {@code address}. So in this family TSQLResolver2 reads a column name from its dotted identifiers, a read
556     * of a field is a read of its column, and lineage models a STRUCT column as the declared column
557     * (Mantis 4811, 4822).
558     *
559     * @param vendor the vendor of the analysis; a Trino analysis reads statements the Presto grammar stamped
560     *               {@code dbvpresto}
561     *
562     * @deprecated read {@code NestedAccessProfile.forVendor(vendor).isTrinoFamily()}
563     */
564    @Deprecated
565    public static boolean isTrinoFamily(EDbVendor vendor) {
566        return gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(vendor).isTrinoFamily();
567    }
568
569    /**
570     * The vendors that read a dotted name by what its qualifiers name, as {@link #isTrinoFamily the Trino family}
571     * does: a qualifier that names a relation in scope is that relation, otherwise the first part is a column and
572     * every later part a field of its STRUCT value. Hive, Spark SQL and Databricks resolve {@code payload.name} and
573     * {@code customer.address.city} the same way, so their field reads are reads of the declared column with a field
574     * path, and lineage models a STRUCT column as a tree, as for BigQuery and the Trino family (Mantis 4821, 4837).
575     *
576     * @deprecated read {@code NestedAccessProfile.forVendor(vendor).readsFieldChains()}
577     */
578    @Deprecated
579    public static boolean readsFieldChains(EDbVendor vendor) {
580        return gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(vendor).readsFieldChains();
581    }
582
583    /**
584     * Whether a column name is read from its dotted identifiers ({@link #isTrinoFamily}) rather than from its name
585     * slots: never a re-split of the rendered name, which would cut a subscript such as {@code items[1]}.
586     */
587    private boolean readsRowFields(TObjectName objName) {
588        return config != null && objName != null && gudusoft.gsqlparser.nodes.structured.NestedAccessProfile.forVendor(config.getVendor()).readsFieldChains();
589    }
590
591    /** The dotted identifiers of a name as written: {@link TObjectName#getIdentifierChain()}. */
592    private static List<String> identifierChain(TObjectName objName) {
593        List<TSourceToken> tokens = objName.getIdentifierChain();
594        List<String> parts = new ArrayList<String>(tokens.size());
595        for (TSourceToken token : tokens) {
596            parts.add(token.toString());
597        }
598        return parts;
599    }
600
601    /**
602     * Whether a relation in {@code scope} declares a column {@code name}, by DDL or metadata rather than by inference
603     * from its use. A name that starts with such a column reads it, even when a later part is also the name of a table
604     * in scope: with {@code source(payload ROW(events ROW(id)))} declared, {@code payload.events.id FROM source, events}
605     * reads the field {@code events.id} of {@code payload}. An undeclared head ({@code x.events.id FROM events}) keeps
606     * the table reading GSP accepts.
607     */
608    private boolean declaresColumn(String name, IScope scope) {
609        ResolvedImpl resolved = new ResolvedImpl();
610        scope.resolve(Collections.singletonList(name), nameMatcher, false, resolved);
611        for (ResolvedImpl.Match match : resolved.getMatches()) {
612            ColumnSource source = match.namespace.resolveColumn(name);
613            if (source != null && source.hasDefiniteEvidence()) {
614                return true;
615            }
616        }
617        return false;
618    }
619
620    /**
621     * Whether any of {@code parts} is the name of a table visible in {@code scope}, aliased or not. An alias does not
622     * count: it is never qualified, so {@code payload.address.city} with a relation aliased {@code address} in scope
623     * is still not a table reading (Mantis 4822).
624     */
625    private boolean namesVisibleTable(List<String> parts, IScope scope) {
626        if (parts.isEmpty()) {
627            return false;
628        }
629        for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : visibleRelations(scope)) {
630            TTable table = child.getNamespace() == null ? null : child.getNamespace().getSourceTable();
631            String tableName = table == null || table.getTableName() == null ? null
632                    : table.getTableName().getObjectString();
633            if (tableName == null || tableName.isEmpty()) {
634                continue;
635            }
636            for (String part : parts) {
637                if (nameMatcher.matches(part, tableName)) {
638                    return true;
639                }
640            }
641        }
642        return false;
643    }
644
645    /**
646     * Whether {@code qualifier} names a relation visible in {@code scope}: its alias, or the table's own name even
647     * when it is aliased. A failed {@code e.missing} in {@code FROM events e} is a column that relation lacks, not
648     * the field {@code missing} of a column {@code e}; and Trino hides an aliased table's own name, but GSP links
649     * {@code events.id} in {@code FROM events e} to that table. Reading either qualifier as a column would invent
650     * a source (Mantis 4811).
651     */
652    private boolean qualifierNamesVisibleRelation(String qualifier, IScope scope) {
653        if (qualifier == null) {
654            return false;
655        }
656        if (namesLateralView(qualifier, scope)) {
657            return true;
658        }
659        for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : visibleRelations(scope)) {
660            String alias = child.getAlias();
661            if (alias != null && !alias.isEmpty() && nameMatcher.matches(qualifier, alias)) {
662                return true;
663            }
664            TTable table = child.getNamespace() == null ? null : child.getNamespace().getSourceTable();
665            if (table == null) {
666                continue;
667            }
668            if (table.getAliasName() != null && !table.getAliasName().isEmpty()
669                    && nameMatcher.matches(qualifier, table.getAliasName())) {
670                return true;
671            }
672            String tableName = table.getTableName() == null ? null : table.getTableName().getObjectString();
673            if (tableName != null && !tableName.isEmpty() && nameMatcher.matches(qualifier, tableName)) {
674                return true;
675            }
676        }
677        return false;
678    }
679
680    /**
681     * Binds the longest prefix of two or three parts of {@code chain}, short of its last part, that is the qualified
682     * name of a table in scope ({@code schema.table}, {@code catalog.schema.table}, aliased or not), searching the
683     * innermost scope first, and records each such table of that scope in {@code resolved} with the rest of the chain
684     * as the names left to resolve in it. Trino tries the longest qualifier first, so {@code sch.events.id} is the
685     * column id of the table sch.events in scope even when an enclosing query aliases another relation {@code sch},
686     * which the head alone would name; and {@code sch.events.payload.address} is its column payload with a field.
687     * resolver2 otherwise binds a single-part qualifier only (Mantis 4822).
688     *
689     * @return whether a table was found
690     */
691    private boolean findQualifiedRelation(List<String> chain, IScope scope, ResolvedImpl resolved) {
692        for (int length = Math.min(chain.size() - 1, 3); length >= 2; length--) {
693            List<String> qualifier = chain.subList(0, length);
694            Set<IScope> seen = Collections.newSetFromMap(new IdentityHashMap<IScope, Boolean>());
695            for (IScope current = scope; current != null && seen.add(current); current = current.getParent()) {
696                boolean found = false;
697                for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : relationsOf(current)) {
698                    TTable table = child.getNamespace() == null ? null : child.getNamespace().getSourceTable();
699                    if (table != null && table.getTableName() != null
700                            && qualifiedNameEndsWith(table.getTableName(), qualifier)) {
701                        resolved.found(child.getNamespace(), child.isNullable(), current,
702                                new gudusoft.gsqlparser.resolver2.model.ResolvePath(),
703                                chain.subList(length, chain.size()));
704                        found = true;
705                    }
706                }
707                if (found) {
708                    return true;
709                }
710            }
711        }
712        return false;
713    }
714
715    /** Whether the qualified name of a table ends with {@code parts}: {@code sch.events} for {@code db.sch.events}. */
716    private boolean qualifiedNameEndsWith(TObjectName tableName, List<String> parts) {
717        List<String> qualified = new ArrayList<String>(3);
718        if (!tableName.getDatabaseString().isEmpty()) {
719            qualified.add(tableName.getDatabaseString());
720        }
721        if (!tableName.getSchemaString().isEmpty()) {
722            qualified.add(tableName.getSchemaString());
723        }
724        qualified.add(tableName.getObjectString());
725        if (parts.size() > qualified.size()) {
726            return false;
727        }
728        for (int i = 1; i <= parts.size(); i++) {
729            if (!nameMatcher.matches(parts.get(parts.size() - i), qualified.get(qualified.size() - i))) {
730                return false;
731            }
732        }
733        return true;
734    }
735
736    /**
737     * Whether {@code qualifier} is the alias of a Hive-family LATERAL VIEW of a SELECT in scope,
738     * {@code explode_table} in {@code LATERAL VIEW explode(arr) explode_table AS col1}: a relation of that SELECT
739     * (its {@code LateralViewNamespace}), so {@code explode_table.col1} is its column, not a field of a column
740     * (Mantis 4821, 4845).
741     */
742    private boolean namesLateralView(String qualifier, IScope scope) {
743        Set<IScope> seen = Collections.newSetFromMap(new IdentityHashMap<IScope, Boolean>());
744        for (IScope current = scope; current != null && seen.add(current); current = current.getParent()) {
745            if (!(current.getNode() instanceof TSelectSqlStatement)) {
746                continue;
747            }
748            TSelectSqlStatement select = (TSelectSqlStatement) current.getNode();
749            if (select.tables == null) {
750                continue;
751            }
752            for (int i = 0; i < select.tables.size(); i++) {
753                TTable table = select.tables.getTable(i);
754                if (table.getLateralViewList() == null) {
755                    continue;
756                }
757                for (gudusoft.gsqlparser.nodes.TLateralView view : table.getLateralViewList()) {
758                    if (view.getTableAlias() != null && view.getTableAlias().getAliasName() != null
759                            && nameMatcher.matches(qualifier, view.getTableAlias().getAliasName().toString())) {
760                        return true;
761                    }
762                }
763            }
764        }
765        return false;
766    }
767
768    /**
769     * The relations visible in {@code scope} and its enclosing scopes, with the FROM scopes of SELECT, UPDATE,
770     * DELETE and MERGE. Walked by hand: IScope.getVisibleNamespaces() recurses between a SELECT scope and its FROM
771     * scope, whose parent is that SELECT scope.
772     */
773    private static List<gudusoft.gsqlparser.resolver2.model.ScopeChild> visibleRelations(IScope scope) {
774        List<gudusoft.gsqlparser.resolver2.model.ScopeChild> children =
775                new ArrayList<gudusoft.gsqlparser.resolver2.model.ScopeChild>();
776        Set<IScope> seen = Collections.newSetFromMap(new IdentityHashMap<IScope, Boolean>());
777        for (IScope current = scope; current != null && seen.add(current); current = current.getParent()) {
778            children.addAll(relationsOf(current));
779        }
780        return children;
781    }
782
783    /** The relations of one scope: its children and those of its FROM scope ({@link #fromScopeOf}). */
784    private static List<gudusoft.gsqlparser.resolver2.model.ScopeChild> relationsOf(IScope scope) {
785        List<gudusoft.gsqlparser.resolver2.model.ScopeChild> children =
786                new ArrayList<gudusoft.gsqlparser.resolver2.model.ScopeChild>(scope.getChildren());
787        IScope from = fromScopeOf(scope);
788        if (from != null) {
789            children.addAll(from.getChildren());
790        }
791        return children;
792    }
793
794    /** The FROM scope a SELECT, UPDATE, DELETE or MERGE scope keeps its relations in, or null. */
795    private static IScope fromScopeOf(IScope scope) {
796        if (scope instanceof gudusoft.gsqlparser.resolver2.scope.SelectScope) {
797            return ((gudusoft.gsqlparser.resolver2.scope.SelectScope) scope).getFromScope();
798        }
799        if (scope instanceof gudusoft.gsqlparser.resolver2.scope.UpdateScope) {
800            return ((gudusoft.gsqlparser.resolver2.scope.UpdateScope) scope).getFromScope();
801        }
802        if (scope instanceof gudusoft.gsqlparser.resolver2.scope.DeleteScope) {
803            return ((gudusoft.gsqlparser.resolver2.scope.DeleteScope) scope).getFromScope();
804        }
805        if (scope instanceof gudusoft.gsqlparser.resolver2.scope.MergeScope) {
806            return ((gudusoft.gsqlparser.resolver2.scope.MergeScope) scope).getFromScope();
807        }
808        return null;
809    }
810
811    /**
812     * Check if the current vendor supports struct-field hint annotations.
813     * Currently BigQuery only (Snowflake uses schema.table.column for 3-part names).
814     */
815    private boolean isStructFieldHintVendor() {
816        if (config == null) {
817            return false;
818        }
819        return config.getVendor() == EDbVendor.dbvbigquery;
820    }
821
822    /**
823     * Delta 4: Try to set a StructFieldHint for 3+ part no-alias struct access.
824     *
825     * For "customer.address.city" (3 parts, no alias):
826     * - Treat the first part ("customer") as a potential base column
827     * - If found in any visible namespace, set a hint with fieldPath=["address", "city"]
828     * - Does NOT change ResolutionResult or sourceTable
829     *
830     * @param objName The TObjectName to annotate with a hint
831     * @param nameParts The extracted name parts (e.g., ["customer", "address", "city"])
832     * @param scope The scope to search in
833     */
834    private ResolutionResult trySetStructFieldHint(TObjectName objName, List<String> nameParts, IScope scope) {
835        // A qualified name is not a field path: in ds.t.c FROM ds.t the head is not a column and t is the table
836        // (STRUCT tree plan rule R4). The head would be found as an inferred column of any table without DDL.
837        if (qualifierNamesVisibleRelation(nameParts.get(0), scope)
838                || (namesVisibleTable(nameParts.subList(1, nameParts.size() - 1), scope)
839                        && !declaresColumn(nameParts.get(0), scope))) {
840            return null;
841        }
842        String baseColumnName = nameParts.get(0);
843        List<String> fieldPathSegments = nameParts.subList(1, nameParts.size());
844
845        // Try to find the base column as an unqualified name
846        List<String> singlePartName = Collections.singletonList(baseColumnName);
847        ResolvedImpl resolved = new ResolvedImpl();
848        scope.resolve(singlePartName, nameMatcher, false, resolved);
849
850        if (resolved.isEmpty()) {
851            return null; // Base column not found in any namespace
852        }
853
854        // Several relations that could provide the base column leave its owner undecided, as for two parts
855        // (tryStructFieldFallback): the finding is that ambiguity, with the field read below each candidate.
856        List<INamespace> providers = new ArrayList<INamespace>();
857        List<ColumnSource> fieldReads = new ArrayList<ColumnSource>();
858        for (ResolvedImpl.Match match : resolved.getMatches()) {
859            if (providers.contains(match.namespace)) {
860                continue;
861            }
862            ColumnSource base = match.namespace.resolveColumn(baseColumnName);
863            if (base != null) {
864                providers.add(match.namespace);
865                fieldReads.add(base);
866            }
867        }
868        if (fieldReads.size() > 1 && mergedUsingSource(resolved, baseColumnName, fieldReads) == null) {
869            sortCandidatesByTablePosition(fieldReads);
870            List<ColumnSource> candidates = new ArrayList<ColumnSource>(fieldReads.size());
871            for (ColumnSource base : fieldReads) {
872                candidates.add(base.withFieldPath(FieldPath.of(fieldPathSegments), "struct_field_access"));
873            }
874            return ResolutionResult.ambiguous(new AmbiguousColumnSource(baseColumnName, candidates));
875        }
876
877        // Check if any namespace actually has this column
878        for (ResolvedImpl.Match match : resolved.getMatches()) {
879            gudusoft.gsqlparser.resolver2.model.ColumnSource baseColumnSource =
880                match.namespace.resolveColumn(baseColumnName);
881            if (baseColumnSource != null) {
882                // Found the base column - create hint
883                gudusoft.gsqlparser.resolver2.model.FieldPath fieldPath =
884                    gudusoft.gsqlparser.resolver2.model.FieldPath.of(fieldPathSegments);
885                gudusoft.gsqlparser.resolver2.model.StructFieldHint hint =
886                    new gudusoft.gsqlparser.resolver2.model.StructFieldHint(
887                        baseColumnName, fieldPath,
888                        "struct_field_hint_no_alias", 0.7);
889                objName.setStructFieldHint(hint);
890
891                if (DEBUG_RESOLUTION) {
892                    System.out.println("[DEBUG-RESOLVE]   StructFieldHint set for: " + objName +
893                        " -> base=" + baseColumnName + ", fieldPath=" + fieldPathSegments);
894                }
895                return null;
896            }
897        }
898        return null;
899    }
900
901    /**
902     * Delta 3: Try to resolve a qualified name as struct-field access (column.field).
903     *
904     * In BigQuery/Snowflake, "customer.customer_id" might be:
905     * 1. Table "customer" with column "customer_id" (standard interpretation)
906     * 2. Column "customer" (STRUCT type) with field "customer_id" (struct-field access)
907     *
908     * If the standard interpretation failed, try the struct-field interpretation:
909     * - Treat the first part as an unqualified column name
910     * - If found, return the base column as the source (the STRUCT column)
911     * - Preserve the field path (segments beyond the base column) for downstream use
912     *
913     * @param objName The original TObjectName
914     * @param nameParts The extracted name parts (e.g., ["customer", "customer_id"])
915     * @param scope The scope to search in
916     * @return Resolution result if struct-field interpretation succeeds, null otherwise
917     */
918    private ResolutionResult tryStructFieldFallback(TObjectName objName, List<String> nameParts, IScope scope) {
919        // The base column is the first part (e.g., "customer" in "customer.customer_id")
920        String baseColumnName = nameParts.get(0);
921
922        // The field path is everything after the base column
923        // For "customer.customer_id", fieldPath = ["customer_id"]
924        // For "customer.address.city", fieldPath = ["address", "city"]
925        List<String> fieldPathSegments = nameParts.size() > 1
926            ? nameParts.subList(1, nameParts.size())
927            : Collections.emptyList();
928
929        // Try to resolve the base column as an unqualified name
930        List<String> singlePartName = Collections.singletonList(baseColumnName);
931        ResolvedImpl resolved = new ResolvedImpl();
932        scope.resolve(singlePartName, nameMatcher, false, resolved);
933
934        if (resolved.isEmpty()) {
935            return null; // Base column not found
936        }
937
938        // EVERY namespace that could provide the base column, not the first one found.
939        //
940        // `SELECT customer.name FROM orders, items` settles the ROLE — `customer` is not a relation
941        // in scope, so it is a column and `name` is a field inside it — but it does not settle the
942        // OWNER: `customer` could belong to either relation and nothing in the statement says
943        // which. Returning on the first match answered `orders` and reported EXACT_MATCH, which is
944        // a guess wearing a definite answer's clothes: nothing downstream could tell it apart from
945        // a binding the statement actually proves.
946        //
947        // Guessing is not forbidden here, it is OPT-IN, and this is not where it is opted into:
948        // dlineage's Option.setLinkOrphanColumnToFirstTable(boolean) defaults to false and, when
949        // switched on, links the column to the first relation AND records the others as candidate
950        // parents AND emits a LinkOrphanColumn diagnostic. A guess made here instead would be
951        // silent and unconditional, which is why the resolver must decline.
952        //
953        // So: exactly one provider is a resolution; several is an ambiguity this layer must not
954        // resolve, and the caller then leaves the reference unresolved, which is what every
955        // non-struct dialect already does for the same shape.
956        List<INamespace> providerNamespaces = new ArrayList<INamespace>();
957        List<ColumnSource> providedBases = new ArrayList<ColumnSource>();
958        for (ResolvedImpl.Match match : resolved.getMatches()) {
959            INamespace namespace = match.namespace;
960            // One namespace can appear under several matches (a relation reachable by name and by
961            // alias, say). Counting those as separate owners would refuse a perfectly decided
962            // single-relation query, so identity decides, not match count.
963            boolean already = false;
964            for (INamespace seen : providerNamespaces) {
965                if (seen == namespace) {
966                    already = true;
967                    break;
968                }
969            }
970            if (already) {
971                continue;
972            }
973
974            // Try to resolve the base column name in this namespace
975            ColumnSource baseColumnSource = namespace.resolveColumn(baseColumnName);
976            if (baseColumnSource == null) {
977                continue;
978            }
979            providerNamespaces.add(namespace);
980            providedBases.add(baseColumnSource);
981        }
982
983        ColumnSource providedBase = null;
984        if (providedBases.size() == 1) {
985            providedBase = providedBases.get(0);
986        } else if (providedBases.size() > 1) {
987            // Several relations could own the base. A JOIN USING merge makes that one column after
988            // all; otherwise the owner is genuinely undecided.
989            providedBase = mergedUsingSource(resolved, baseColumnName, providedBases);
990            if (providedBase == null) {
991                if (DEBUG_RESOLUTION) {
992                    System.out.println("[DEBUG-RESOLVE]   Struct-field: base column '"
993                        + baseColumnName + "' could come from " + providedBases.size()
994                        + " relations — declining to guess an owner");
995                }
996                // AMBIGUOUS, not null: the owner being undecided is a FINDING, and a later stage
997                // that sees NOT_FOUND cannot tell it apart from a name nothing provides.
998                sortCandidatesByTablePosition(providedBases);
999                // Each candidate is the field read below its base, so the finding says what was read, not
1000                // only whose column it might be.
1001                List<ColumnSource> fieldReads = new ArrayList<ColumnSource>(providedBases.size());
1002                for (ColumnSource base : providedBases) {
1003                    fieldReads.add(base.withFieldPath(FieldPath.of(fieldPathSegments), "struct_field_access"));
1004                }
1005                return ResolutionResult.ambiguous(
1006                        new AmbiguousColumnSource(baseColumnName, fieldReads));
1007            }
1008        }
1009
1010        if (providedBase != null) {
1011            // Found the base column in exactly one relation - return it with the field path
1012            if (DEBUG_RESOLUTION) {
1013                System.out.println("[DEBUG-RESOLVE]   Struct-field: found base column '" +
1014                    baseColumnName + "', fieldPath=" + fieldPathSegments);
1015            }
1016
1017            // Create a new ColumnSource with:
1018            // 1. struct_field_access evidence marker (for backward compatibility)
1019            // 2. fieldPath preserved (new in Improvement B)
1020            FieldPath fieldPath = FieldPath.of(fieldPathSegments);
1021            ColumnSource structFieldSource = providedBase.withFieldPath(fieldPath, "struct_field_access");
1022
1023            return ResolutionResult.exactMatch(structFieldSource);
1024        }
1025
1026        return null;
1027    }
1028
1029    /**
1030     * Extract name parts from TObjectName.
1031     * Examples:
1032     * - "col" -> ["col"]
1033     * - "t.col" -> ["t", "col"]
1034     * - "schema.table.col" -> ["schema", "table", "col"]
1035     *
1036     * For BigQuery/Snowflake struct field access like "customer.customer_id":
1037     * - If schema/table tokens are not set, but toString() contains dots,
1038     *   extract all parts from toString() to capture field paths.
1039     */
1040    private List<String> extractNameParts(TObjectName objName) {
1041        List<String> parts = new ArrayList<>();
1042
1043        // Add schema if present, but NOT when databaseToken is also set.
1044        // When databaseToken is present, the name is fully qualified (db.schema.table.column)
1045        // and the schema position IS the schema, not a table alias. Including it would cause
1046        // the resolver to incorrectly match the schema against table aliases. (Mantis #4268)
1047        if (objName.getSchemaToken() != null && objName.getDatabaseToken() == null) {
1048            parts.add(objName.getSchemaString());
1049        }
1050
1051        // Add table/qualifier if present
1052        if (objName.getTableToken() != null) {
1053            parts.add(objName.getTableString());
1054        }
1055
1056        // Add column name
1057        String columnName = objName.getColumnNameOnly();
1058        if (columnName != null) {
1059            parts.add(columnName);
1060        }
1061
1062        // Improvement B: Handle BigQuery/Snowflake struct field access
1063        // If we only got a single part from standard extraction,
1064        // but toString() contains more segments (dots), extract them
1065        // This handles cases like "customer.customer_id" where:
1066        // - getColumnNameOnly() returns "customer"
1067        // - toString() returns "customer.customer_id"
1068        if (isStructFieldVendor()) {
1069            String fullName = objName.toString();
1070            if (fullName != null && fullName.contains(".")) {
1071                // Check if fullName has more segments than what we extracted
1072                String[] fullParts = splitNameParts(fullName);
1073                if (fullParts.length > parts.size() || hasConsecutiveDuplicates(parts)) {
1074                    // Replace parts with full extracted segments
1075                    parts.clear();
1076                    Collections.addAll(parts, fullParts);
1077                }
1078            }
1079        }
1080
1081        return parts;
1082    }
1083
1084    /**
1085     * Check if a list has consecutive duplicate elements.
1086     * This detects parser bugs where segments are duplicated.
1087     * Example: ["customer", "customer", "address"] returns true.
1088     */
1089    private boolean hasConsecutiveDuplicates(List<String> list) {
1090        if (list == null || list.size() < 2) {
1091            return false;
1092        }
1093        for (int i = 1; i < list.size(); i++) {
1094            if (list.get(i) != null && list.get(i).equals(list.get(i - 1))) {
1095                return true;
1096            }
1097        }
1098        return false;
1099    }
1100
1101
1102    /**
1103     * Split a dotted name into parts, handling quoted identifiers.
1104     * Examples:
1105     * - "a.b.c" -> ["a", "b", "c"]
1106     * - "`a.b`.c" -> ["`a.b`", "c"]
1107     * - "a.`b.c`" -> ["a", "`b.c`"]
1108     *
1109     * @param name The dotted name string
1110     * @return Array of name parts
1111     */
1112    private String[] splitNameParts(String name) {
1113        if (name == null || name.isEmpty()) {
1114            return new String[0];
1115        }
1116
1117        // Simple case: no quotes, just split on dots
1118        if (!name.contains("`") && !name.contains("\"") && !name.contains("[")) {
1119            return name.split("\\.");
1120        }
1121
1122        // Complex case: handle quoted identifiers
1123        List<String> parts = new ArrayList<>();
1124        StringBuilder current = new StringBuilder();
1125        char quoteChar = 0;
1126
1127        for (int i = 0; i < name.length(); i++) {
1128            char c = name.charAt(i);
1129
1130            if (quoteChar != 0) {
1131                // Inside a quoted identifier
1132                current.append(c);
1133                if (c == quoteChar) {
1134                    // Check for escaped quote (doubled)
1135                    if (i + 1 < name.length() && name.charAt(i + 1) == quoteChar) {
1136                        current.append(name.charAt(++i));
1137                    } else {
1138                        // End of quoted identifier
1139                        quoteChar = 0;
1140                    }
1141                }
1142            } else if (c == '`' || c == '"' || c == '[') {
1143                // Start of quoted identifier
1144                quoteChar = (c == '[') ? ']' : c;
1145                current.append(c);
1146            } else if (c == '.') {
1147                // Separator
1148                if (current.length() > 0) {
1149                    parts.add(current.toString());
1150                    current.setLength(0);
1151                }
1152            } else {
1153                current.append(c);
1154            }
1155        }
1156
1157        // Add last part
1158        if (current.length() > 0) {
1159            parts.add(current.toString());
1160        }
1161
1162        return parts.toArray(new String[0]);
1163    }
1164
1165    /**
1166     * Process resolution matches and determine final result.
1167     */
1168    /**
1169     * A wildcard over a CTE expands to the CTE's projection, so it binds to the CTE itself.
1170     * A CTE whose projection has no star holds no column that could stand for the wildcard
1171     * (the CTE used to invent one from its base table). Whether the reference IS the wildcard
1172     * comes from the parse, the asterisk part token, never from its text: {@code x."*"} is a
1173     * column.
1174     */
1175    private static ColumnSource wildcardOverCte(TObjectName objName, INamespace namespace) {
1176        if (!(namespace instanceof CTENamespace) || objName == null || objName.getPartToken() == null
1177                || objName.getPartToken().tokentype != ETokenType.ttasterisk) {
1178            return null;
1179        }
1180        return new ColumnSource(namespace, objName.getColumnNameOnly(), null, 1.0, "cte_star_expansion");
1181    }
1182
1183    /** An unqualified wildcard whose FROM clause is a single CTE: see {@link #wildcardOverCte}. */
1184    private static ColumnSource wildcardOverSoleCte(TObjectName objName, List<String> nameParts, IScope scope) {
1185        if (nameParts.size() != 1 || !(scope instanceof SelectScope)) {
1186            return null;
1187        }
1188        FromScope from = ((SelectScope) scope).getFromScope();
1189        if (from == null || from.getChildren().size() != 1) {
1190            return null;
1191        }
1192        return wildcardOverCte(objName, from.getChildren().get(0).getNamespace());
1193    }
1194
1195    private ResolutionResult processResolvedMatches(TObjectName objName,
1196                                                    List<String> nameParts,
1197                                                    ResolvedImpl resolved,
1198                                                    IScope scope) {
1199        String columnName = nameParts.get(nameParts.size() - 1);
1200
1201        if (resolved.isEmpty()) {
1202            ColumnSource star = wildcardOverSoleCte(objName, nameParts, scope);
1203            if (star != null) {
1204                return ResolutionResult.exactMatch(star);
1205            }
1206            // No matches found
1207            return ResolutionResult.notFound(columnName);
1208        }
1209
1210        // Deduplicate matches by namespace identity
1211        // The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope)
1212        // but it's still the same source - not truly ambiguous
1213        List<ResolvedImpl.Match> uniqueMatches = deduplicateMatchesByNamespace(resolved.getMatches());
1214
1215        if (uniqueMatches.size() == 1) {
1216            // Exactly one unique namespace - success!
1217            ResolvedImpl.Match match = uniqueMatches.get(0);
1218            INamespace namespace = match.namespace;
1219
1220            // For qualified names like "t.col", we need to resolve the column
1221            // For unqualified names like "col", we need to find it in the namespace
1222            ColumnSource columnSource;
1223
1224            if (!match.remainingNames.isEmpty()) {
1225                // Still have parts to resolve (e.g., found table, need to find column)
1226                // Phase B3: Support multi-segment paths (table.column.field...)
1227                if (match.remainingNames.size() > 1 && readsColumnPaths()) {
1228                    // Multiple segments: use resolveColumnPath for deep field access
1229                    // e.g., remainingNames = ["customer", "address", "city"]
1230                    // -> base column "customer", fieldPath ["address", "city"]
1231                    columnSource = namespace.resolveColumnPath(match.remainingNames);
1232
1233                    if (columnSource == null) {
1234                        // Base column not found
1235                        String baseColName = match.remainingNames.get(0);
1236                        return ResolutionResult.notFound(baseColName,
1237                            "Column '" + baseColName + "' not found in " + namespace.getDisplayName());
1238                    }
1239
1240                    if (DEBUG_RESOLUTION) {
1241                        System.out.println("[DEBUG-RESOLVE]   Deep path resolved: " +
1242                            match.remainingNames + " -> base=" + columnSource.getExposedName() +
1243                            ", fieldPath=" + (columnSource.hasFieldPath() ? columnSource.getFieldPath() : "none"));
1244                    }
1245                } else {
1246                    // Single segment: regular column resolution
1247                    String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1);
1248                    columnSource = namespace.resolveColumn(remainingColName);
1249                    if (columnSource == null) {
1250                        columnSource = wildcardOverCte(objName, namespace);
1251                    }
1252
1253                    if (columnSource == null) {
1254                        // Column not found in the namespace
1255                        return ResolutionResult.notFound(remainingColName,
1256                            "Column '" + remainingColName + "' not found in " + namespace.getDisplayName());
1257                    }
1258                }
1259            } else {
1260                // Name resolved to a table/namespace directly with no remaining parts
1261                // This happens when the column name matches the table alias exactly.
1262                //
1263                // For UNNEST tables (e.g., "UNNEST(arr) AS x"), the alias "x" is ALSO
1264                // the implicit column name. When user writes "x" as a column reference,
1265                // the scope resolution matches the table alias "x", leaving remainingNames empty.
1266                // We should still try to resolve "x" as a column in the namespace.
1267                //
1268                // Example: SELECT x FROM UNNEST([1,2,3]) AS x
1269                // Here "x" in SELECT refers to the implicit column, not the table.
1270                // A call whose layout names its column after the alias (PostgreSQL: FROM unnest(a) AS u, FROM
1271                // generate_series(1, 9) g, or the function's own name without an alias) is the same case
1272                // (TableFunctionLayout, plan 09 slice 11).
1273                if (namespace instanceof UnnestNamespace || (namespace instanceof TableNamespace
1274                        && ((TableNamespace) namespace).getTable() != null
1275                        && TableFunctionLayout.of(((TableNamespace) namespace).getTable()) != null
1276                        && TableFunctionLayout.of(((TableNamespace) namespace).getTable()).getNames() != null)) {
1277                    // For UNNEST, try to resolve the matched name as a column
1278                    columnSource = namespace.resolveColumn(columnName);
1279                    if (columnSource != null) {
1280                        if (DEBUG_RESOLUTION) {
1281                            System.out.println("[DEBUG-RESOLVE]   UNNEST implicit column resolved: " +
1282                                columnName + " in " + namespace.getDisplayName());
1283                        }
1284                        if (namespace instanceof TableNamespace) {
1285                            // the alias matched the call first, so the other FROM items were never asked: a
1286                            // declared column of the same name competes (PostgreSQL: CREATE TABLE t(g INT);
1287                            // SELECT g FROM t, generate_series(1, 9) g is ambiguous, as with AS s(g))
1288                            List<ColumnSource> competing = declaredColumnsOfOtherItems(scope, namespace, columnName);
1289                            if (!competing.isEmpty()) {
1290                                competing.add(columnSource);
1291                                sortCandidatesByTablePosition(competing);
1292                                return ResolutionResult.ambiguous(new AmbiguousColumnSource(columnName, competing));
1293                            }
1294                        }
1295                        return ResolutionResult.exactMatch(columnSource);
1296                    }
1297                }
1298
1299                // For other namespaces or if column not found, this is an error
1300                return ResolutionResult.notFound(columnName,
1301                    "Name '" + columnName + "' resolves to a table, not a column");
1302            }
1303
1304            if (isInferredCandidate(columnSource)) {
1305                List<UnnestNamespace> valueTables = unknownValueTablesIn(scope, objName);
1306                valueTables.remove(namespace);
1307                if (!valueTables.isEmpty()) {
1308                    // SELECT item_name FROM orders_raw, UNNEST(items) without DDL: item_name may be a column of
1309                    // orders_raw or a field of the unnested element, and nothing proves which; the read stays
1310                    // ambiguous instead of binding to the table (Mantis 4828, STRUCT tree plan rule R3). The value
1311                    // table answers hasColumn() with NOT_EXISTS, so it is not among the matches.
1312                    List<ColumnSource> all = new ArrayList<ColumnSource>();
1313                    all.add(columnSource);
1314                    addValueTableCandidates(all, valueTables, columnName);
1315                    sortCandidatesByTablePosition(all);
1316                    return ResolutionResult.ambiguous(new AmbiguousColumnSource(columnName, all));
1317                }
1318            }
1319            return ResolutionResult.exactMatch(columnSource);
1320        }
1321
1322        // Multiple unique namespaces - truly ambiguous
1323        List<ColumnSource> candidates = new ArrayList<>();
1324        // Mantis 4550: set when we synthesize a placeholder candidate for an
1325        // ambiguous-star subquery (see below). Such a candidate means the column
1326        // is genuinely ambiguous across derived tables and must NOT be collapsed
1327        // by GUESS_COLUMN_STRATEGY into a single guessed match.
1328        boolean synthesizedAmbStar = false;
1329        // the unaliased UNNEST value tables whose element type is unknown (Mantis 4828)
1330        List<UnnestNamespace> unknownValueTables = new ArrayList<UnnestNamespace>();
1331
1332        for (ResolvedImpl.Match match : uniqueMatches) {
1333            INamespace namespace = match.namespace;
1334
1335            // Resolve column in this namespace
1336            // Phase B3: Support multi-segment paths
1337            ColumnSource columnSource;
1338            if (!match.remainingNames.isEmpty()) {
1339                if (match.remainingNames.size() > 1 && readsColumnPaths()) {
1340                    // Multi-segment: use resolveColumnPath
1341                    columnSource = namespace.resolveColumnPath(match.remainingNames);
1342                } else {
1343                    // Single segment: use resolveColumn
1344                    String remainingColName = match.remainingNames.get(match.remainingNames.size() - 1);
1345                    columnSource = namespace.resolveColumn(remainingColName);
1346                }
1347            } else {
1348                columnSource = namespace.resolveColumn(columnName);
1349            }
1350
1351            // Mantis 4550: an ambiguous-star subquery (SELECT * over a multi-table
1352            // FROM) cannot pin an unqualified column to a single base table and,
1353            // under the default NOT_PICKUP strategy, resolveColumn() returns null.
1354            // SelectScope now still reports it as an outer-scope candidate. Anchor a
1355            // placeholder ColumnSource to the subquery so it counts as a distinct
1356            // candidate (getSourceNamespace().getSourceTable() -> the derived table).
1357            // finalTable is left null; candidateTables reconciliation expands the
1358            // derived table into its base tables. The placeholder is NOT cached as an
1359            // inferred column on the namespace, so resolveColumn() itself is unchanged.
1360            // Only single-segment column references qualify (no deep struct path);
1361            // qualified single-table refs never reach this multi-namespace branch
1362            // (they resolve via the uniqueMatches.size()==1 path above), so this
1363            // cannot make "T0_2.col" falsely resolve.
1364            boolean singleSegment = match.remainingNames.isEmpty()
1365                    || match.remainingNames.size() == 1;
1366            if (columnSource == null && singleSegment && namespace instanceof UnnestNamespace
1367                    && ((UnnestNamespace) namespace).mayHoldUnknownFields()
1368                    && !insideUnnestArgument(objName, (UnnestNamespace) namespace)) {
1369                unknownValueTables.add((UnnestNamespace) namespace);
1370            }
1371            if (columnSource == null
1372                    && singleSegment
1373                    && namespace instanceof gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace
1374                    && ((gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace) namespace).hasAmbiguousStar()) {
1375                String placeholderName = match.remainingNames.isEmpty()
1376                        ? columnName
1377                        : match.remainingNames.get(match.remainingNames.size() - 1);
1378                columnSource = new ColumnSource(namespace, placeholderName, null, 0.3,
1379                        "ambiguous_star_outer_candidate");
1380                synthesizedAmbStar = true;
1381            }
1382
1383            if (columnSource != null) {
1384                candidates.add(columnSource);
1385            }
1386        }
1387
1388        if (candidates.isEmpty()) {
1389            // Resolved to tables, but none have the column
1390            return ResolutionResult.notFound(columnName,
1391                "Column '" + columnName + "' not found in any of " + resolved.getCount() + " tables");
1392        }
1393
1394        if (candidates.size() == 1 && isInferredCandidate(candidates.get(0))) {
1395            // a value table answers hasColumn() with NOT_EXISTS, so it is not among the matches: look at the FROM
1396            // scope for every one of them
1397            for (UnnestNamespace valueTable : unknownValueTablesIn(scope, objName)) {
1398                if (!unknownValueTables.contains(valueTable)) {
1399                    unknownValueTables.add(valueTable);
1400                }
1401            }
1402        }
1403        if (candidates.size() == 1 && !unknownValueTables.isEmpty() && isInferredCandidate(candidates.get(0))) {
1404            // SELECT item_name FROM orders_raw, UNNEST(items) without DDL: item_name may be a column of orders_raw
1405            // or a field of the unnested element, and nothing proves which; the read stays ambiguous instead of
1406            // binding to the table (Mantis 4828, STRUCT tree plan rule R3). With DDL the table's authority decides,
1407            // and the unnest namespace's declared fields answer first.
1408            addValueTableCandidates(candidates, unknownValueTables, columnName);
1409            sortCandidatesByTablePosition(candidates);
1410            return ResolutionResult.ambiguous(new AmbiguousColumnSource(columnName, candidates));
1411        }
1412
1413        if (candidates.size() == 1) {
1414            // Only one table actually has the column
1415            return ResolutionResult.exactMatch(candidates.get(0));
1416        }
1417
1418        // Mantis 4550: when a placeholder ambiguous-star candidate was synthesized,
1419        // the column is genuinely ambiguous across the derived tables. Force an
1420        // AMBIGUOUS result regardless of GUESS_COLUMN_STRATEGY (nearest/farthest),
1421        // which would otherwise collapse a definite sibling + maybe-star sibling
1422        // back into a single EXACT match.
1423        if (synthesizedAmbStar) {
1424            sortCandidatesByTablePosition(candidates);
1425            AmbiguousColumnSource ambiguousResult = new AmbiguousColumnSource(columnName, candidates);
1426            return ResolutionResult.ambiguous(ambiguousResult);
1427        }
1428
1429        // Sort candidates by their table's position in the SQL text (FROM clause order)
1430        // This ensures consistent ordering for both ambiguous results and GUESS_COLUMN_STRATEGY
1431        sortCandidatesByTablePosition(candidates);
1432
1433        // A JOIN USING column is ONE column, not one per side.
1434        ColumnSource merged = mergedUsingSource(resolved, columnName, candidates);
1435        if (merged != null) {
1436            return ResolutionResult.exactMatch(merged);
1437        }
1438
1439        // Check if all candidates are "inferred" (from tables without DDL metadata)
1440        // If so, return AMBIGUOUS regardless of GUESS_COLUMN_STRATEGY
1441        // Only apply GUESS_COLUMN_STRATEGY when we have definite knowledge about the columns
1442        //
1443        // The determination uses configurable thresholds:
1444        // - minDefiniteConfidence: minimum confidence to be considered "definite" (default 0.9)
1445        // - allowGuessWhenAllInferred: if true, allow guessing even when all candidates are inferred
1446        boolean hasDefiniteCandidate = false;
1447        double highestConfidence = 0.0;
1448        double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9;
1449
1450        for (ColumnSource candidate : candidates) {
1451            // A candidate is "definite" if it has high confidence and is not just inferred from usage
1452            String evidence = candidate.getEvidence();
1453            double confidence = candidate.getConfidence();
1454
1455            // Track highest confidence among candidates
1456            if (confidence > highestConfidence) {
1457                highestConfidence = confidence;
1458            }
1459
1460            // Use structured evidence if available, otherwise check legacy evidence
1461            boolean isInferred;
1462            if (candidate.getEvidenceDetail() != null) {
1463                // Use the structured evidence's own determination
1464                isInferred = !candidate.getEvidenceDetail().isHighConfidence() ||
1465                             candidate.getEvidenceDetail().isInferred();
1466            } else {
1467                // Fallback to legacy logic
1468                // "inferred_from_usage" means table without DDL metadata - not definite
1469                // Low confidence (< minDefiniteConf) means we're guessing - not definite
1470                // Null evidence with high confidence is also considered inferred
1471                isInferred = (evidence == null || evidence.equals("inferred_from_usage")) ||
1472                             confidence < minDefiniteConf;
1473            }
1474
1475            if (!isInferred) {
1476                hasDefiniteCandidate = true;
1477                break;
1478            }
1479        }
1480
1481        // Check if we should allow guessing when all candidates are inferred
1482        boolean allowGuessInferred = config != null && config.isAllowGuessWhenAllInferred();
1483
1484        if (!hasDefiniteCandidate && !allowGuessInferred) {
1485            // All candidates are from tables without DDL metadata (all inferred)
1486            // Don't guess - return as ambiguous so formatter can handle appropriately
1487            if (DEBUG_RESOLUTION) {
1488                System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (all inferred): " + columnName + " with " + candidates.size() + " candidates");
1489                for (ColumnSource c : candidates) {
1490                    System.out.println("[DEBUG-RESOLVE]     - " + (c.getSourceNamespace() != null ? c.getSourceNamespace().getDisplayName() : "null") +
1491                        " evidence=" + c.getEvidence() + " confidence=" + c.getConfidence());
1492                }
1493            }
1494            AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
1495            return ResolutionResult.ambiguous(ambiguous);
1496        }
1497
1498        // Additional check: even if we have definite candidates or allow inferred guessing,
1499        // require at least one candidate to meet the minConfidenceToGuess threshold
1500        double minConfToGuess = config != null ? config.getMinConfidenceToGuess() : 0.95;
1501        if (highestConfidence < minConfToGuess && !allowGuessInferred) {
1502            // No candidate has sufficient confidence for guessing
1503            if (DEBUG_RESOLUTION) {
1504                System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (confidence too low): " + columnName +
1505                    " highest=" + highestConfidence + " required=" + minConfToGuess);
1506            }
1507            AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
1508            return ResolutionResult.ambiguous(ambiguous);
1509        }
1510
1511        // Multiple tables have the column - apply GUESS_COLUMN_STRATEGY
1512        // Candidates are already sorted by table position (done earlier)
1513        int strategy = getGuessColumnStrategy();
1514
1515        if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_NEAREST) {
1516            // Pick the first candidate (nearest table in FROM clause order)
1517            return ResolutionResult.exactMatch(candidates.get(0));
1518        } else if (strategy == TSQLResolverConfig.GUESS_COLUMN_STRATEGY_FARTHEST) {
1519            // Pick the last candidate (farthest table in FROM clause order)
1520            return ResolutionResult.exactMatch(candidates.get(candidates.size() - 1));
1521        }
1522
1523        // GUESS_COLUMN_STRATEGY_NOT_PICKUP: leave as ambiguous
1524        if (DEBUG_RESOLUTION) {
1525            System.out.println("[DEBUG-RESOLVE]   AMBIGUOUS (NOT_PICKUP strategy): " + columnName + " with " + candidates.size() + " candidates");
1526        }
1527        AmbiguousColumnSource ambiguous = new AmbiguousColumnSource(columnName, candidates);
1528        return ResolutionResult.ambiguous(ambiguous);
1529    }
1530
1531    /**
1532     * Deduplicate matches by namespace identity.
1533     * The same namespace can be found through different scope paths (e.g., CTE scope and SELECT scope)
1534     * but it's still the same source - not truly ambiguous.
1535     *
1536     * @param matches All matches from scope resolution
1537     * @return List of unique matches by namespace identity
1538     */
1539    private List<ResolvedImpl.Match> deduplicateMatchesByNamespace(List<ResolvedImpl.Match> matches) {
1540        if (matches == null || matches.size() <= 1) {
1541            return matches;
1542        }
1543
1544        // Use identity-based set to track unique namespaces
1545        java.util.IdentityHashMap<INamespace, ResolvedImpl.Match> uniqueByNamespace =
1546            new java.util.IdentityHashMap<>();
1547
1548        for (ResolvedImpl.Match match : matches) {
1549            if (match.namespace != null && !uniqueByNamespace.containsKey(match.namespace)) {
1550                uniqueByNamespace.put(match.namespace, match);
1551            }
1552        }
1553
1554        return new ArrayList<>(uniqueByNamespace.values());
1555    }
1556
1557    /**
1558     * Sort candidates by their source table's position in the SQL text.
1559     * This ensures that when GUESS_COLUMN_STRATEGY_NEAREST or FARTHEST is applied,
1560     * the candidates are ordered according to their actual position in the FROM clause.
1561     *
1562     * Uses the table's start token (lineNo, columnNo) to determine position.
1563     *
1564     * @param candidates List of ColumnSource candidates to sort in place
1565     */
1566    private void sortCandidatesByTablePosition(List<ColumnSource> candidates) {
1567        if (candidates == null || candidates.size() <= 1) {
1568            return;
1569        }
1570
1571        candidates.sort(new Comparator<ColumnSource>() {
1572            @Override
1573            public int compare(ColumnSource c1, ColumnSource c2) {
1574                TTable t1 = c1.getFinalTable();
1575                TTable t2 = c2.getFinalTable();
1576
1577                // If either table is null, maintain relative order
1578                if (t1 == null && t2 == null) return 0;
1579                if (t1 == null) return 1;  // null tables go to the end
1580                if (t2 == null) return -1;
1581
1582                TSourceToken token1 = t1.getStartToken();
1583                TSourceToken token2 = t2.getStartToken();
1584
1585                // If either token is null, maintain relative order
1586                if (token1 == null && token2 == null) return 0;
1587                if (token1 == null) return 1;
1588                if (token2 == null) return -1;
1589
1590                // Compare by line number first
1591                int lineCmp = Long.compare(token1.lineNo, token2.lineNo);
1592                if (lineCmp != 0) {
1593                    return lineCmp;
1594                }
1595
1596                // If same line, compare by column number
1597                return Long.compare(token1.columnNo, token2.columnNo);
1598            }
1599        });
1600    }
1601
1602    /**
1603     * Update TObjectName with the resolution result.
1604     * Also registers with ResolutionContext.
1605     *
1606     * For ambiguous columns (multiple candidate tables), this also populates
1607     * TObjectName.candidateTables with all possible source tables.
1608     *
1609     * IMPORTANT: When resolution fails (notFound) and the column's sourceTable
1610     * was set during Phase 1 to an UNNEST table, we clear the sourceTable.
1611     * This is because UNNEST tables have a fixed set of columns (implicit column,
1612     * offset, struct fields) and should NOT have arbitrary columns inferred.
1613     * Clearing sourceTable allows the formatter to treat these as "missed" columns.
1614     */
1615    private void updateObjectNameWithResult(TObjectName objName, ResolutionResult result) {
1616        // Update TObjectName with resolution result
1617        objName.setResolution(result);
1618
1619        // For notFound results, check if Phase 1 incorrectly linked to UNNEST table
1620        // UNNEST tables have a fixed column set - don't allow inferred columns
1621        if (!result.isExactMatch() && !result.isAmbiguous()) {
1622            TTable currentSourceTable = objName.getSourceTable();
1623            if (currentSourceTable != null &&
1624                currentSourceTable.getTableType() == ETableSource.unnest) {
1625                // Clear the incorrectly set sourceTable from Phase 1
1626                // This column wasn't found in the UNNEST namespace, so it shouldn't
1627                // be attributed to the UNNEST table
1628                objName.setSourceTable(null);
1629                if (DEBUG_RESOLUTION) {
1630                    System.out.println("[DEBUG-RESOLVE]   Cleared incorrect UNNEST sourceTable for: " +
1631                        objName + " (not found in UNNEST namespace)");
1632                }
1633            }
1634        }
1635
1636        // For ambiguous results, populate candidateTables with all candidate tables
1637        // IMPORTANT: Clear existing candidateTables first, as Phase 1 (linkColumnToTable)
1638        // may have added candidates from incorrect scopes (e.g., MERGE target table for
1639        // columns inside USING subquery). Phase 2 (NameResolver) has proper scope awareness
1640        // and produces the authoritative candidate list.
1641        if (result.isAmbiguous() && result.getAmbiguousSource() != null) {
1642            AmbiguousColumnSource ambiguous = result.getAmbiguousSource();
1643            // Clear Phase 1 candidates before adding Phase 2's scope-aware candidates
1644            objName.getCandidateTables().clear();
1645            for (ColumnSource candidate : ambiguous.getCandidates()) {
1646                gudusoft.gsqlparser.nodes.TTable candidateTable = candidate.getFinalTable();
1647                if (candidateTable != null) {
1648                    objName.getCandidateTables().addTable(candidateTable);
1649                    continue;
1650                }
1651                // Mantis 4550: an ambiguous-star subquery placeholder has no single
1652                // finalTable. Add its immediate derived table (the subquery) so the
1653                // reconcile pass below expands it into the physical base tables that
1654                // can actually supply the column (e.g. T0_2 -> A.A2, A.A3).
1655                gudusoft.gsqlparser.nodes.TTable derivedTable =
1656                    candidate.getSourceNamespace() != null
1657                        ? candidate.getSourceNamespace().getSourceTable() : null;
1658                if (derivedTable != null) {
1659                    objName.getCandidateTables().addTable(derivedTable);
1660                    continue;
1661                }
1662                // A candidate that cannot collapse to a single physical table
1663                // (e.g. a UNION-backed derived table) would otherwise be dropped
1664                // here. Trace its set-operation branches projection-aware, by the
1665                // requested column, so only branches that actually supply the
1666                // column contribute base tables (a constant branch like
1667                // "SELECT 1 AS a" adds nothing).
1668                addBranchBaseTablesForColumn(objName, candidate);
1669            }
1670        }
1671
1672        // Mantis 4550: reconcile candidateTables so they point to the physical
1673        // base tables that could actually supply the column, rather than the
1674        // intermediate derived tables. Phase 1 (linkColumnToTable) may leave
1675        // stale candidates that include derived tables which cannot supply the
1676        // column (e.g. a subquery with an explicit column list that omits it),
1677        // and the EXACT/guessed-match path above does not clear them. See
1678        // reconcileCandidateTablesToBaseTables() for the full rationale.
1679        reconcileCandidateTablesToBaseTables(objName);
1680
1681        // Register with context for global querying
1682        context.registerResolution(objName, result);
1683    }
1684
1685    /**
1686     * Mantis 4550: rewrite {@code candidateTables} so each entry is a physical
1687     * base table that could supply the column, expanding any intermediate
1688     * derived table (subquery / CTE) into the underlying tables it exposes.
1689     *
1690     * <p>Background: for an unqualified column whose source is ambiguous across
1691     * several derived tables in the FROM clause, Phase 1 ({@code linkColumnToTable})
1692     * may populate {@code candidateTables} with the derived tables themselves.
1693     * When Phase 2 resolves the column to a guessed EXACT match (rather than a
1694     * truly AMBIGUOUS result) the scope-aware ambiguous branch that normally
1695     * clears and rebuilds {@code candidateTables} is skipped, leaving those
1696     * stale derived-table candidates in place. Some of them cannot even supply
1697     * the column (e.g. a {@code SELECT x, ds FROM ...} subquery for column
1698     * {@code S311}).</p>
1699     *
1700     * <p>This pass walks each candidate: physical tables are kept as-is, while
1701     * a derived table is replaced by the set of physical base tables reachable
1702     * through its projection that could supply the column. A derived table whose
1703     * closed projection does not expose the column contributes nothing. Results
1704     * are de-duplicated by qualified table name, preserving first-seen order, so
1705     * the same physical table reached via two derived paths (or two aliases of a
1706     * self-join) collapses to a single candidate.</p>
1707     */
1708    private void reconcileCandidateTablesToBaseTables(TObjectName objName) {
1709        TTableList candidates = objName.getCandidateTables();
1710        if (candidates == null || candidates.size() == 0) {
1711            return;
1712        }
1713
1714        String columnName = objName.getColumnNameOnly();
1715        if (columnName == null || columnName.isEmpty()) {
1716            return;
1717        }
1718
1719        List<TTable> expanded = new ArrayList<TTable>();
1720        Set<String> seenKeys = new LinkedHashSet<String>();
1721        IdentityHashMap<TSelectSqlStatement, Boolean> visiting =
1722            new IdentityHashMap<TSelectSqlStatement, Boolean>();
1723        for (int i = 0; i < candidates.size(); i++) {
1724            collectBaseTablesSupplyingColumn(candidates.getTable(i), columnName,
1725                expanded, seenKeys, visiting);
1726        }
1727
1728        // Only replace when expansion produced concrete base tables; otherwise
1729        // keep the existing list rather than wiping resolution information.
1730        if (expanded.isEmpty()) {
1731            return;
1732        }
1733
1734        candidates.clear();
1735        for (TTable t : expanded) {
1736            candidates.addTable(t);
1737        }
1738    }
1739
1740    /**
1741     * Add, to {@code objName}'s candidate tables, the base tables that supply the
1742     * column for an ambiguous candidate whose {@link ColumnSource#getFinalTable()}
1743     * is {@code null} (typically a set-operation/UNION-backed namespace). The
1744     * namespace's SELECT statement is traced projection-aware by the requested
1745     * column, so a branch whose projection at that position is a constant or
1746     * expression contributes no base table. Falls back to every reachable final
1747     * table only when projection-aware tracing yields nothing.
1748     */
1749    private void addBranchBaseTablesForColumn(TObjectName objName, ColumnSource candidate) {
1750        INamespace ns = candidate.getSourceNamespace();
1751        if (ns == null) {
1752            return;
1753        }
1754
1755        List<TTable> base = new ArrayList<TTable>();
1756        Set<String> seenKeys = new LinkedHashSet<String>();
1757        IdentityHashMap<TSelectSqlStatement, Boolean> visiting =
1758            new IdentityHashMap<TSelectSqlStatement, Boolean>();
1759
1760        String columnName = objName.getColumnNameOnly();
1761        TSelectSqlStatement nsSelect = ns.getSelectStatement();
1762        boolean traced = false;
1763        if (nsSelect != null && columnName != null && !columnName.isEmpty()) {
1764            collectFromSelectBody(nsSelect, columnName, -1, base, seenKeys, visiting);
1765            traced = true;
1766        }
1767
1768        if (!traced) {
1769            // We could not trace the namespace projection-aware (no SELECT body to
1770            // inspect): report every reachable final table rather than dropping
1771            // the candidate entirely. When tracing DID run, an empty result means
1772            // "no physical table supplies this column" (e.g. all branches project
1773            // a constant), so we must not fall back and re-add those tables.
1774            List<TTable> all = ns.getAllFinalTables();
1775            if (all != null) {
1776                for (TTable t : all) {
1777                    if (t != null) {
1778                        base.add(t);
1779                    }
1780                }
1781            }
1782        }
1783
1784        for (TTable t : base) {
1785            objName.getCandidateTables().addTable(t);
1786        }
1787    }
1788
1789    /**
1790     * Collect, into {@code out}, the physical base tables reachable from
1791     * {@code table} that could supply {@code columnName}. Derived tables
1792     * (subqueries / CTEs) are expanded through their projection; physical
1793     * tables are added directly. De-duplicated by qualified table name via
1794     * {@code seenKeys}. {@code visiting} guards against cyclic CTE/subquery
1795     * references.
1796     */
1797    private void collectBaseTablesSupplyingColumn(TTable table, String columnName,
1798            List<TTable> out, Set<String> seenKeys,
1799            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1800        if (table == null) {
1801            return;
1802        }
1803
1804        TSelectSqlStatement body = getDerivedBody(table);
1805        if (body == null) {
1806            // Physical base table (or a reference we cannot trace further).
1807            addBaseTable(table, out, seenKeys);
1808            return;
1809        }
1810
1811        if (visiting.containsKey(body)) {
1812            return;
1813        }
1814        visiting.put(body, Boolean.TRUE);
1815        try {
1816            // A CTE with an explicit column list (e.g. WITH cte(a) AS (SELECT x
1817            // FROM t)) renames its outputs, so the requested name must be mapped
1818            // positionally onto the body's projection rather than matched by the
1819            // body's own display names.
1820            int forcedIndex = -1;
1821            gudusoft.gsqlparser.nodes.TObjectNameList cteColumns =
1822                (table.isCTEName() && table.getCTE() != null) ? table.getCTE().getColumnList() : null;
1823            if (cteColumns != null && cteColumns.size() > 0) {
1824                forcedIndex = indexOfMatchingName(cteColumns, columnName);
1825                if (forcedIndex < 0) {
1826                    // The CTE's column list does not expose this name.
1827                    return;
1828                }
1829            }
1830
1831            collectFromSelectBody(body, columnName, forcedIndex, out, seenKeys, visiting);
1832        } finally {
1833            visiting.remove(body);
1834        }
1835    }
1836
1837    /**
1838     * Collect base tables from a derived table's SELECT body. Handles set
1839     * operations (UNION / EXCEPT / INTERSECT) by flattening to leaf SELECTs and
1840     * mapping the column onto each leaf positionally, and plain SELECTs via
1841     * {@link #collectFromSimpleSelect}.
1842     *
1843     * @param forcedIndex when {@code >= 0}, the column is identified by position
1844     *     (e.g. a CTE explicit column list); when {@code -1}, by output name.
1845     */
1846    private void collectFromSelectBody(TSelectSqlStatement body, String columnName,
1847            int forcedIndex, List<TTable> out, Set<String> seenKeys,
1848            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1849        if (body == null) {
1850            return;
1851        }
1852
1853        if (body.isCombinedQuery()) {
1854            List<TSelectSqlStatement> leaves = new ArrayList<TSelectSqlStatement>();
1855            flattenSetOperationLeaves(body, leaves);
1856            if (leaves.isEmpty()) {
1857                return;
1858            }
1859            // All set operands share one output shape by position. Determine the
1860            // position from the first leaf (or the forced/CTE index) and trace
1861            // that ordinal in every operand.
1862            int idx = forcedIndex;
1863            if (idx < 0) {
1864                idx = indexOfNamedProjection(leaves.get(0).getResultColumnList(), columnName);
1865            }
1866            if (idx < 0) {
1867                // The output ordinal is unknown (e.g. the leftmost operand is
1868                // SELECT *), so the column cannot be mapped onto each operand by
1869                // position. Matching later operands by name would silently drop a
1870                // renamed branch, so over-report every FROM table of every operand
1871                // instead.
1872                for (TSelectSqlStatement leaf : leaves) {
1873                    expandAllFromTables(leaf, columnName, out, seenKeys, visiting);
1874                }
1875                return;
1876            }
1877            for (TSelectSqlStatement leaf : leaves) {
1878                collectFromSimpleSelect(leaf, columnName, idx, out, seenKeys, visiting);
1879            }
1880            return;
1881        }
1882
1883        collectFromSimpleSelect(body, columnName, forcedIndex, out, seenKeys, visiting);
1884    }
1885
1886    /**
1887     * Collect base tables from a single (non-combined) SELECT.
1888     *
1889     * <p>When {@code forcedIndex >= 0} the column is resolved by position onto
1890     * the projection (used for CTE column lists and set-operation operands);
1891     * otherwise it is matched by output name. A {@code SELECT *} or an
1892     * out-of-range/star position falls back to expanding every FROM table, since
1893     * any of them may supply the column.</p>
1894     */
1895    private void collectFromSimpleSelect(TSelectSqlStatement select, String columnName,
1896            int forcedIndex, List<TTable> out, Set<String> seenKeys,
1897            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1898        if (select == null) {
1899            return;
1900        }
1901        TResultColumnList rcl = select.getResultColumnList();
1902        if (rcl == null) {
1903            return;
1904        }
1905
1906        if (forcedIndex >= 0) {
1907            boolean hasStar = false;
1908            for (int i = 0; i < rcl.size(); i++) {
1909                if (starQualifier(rcl.getResultColumn(i)) != null) {
1910                    hasStar = true;
1911                    break;
1912                }
1913            }
1914            if (hasStar || forcedIndex >= rcl.size()) {
1915                // Positional mapping is unreliable: any FROM table may supply it.
1916                expandAllFromTables(select, columnName, out, seenKeys, visiting);
1917                return;
1918            }
1919            TResultColumn rc = rcl.getResultColumn(forcedIndex);
1920            TObjectName underlying = rc != null ? rc.getColumnFullname() : null;
1921            if (underlying != null) {
1922                traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting);
1923            }
1924            // A computed projection at this position has no physical base table.
1925            return;
1926        }
1927
1928        // Match by output name. Collect base tables from EVERY projection that
1929        // could expose the column: each matching named projection (there may be
1930        // several with the same output name, e.g. "SELECT t1.c, t2.c") AND every
1931        // star projection. We do not early-return on the first match, so
1932        // genuinely ambiguous derived tables surface all of their suppliers.
1933        boolean unqualifiedStar = false;
1934        List<String> qualifiedStarPrefixes = new ArrayList<String>();
1935        for (int i = 0; i < rcl.size(); i++) {
1936            TResultColumn rc = rcl.getResultColumn(i);
1937            if (rc == null) {
1938                continue;
1939            }
1940            String starPrefix = starQualifier(rc);
1941            if (starPrefix != null) {
1942                if (starPrefix.isEmpty()) {
1943                    unqualifiedStar = true;
1944                } else {
1945                    qualifiedStarPrefixes.add(starPrefix);
1946                }
1947                continue;
1948            }
1949
1950            String outName = rc.getDisplayName();
1951            if (outName != null && nameMatcher.matches(outName, columnName)) {
1952                TObjectName underlying = rc.getColumnFullname();
1953                if (underlying != null) {
1954                    traceNamedColumnIntoFrom(select, underlying, out, seenKeys, visiting);
1955                }
1956                // A computed/expression column has no physical base table.
1957            }
1958        }
1959
1960        if (unqualifiedStar) {
1961            expandAllFromTables(select, columnName, out, seenKeys, visiting);
1962        }
1963        for (String prefix : qualifiedStarPrefixes) {
1964            TTable matched = findTableByAliasOrName(select, prefix);
1965            if (matched != null) {
1966                collectBaseTablesSupplyingColumn(matched, columnName, out, seenKeys, visiting);
1967            }
1968        }
1969        // No star and no named match: this SELECT cannot supply the column.
1970    }
1971
1972    /**
1973     * Expand every table in {@code select}'s FROM clause as a possible supplier
1974     * of {@code columnName}.
1975     */
1976    private void expandAllFromTables(TSelectSqlStatement select, String columnName,
1977            List<TTable> out, Set<String> seenKeys,
1978            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
1979        if (select == null || select.tables == null) {
1980            return;
1981        }
1982        for (int i = 0; i < select.tables.size(); i++) {
1983            collectBaseTablesSupplyingColumn(select.tables.getTable(i),
1984                columnName, out, seenKeys, visiting);
1985        }
1986    }
1987
1988    /**
1989     * Iteratively flatten a set-operation tree into the leaf SELECTs that
1990     * actually supply output column values, left to right. Uses an explicit
1991     * stack to avoid StackOverflowError on deeply nested set-operation chains.
1992     *
1993     * <p>The operator matters: UNION and INTERSECT take values from both
1994     * operands, but EXCEPT / MINUS take output values from the LEFT operand only
1995     * (the right operand merely filters rows), so its right branch is not a
1996     * source of the selected column and is skipped.</p>
1997     */
1998    private void flattenSetOperationLeaves(TSelectSqlStatement stmt, List<TSelectSqlStatement> leaves) {
1999        java.util.Deque<TSelectSqlStatement> stack = new java.util.ArrayDeque<TSelectSqlStatement>();
2000        stack.push(stmt);
2001        while (!stack.isEmpty()) {
2002            TSelectSqlStatement cur = stack.pop();
2003            if (cur == null) {
2004                continue;
2005            }
2006            if (cur.isCombinedQuery()) {
2007                gudusoft.gsqlparser.ESetOperatorType op = cur.getSetOperatorType();
2008                boolean rightSuppliesValues =
2009                    op != gudusoft.gsqlparser.ESetOperatorType.except
2010                    && op != gudusoft.gsqlparser.ESetOperatorType.minus;
2011                // Push right first (when it supplies values) so the left operand
2012                // is processed first.
2013                if (rightSuppliesValues && cur.getRightStmt() != null) {
2014                    stack.push(cur.getRightStmt());
2015                }
2016                if (cur.getLeftStmt() != null) {
2017                    stack.push(cur.getLeftStmt());
2018                }
2019            } else {
2020                leaves.add(cur);
2021            }
2022        }
2023    }
2024
2025    /**
2026     * Index of the first projection in {@code rcl} whose output name matches
2027     * {@code columnName}, or {@code -1} if none matches or a star projection
2028     * makes positional indexing unreliable.
2029     */
2030    private int indexOfNamedProjection(TResultColumnList rcl, String columnName) {
2031        if (rcl == null) {
2032            return -1;
2033        }
2034        for (int i = 0; i < rcl.size(); i++) {
2035            TResultColumn rc = rcl.getResultColumn(i);
2036            if (rc == null) {
2037                continue;
2038            }
2039            if (starQualifier(rc) != null) {
2040                // A star shifts subsequent positions unpredictably.
2041                return -1;
2042            }
2043            String name = rc.getDisplayName();
2044            if (name != null && nameMatcher.matches(name, columnName)) {
2045                return i;
2046            }
2047        }
2048        return -1;
2049    }
2050
2051    /**
2052     * Index of the first name in {@code names} that matches {@code columnName}.
2053     */
2054    private int indexOfMatchingName(gudusoft.gsqlparser.nodes.TObjectNameList names, String columnName) {
2055        if (names == null) {
2056            return -1;
2057        }
2058        for (int i = 0; i < names.size(); i++) {
2059            TObjectName name = names.getObjectName(i);
2060            String n = name != null ? name.getColumnNameOnly() : null;
2061            if (n != null && nameMatcher.matches(n, columnName)) {
2062                return i;
2063            }
2064        }
2065        return -1;
2066    }
2067
2068    /**
2069     * Trace a named projection column (e.g. {@code t.col} or {@code col}) into
2070     * the FROM clause of {@code body} and collect the base tables that supply
2071     * the referenced column.
2072     */
2073    private void traceNamedColumnIntoFrom(TSelectSqlStatement body, TObjectName underlying,
2074            List<TTable> out, Set<String> seenKeys,
2075            IdentityHashMap<TSelectSqlStatement, Boolean> visiting) {
2076        if (body == null || body.tables == null || underlying == null) {
2077            return;
2078        }
2079        String underlyingName = underlying.getColumnNameOnly();
2080        if (underlyingName == null || underlyingName.isEmpty()) {
2081            return;
2082        }
2083        String tableQualifier = columnTableQualifier(underlying);
2084        if (tableQualifier != null && !tableQualifier.isEmpty()) {
2085            TTable matched = findTableByAliasOrName(body, tableQualifier);
2086            if (matched != null) {
2087                collectBaseTablesSupplyingColumn(matched, underlyingName, out,
2088                    seenKeys, visiting);
2089                return;
2090            }
2091        }
2092
2093        // If resolver2 already resolved this inner projection column to a single
2094        // source (e.g. via DDL/sqlenv metadata), trust that source rather than
2095        // broadening to every FROM table. This keeps candidateTables accurate
2096        // for "SELECT c FROM t1 JOIN t2" when only t1 actually exposes c.
2097        TTable resolvedSource = underlying.getSourceTable();
2098        if (resolvedSource != null && isInFromClause(body, resolvedSource)) {
2099            collectBaseTablesSupplyingColumn(resolvedSource, underlyingName, out,
2100                seenKeys, visiting);
2101            return;
2102        }
2103
2104        // Unqualified and unresolved: the underlying column may originate from
2105        // any FROM table.
2106        for (int i = 0; i < body.tables.size(); i++) {
2107            collectBaseTablesSupplyingColumn(body.tables.getTable(i),
2108                underlyingName, out, seenKeys, visiting);
2109        }
2110    }
2111
2112    /**
2113     * Whether {@code target} is one of the tables in {@code body}'s FROM clause
2114     * (by object identity). Used to ensure a resolved {@code sourceTable} really
2115     * belongs to this derived table's scope before trusting it.
2116     */
2117    private boolean isInFromClause(TSelectSqlStatement body, TTable target) {
2118        if (body == null || body.tables == null || target == null) {
2119            return false;
2120        }
2121        for (int i = 0; i < body.tables.size(); i++) {
2122            if (body.tables.getTable(i) == target) {
2123                return true;
2124            }
2125        }
2126        return false;
2127    }
2128
2129    /**
2130     * Return the SELECT body of a derived table (inline subquery or CTE
2131     * reference), or {@code null} if {@code table} is a physical table.
2132     */
2133    private TSelectSqlStatement getDerivedBody(TTable table) {
2134        if (table == null) {
2135            return null;
2136        }
2137        if (table.getSubquery() != null) {
2138            return table.getSubquery();
2139        }
2140        if (table.isCTEName() && table.getCTE() != null) {
2141            return table.getCTE().getSubquery();
2142        }
2143        return null;
2144    }
2145
2146    /**
2147     * Add a physical base table to {@code out}, de-duplicated by qualified name.
2148     */
2149    private void addBaseTable(TTable table, List<TTable> out, Set<String> seenKeys) {
2150        if (table == null) {
2151            return;
2152        }
2153        String key = baseTableKey(table);
2154        if (seenKeys.add(key)) {
2155            out.add(table);
2156        }
2157    }
2158
2159    private String baseTableKey(TTable table) {
2160        String name = table.getFullName();
2161        if (name == null || name.isEmpty()) {
2162            name = table.getName();
2163        }
2164        if (name == null) {
2165            name = table.toString();
2166        }
2167        return name == null ? "" : name.toLowerCase(java.util.Locale.ROOT);
2168    }
2169
2170    /**
2171     * If {@code rc} is a star result column, return its qualifier: an empty
2172     * string for an unqualified {@code *}, or the table prefix for {@code q.*}.
2173     * Returns {@code null} when {@code rc} is not a star column.
2174     */
2175    private String starQualifier(TResultColumn rc) {
2176        if (rc == null || rc.getExpr() == null) {
2177            return null;
2178        }
2179        TObjectName operand = rc.getExpr().getObjectOperand();
2180        String text = operand != null ? operand.toString() : rc.toString();
2181        if (text == null) {
2182            return null;
2183        }
2184        text = text.trim();
2185        if ("*".equals(text)) {
2186            return "";
2187        }
2188        if (text.endsWith(".*")) {
2189            String prefix = text.substring(0, text.length() - 2).trim();
2190            // Keep only the last segment of a multi-part prefix (e.g. a.b.* -> b).
2191            int dot = prefix.lastIndexOf('.');
2192            if (dot >= 0) {
2193                prefix = prefix.substring(dot + 1).trim();
2194            }
2195            return prefix;
2196        }
2197        return null;
2198    }
2199
2200    /**
2201     * Return the table qualifier of a (possibly) qualified column reference,
2202     * e.g. {@code "t"} for {@code t.col} or {@code "t"} for {@code s.t.col}.
2203     * Returns {@code null} for an unqualified column.
2204     */
2205    private String columnTableQualifier(TObjectName column) {
2206        if (column == null) {
2207            return null;
2208        }
2209        String full = column.toString();
2210        if (full == null) {
2211            return null;
2212        }
2213        int lastDot = full.lastIndexOf('.');
2214        if (lastDot < 0) {
2215            return null;
2216        }
2217        String prefix = full.substring(0, lastDot).trim();
2218        int prevDot = prefix.lastIndexOf('.');
2219        if (prevDot >= 0) {
2220            prefix = prefix.substring(prevDot + 1).trim();
2221        }
2222        return prefix.isEmpty() ? null : prefix;
2223    }
2224
2225    /**
2226     * Find a table in {@code body}'s FROM clause by alias or (last segment of)
2227     * name, matching case-insensitively.
2228     */
2229    private TTable findTableByAliasOrName(TSelectSqlStatement body, String qualifier) {
2230        if (body == null || body.tables == null || qualifier == null) {
2231            return null;
2232        }
2233        for (int i = 0; i < body.tables.size(); i++) {
2234            TTable t = body.tables.getTable(i);
2235            if (t == null) {
2236                continue;
2237            }
2238            String alias = t.getAliasName();
2239            if (alias != null && tableNameMatches(alias, qualifier)) {
2240                return t;
2241            }
2242            String name = t.getName();
2243            if (name != null) {
2244                int dot = name.lastIndexOf('.');
2245                String shortName = dot >= 0 ? name.substring(dot + 1) : name;
2246                if (tableNameMatches(shortName, qualifier)) {
2247                    return t;
2248                }
2249            }
2250        }
2251        return null;
2252    }
2253
2254    /**
2255     * Table-name/alias equality routed through the unified façade; falls back to
2256     * case-insensitive comparison when no vendor is configured (synthetic scopes).
2257     */
2258    private boolean tableNameMatches(String a, String b) {
2259        if (a == null || b == null) {
2260            return false;
2261        }
2262        EDbVendor vendor = config != null ? config.getVendor() : null;
2263        // The resolution's own service (a configured profile, else the vendor default — the same
2264        // relation SQLUtil.sameName uses), so a qualifier matches here exactly as in the scopes.
2265        return vendor == null ? a.equalsIgnoreCase(b)
2266                : config.identifierService().areEqual(a, b, ESQLDataObjectType.dotTable);
2267    }
2268
2269    /**
2270     * Resolve a column within a specific namespace (for direct lookups).
2271     */
2272    public ResolutionResult resolveInNamespace(String columnName, INamespace namespace) {
2273        if (columnName == null || namespace == null) {
2274            return ResolutionResult.notFound("<null>");
2275        }
2276
2277        ColumnSource source = namespace.resolveColumn(columnName);
2278        if (source != null) {
2279            return ResolutionResult.exactMatch(source);
2280        }
2281
2282        return ResolutionResult.notFound(columnName);
2283    }
2284
2285    /**
2286     * Find all namespaces that contain a given column.
2287     * Used for implementing full candidate collection in ambiguous scenarios.
2288     */
2289    /**
2290     * The unaliased UNNEST value tables of unknown element type in the FROM clause of {@code scope} that the
2291     * reference can read (Mantis 4828). A reference inside an UNNEST argument, {@code UNNEST(a)} in
2292     * {@code FROM t, UNNEST(a), UNNEST(b)}, reads only the value tables to its left: the argument cannot name a
2293     * column of the table it feeds, nor of one that comes after it.
2294     */
2295    private List<UnnestNamespace> unknownValueTablesIn(IScope scope, TObjectName reference) {
2296        List<UnnestNamespace> result = new ArrayList<UnnestNamespace>();
2297        if (!(scope instanceof SelectScope) || ((SelectScope) scope).getFromScope() == null) {
2298            return result;
2299        }
2300        List<gudusoft.gsqlparser.resolver2.model.ScopeChild> children = ((SelectScope) scope).getFromScope().getChildren();
2301        UnnestNamespace enclosing = null;
2302        for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : children) {
2303            if (child != null && child.getNamespace() instanceof UnnestNamespace
2304                    && insideUnnestArgument(reference, (UnnestNamespace) child.getNamespace())) {
2305                enclosing = (UnnestNamespace) child.getNamespace();
2306            }
2307        }
2308        for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : children) {
2309            if (child == null || !(child.getNamespace() instanceof UnnestNamespace)) {
2310                continue;
2311            }
2312            UnnestNamespace valueTable = (UnnestNamespace) child.getNamespace();
2313            if (!valueTable.mayHoldUnknownFields() || valueTable == enclosing) {
2314                continue;
2315            }
2316            if (enclosing != null && !precedes(valueTable, enclosing)) {
2317                continue;
2318            }
2319            result.add(valueTable);
2320        }
2321        return result;
2322    }
2323
2324    /**
2325     * The columns named {@code columnName} that another item of the reference's FROM clause authoritatively declares
2326     * (metadata FOUND; an inferred column of a table without metadata is no competitor).
2327     */
2328    private static List<ColumnSource> declaredColumnsOfOtherItems(IScope scope, INamespace self, String columnName) {
2329        List<ColumnSource> result = new ArrayList<ColumnSource>();
2330        if (!(scope instanceof SelectScope) || ((SelectScope) scope).getFromScope() == null) {
2331            return result;
2332        }
2333        for (gudusoft.gsqlparser.resolver2.model.ScopeChild child : ((SelectScope) scope).getFromScope().getChildren()) {
2334            INamespace other = child == null ? null : child.getNamespace();
2335            if (other == null || other == self
2336                    || other.getMetadataState() != gudusoft.gsqlparser.resolver2.namespace.MetadataState.FOUND
2337                    || other.hasAuthoritativeOutputColumn(columnName) != ColumnLevel.EXISTS) {
2338                continue;
2339            }
2340            ColumnSource column = other.resolveColumn(columnName);
2341            if (column != null) {
2342                result.add(column);
2343            }
2344        }
2345        return result;
2346    }
2347
2348    /** Whether the UNNEST clause of {@code earlier} ends before the clause of {@code later} starts. */
2349    private static boolean precedes(UnnestNamespace earlier, UnnestNamespace later) {
2350        TTable a = earlier.getSourceTable();
2351        TTable b = later.getSourceTable();
2352        if (a == null || b == null || a.getUnnestClause() == null || b.getUnnestClause() == null
2353                || a.getUnnestClause().getEndToken() == null || b.getUnnestClause().getStartToken() == null) {
2354            return false;
2355        }
2356        return a.getUnnestClause().getEndToken().posinlist < b.getUnnestClause().getStartToken().posinlist;
2357    }
2358
2359    /** One placeholder candidate per value table that may hold the field {@code columnName} (Mantis 4828). */
2360    private static void addValueTableCandidates(List<ColumnSource> candidates, List<UnnestNamespace> valueTables,
2361            String columnName) {
2362        for (UnnestNamespace valueTable : valueTables) {
2363            candidates.add(new ColumnSource(valueTable, columnName, null, 0.3, "unnest_value_table_candidate"));
2364        }
2365    }
2366
2367    /** Whether the reference is the UNNEST's own argument, UNNEST(items): it cannot read the value table it feeds. */
2368    private static boolean insideUnnestArgument(TObjectName reference, UnnestNamespace valueTable) {
2369        TTable unnestTable = valueTable.getSourceTable();
2370        if (reference == null || reference.getStartToken() == null || unnestTable == null
2371                || unnestTable.getUnnestClause() == null || unnestTable.getUnnestClause().getStartToken() == null
2372                || unnestTable.getUnnestClause().getEndToken() == null) {
2373            return false;
2374        }
2375        long at = reference.getStartToken().posinlist;
2376        return at >= unnestTable.getUnnestClause().getStartToken().posinlist
2377                && at <= unnestTable.getUnnestClause().getEndToken().posinlist;
2378    }
2379
2380    /** Whether a candidate rests on inference only (a table without DDL, or low confidence): the reading the
2381     *  all-inferred check applies (Mantis 4828). */
2382    private boolean isInferredCandidate(ColumnSource candidate) {
2383        if (candidate.getEvidenceDetail() != null) {
2384            return !candidate.getEvidenceDetail().isHighConfidence() || candidate.getEvidenceDetail().isInferred();
2385        }
2386        double minDefiniteConf = config != null ? config.getMinDefiniteConfidence() : 0.9;
2387        String evidence = candidate.getEvidence();
2388        return evidence == null || "inferred_from_usage".equals(evidence) || candidate.getConfidence() < minDefiniteConf;
2389    }
2390
2391    public List<INamespace> findNamespacesWithColumn(String columnName, IScope scope) {
2392        List<INamespace> result = new ArrayList<>();
2393
2394        for (INamespace ns : scope.getVisibleNamespaces()) {
2395            if (ns.hasColumn(columnName) == ColumnLevel.EXISTS) {
2396                result.add(ns);
2397            }
2398        }
2399
2400        return result;
2401    }
2402
2403    public INameMatcher getNameMatcher() {
2404        return nameMatcher;
2405    }
2406
2407    public ResolutionContext getContext() {
2408        return context;
2409    }
2410}