001package gudusoft.gsqlparser.ir.semantic.binding;
002
003import gudusoft.gsqlparser.ir.semantic.RelationKind;
004import gudusoft.gsqlparser.nodes.TObjectName;
005import gudusoft.gsqlparser.nodes.TResultColumn;
006import gudusoft.gsqlparser.nodes.TResultColumnList;
007import gudusoft.gsqlparser.nodes.TTable;
008import gudusoft.gsqlparser.resolver2.ResolutionStatus;
009import gudusoft.gsqlparser.resolver2.model.ColumnSource;
010import gudusoft.gsqlparser.resolver2.model.ResolutionResult;
011import gudusoft.gsqlparser.sqlenv.TSQLColumn;
012import gudusoft.gsqlparser.sqlenv.TSQLEnv;
013import gudusoft.gsqlparser.sqlenv.TSQLTable;
014import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
015
016import java.util.ArrayList;
017import java.util.Collections;
018import java.util.HashMap;
019import java.util.HashSet;
020import java.util.List;
021import java.util.Locale;
022import java.util.Map;
023import java.util.Set;
024
025/**
026 * {@link NameBindingProvider} backed by the data already attached to AST
027 * nodes by {@code TSQLResolver2} during {@code TGSqlParser.parse()}.
028 *
029 * <p>Slice 1/2 only handled {@link RelationKind#TABLE}. Slice 3 adds
030 * {@link RelationKind#CTE} via {@link #withCteContext(Set)}: when the
031 * provider is given a non-empty CTE-name set, a FROM-clause reference
032 * whose name (case-insensitive) is in the set binds as
033 * {@code RelationKind.CTE} instead of {@code RelationKind.TABLE}.
034 */
035public final class Resolver2NameBindingProvider implements NameBindingProvider {
036
037    /**
038     * Slice 58 — optional catalog source for {@link
039     * #getRelationColumnNames(TTable)}. May be {@code null} (no catalog
040     * available); in that case {@code getRelationColumnNames} returns
041     * {@code null} and the builder emits a structured "requires catalog"
042     * diagnostic.
043     */
044    private final TSQLEnv sqlEnv;
045
046    private final Set<String> cteNamesInScope;
047
048    /**
049     * Slice 60 — REPLACE-semantics map of in-scope CTE / FROM-subquery
050     * names → published column names for star expansion. Always
051     * non-null (empty map when no scope is set). Keys are lower-case
052     * (defensive copy in the canonical constructor); the value lists
053     * are wrapped with {@link Collections#unmodifiableList} so callers
054     * cannot mutate the snapshot the provider observed.
055     */
056    private final Map<String, List<String>> inScopeRelationColumns;
057
058    /**
059     * Slice 65 — REPLACE-semantics using-key scope for the current
060     * SELECT body. Always non-null (defaults to {@link UsingScope#EMPTY}).
061     * Each {@code buildSelectStatementImpl} invocation resets this at
062     * entry so an enclosing SELECT's USING cannot leak into recursive
063     * nested builds.
064     */
065    private final UsingScope usingScope;
066
067    /**
068     * Slice 93 — when true, {@link #bindColumn} promotes a column
069     * reference whose Phase-2 ({@code TSQLResolver2}) {@code resolution}
070     * is null but whose Phase-1 ({@code linkColumnToTable})
071     * {@code sourceTable} is set from {@code NOT_FOUND} to
072     * {@code EXACT_MATCH}. Used for Hive multi-insert sub-SELECTs whose
073     * secondary branches are not traversed by Resolver2 during
074     * {@code TGSqlParser.parse()}. The promotion additionally requires
075     * the column's SQL-written qualifier (if any) to be consistent with
076     * the source table's name / alias (see {@link #bindColumn}).
077     */
078    private final boolean sourceTableFallback;
079
080    /**
081     * Slice 117 — set of inner local relation aliases (lowercased) used
082     * by the tolerant-outer-binding fallback in {@link #bindColumn}. When
083     * non-empty, any qualified ref whose Phase-2 binding is not
084     * {@link ResolutionStatus#EXACT_MATCH} AND whose qualifier is NOT in
085     * this set is promoted to a synthetic EXACT_MATCH binding with
086     * {@code (qualifier, columnName)}. Qualifiers IN this set fall
087     * through to strict binding (a real typo on a local alias still
088     * rejects). Empty set disables the fallback.
089     */
090    private final Set<String> tolerantInnerLocalAliases;
091
092    public Resolver2NameBindingProvider() {
093        this(null, Collections.<String>emptySet(),
094                Collections.<String, List<String>>emptyMap(),
095                UsingScope.EMPTY, false, Collections.<String>emptySet());
096    }
097
098    /**
099     * Slice 58 — construct a provider with catalog access. The {@code
100     * sqlEnv} is used only by {@link #getRelationColumnNames(TTable)} for
101     * star expansion; the resolver binding paths remain unchanged.
102     */
103    public Resolver2NameBindingProvider(TSQLEnv sqlEnv) {
104        this(sqlEnv, Collections.<String>emptySet(),
105                Collections.<String, List<String>>emptyMap(),
106                UsingScope.EMPTY, false, Collections.<String>emptySet());
107    }
108
109    /**
110     * Slice 60 / 65 — canonical private constructor. All public and
111     * narrower entry points delegate here so every Resolver2-backed
112     * provider instance has all four fields populated explicitly.
113     * Adding another facet later is a single signature change here +
114     * mirror updates at the narrowers ({@link #withCteContext},
115     * {@link #withInScopeRelationColumns}, {@link #withUsingScope}).
116     */
117    private Resolver2NameBindingProvider(TSQLEnv sqlEnv,
118                                         Set<String> cteNamesInScope,
119                                         Map<String, List<String>> inScopeRelationColumns,
120                                         UsingScope usingScope,
121                                         boolean sourceTableFallback,
122                                         Set<String> tolerantInnerLocalAliases) {
123        this.sqlEnv = sqlEnv;
124        this.sourceTableFallback = sourceTableFallback;
125        // Slice 117: defensive copy + lowercased. Empty set disables
126        // the tolerant-outer-binding fallback.
127        Set<String> normalizedTolerant = new HashSet<>();
128        if (tolerantInnerLocalAliases != null) {
129            for (String a : tolerantInnerLocalAliases) {
130                if (a != null && !a.isEmpty()) {
131                    normalizedTolerant.add(a.toLowerCase(Locale.ROOT));
132                }
133            }
134        }
135        this.tolerantInnerLocalAliases =
136                Collections.unmodifiableSet(normalizedTolerant);
137        // Defensive copy + lowercased for case-insensitive lookup.
138        // NOTE: this is a slice-3 simplification; quoted/case-sensitive
139        // identifiers (e.g. Oracle's `"a"` vs `a`, PostgreSQL's lowercased
140        // unquoted names) are not yet handled. A later slice should fold
141        // identifiers through the parser/resolver's vendor-aware identifier
142        // model rather than `String.toLowerCase()`.
143        Set<String> normalized = new HashSet<>();
144        if (cteNamesInScope != null) {
145            for (String n : cteNamesInScope) {
146                if (n != null && !n.isEmpty()) {
147                    normalized.add(n.toLowerCase(Locale.ROOT));
148                }
149            }
150        }
151        this.cteNamesInScope = Collections.unmodifiableSet(normalized);
152        // Slice 60: deep-copy the map. Lower-case keys; wrap value
153        // lists in unmodifiableList. Empty entries are skipped — an
154        // empty list never means "in scope but has no columns" (it
155        // would surface as `NO_INSCOPE_RELATION_COLUMNS` at the
156        // expander anyway, but explicit drop here keeps the provider
157        // invariant clean).
158        Map<String, List<String>> normalizedColumns = new HashMap<>();
159        if (inScopeRelationColumns != null) {
160            for (Map.Entry<String, List<String>> e : inScopeRelationColumns.entrySet()) {
161                String k = e.getKey();
162                List<String> v = e.getValue();
163                if (k == null || k.isEmpty() || v == null || v.isEmpty()) continue;
164                normalizedColumns.put(
165                        k.toLowerCase(Locale.ROOT),
166                        Collections.unmodifiableList(new ArrayList<>(v)));
167            }
168        }
169        this.inScopeRelationColumns = Collections.unmodifiableMap(normalizedColumns);
170        this.usingScope = usingScope == null ? UsingScope.EMPTY : usingScope;
171    }
172
173    @Override
174    public NameBindingProvider withCteContext(Set<String> cteNamesInScope) {
175        // Slice 58 preserves sqlEnv. Slice 60 also preserves
176        // inScopeRelationColumns: the CTE-context narrowing is
177        // orthogonal to star-expansion scope. Without this, the
178        // CTE-body build path's bodyProvider would lose the outer
179        // map and CTE-body star expansion of an EARLIER CTE would
180        // hit NO_INSCOPE_RELATION_COLUMNS. Slice 65 also preserves
181        // usingScope so a withCteContext call inside an already-narrowed
182        // build doesn't lose the current SELECT's USING scope.
183        // Slice 93: also preserves sourceTableFallback.
184        // Slice 117: also preserves tolerantInnerLocalAliases.
185        return new Resolver2NameBindingProvider(this.sqlEnv, cteNamesInScope,
186                this.inScopeRelationColumns, this.usingScope,
187                this.sourceTableFallback, this.tolerantInnerLocalAliases);
188    }
189
190    @Override
191    public NameBindingProvider withInScopeRelationColumns(
192            Map<String, List<String>> nameToColumns) {
193        // Slice 60 replace-semantics: the supplied map fully describes
194        // the new scope. Preserves sqlEnv (catalog access for
195        // base-table star expansion stays available even when scoped
196        // to a CTE body) AND cteNamesInScope (so a later
197        // bindRelation() call still classifies CTE-bound relations
198        // correctly). Slice 65: also preserves usingScope.
199        // Slice 93: also preserves sourceTableFallback.
200        // Slice 117: also preserves tolerantInnerLocalAliases.
201        return new Resolver2NameBindingProvider(this.sqlEnv,
202                this.cteNamesInScope, nameToColumns, this.usingScope,
203                this.sourceTableFallback, this.tolerantInnerLocalAliases);
204    }
205
206    @Override
207    public NameBindingProvider withUsingScope(UsingScope scope) {
208        // Slice 65 replace-semantics: the supplied scope fully describes
209        // the merged-key context for the current SELECT body. Preserves
210        // sqlEnv, cteNamesInScope, and inScopeRelationColumns so the
211        // other facets are unaffected.
212        // Slice 93: also preserves sourceTableFallback.
213        // Slice 117: also preserves tolerantInnerLocalAliases.
214        return new Resolver2NameBindingProvider(this.sqlEnv,
215                this.cteNamesInScope, this.inScopeRelationColumns,
216                scope == null ? UsingScope.EMPTY : scope,
217                this.sourceTableFallback, this.tolerantInnerLocalAliases);
218    }
219
220    @Override
221    public NameBindingProvider withSourceTableFallback(boolean enabled) {
222        // Slice 93 — preserves all other facets. Returns same instance
223        // when state already matches the requested mode (cheap no-op).
224        // Slice 117: also preserves tolerantInnerLocalAliases.
225        if (this.sourceTableFallback == enabled) {
226            return this;
227        }
228        return new Resolver2NameBindingProvider(this.sqlEnv,
229                this.cteNamesInScope, this.inScopeRelationColumns,
230                this.usingScope, enabled, this.tolerantInnerLocalAliases);
231    }
232
233    @Override
234    public NameBindingProvider withTolerantOuterBinding(
235            Set<String> innerLocalAliasesLower) {
236        // Slice 117 — REPLACE semantics: the supplied set fully
237        // describes the inner-local guard for tolerant-outer-binding.
238        // Preserves all other facets (sqlEnv / cteNamesInScope /
239        // inScopeRelationColumns / usingScope / sourceTableFallback).
240        // Passing null / empty disables the fallback in the canonical
241        // constructor (which normalises a null/empty set to an empty
242        // unmodifiable set).
243        return new Resolver2NameBindingProvider(this.sqlEnv,
244                this.cteNamesInScope, this.inScopeRelationColumns,
245                this.usingScope, this.sourceTableFallback,
246                innerLocalAliasesLower);
247    }
248
249    @Override
250    public UsingScope getUsingScope() {
251        return usingScope;
252    }
253
254    @Override
255    public Map<String, List<String>> getInScopeRelationColumns() {
256        return inScopeRelationColumns;
257    }
258
259    @Override
260    public List<String> getRelationColumnNames(TTable table) {
261        if (sqlEnv == null || table == null) {
262            return null;
263        }
264        // Only base-table relations are eligible for catalog lookup;
265        // CTE / FROM-subquery / function tables resolve via separate
266        // binding paths and are out of scope for slice 58 (S60).
267        if (table.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
268            return null;
269        }
270        TObjectName tableName = table.getTableName();
271        if (tableName == null) {
272            return null;
273        }
274        // TSQLEnv.searchTable(TObjectName) handles bare names via the
275        // "..<name>" fallback (TSQLEnv.java:1167-1171) for PG/Oracle/
276        // Snowflake. Other dialects (e.g. MSSQL with ".dbo." expansion)
277        // require the caller to register the table under the matching
278        // qualified form.
279        TSQLTable tbl = sqlEnv.searchTable(tableName);
280        if (tbl == null) {
281            return null;
282        }
283        List<TSQLColumn> cols = tbl.getColumnList();
284        if (cols == null || cols.isEmpty()) {
285            return null;
286        }
287        // Slice 58 dedup. TSQLTable.getColumnList iterates columnMap.keySet,
288        // and addColumn stores each column under both the legacy
289        // normalization key AND the IdentifierService key when the two
290        // differ (TSQLTable.java:127-150). For unquoted identifiers on
291        // most dialects those keys disagree, so the same TSQLColumn
292        // surfaces twice (the SAME instance under two keys). Identity-
293        // based dedup keeps distinct case-sensitive/quoted catalog
294        // columns intact (codex round-1 diff review SHOULD) — case-fold
295        // dedup would have collapsed e.g. catalog columns "Id" and "id"
296        // declared as separate quoted identifiers.
297        List<String> names = new ArrayList<>(cols.size());
298        java.util.IdentityHashMap<TSQLColumn, Boolean> seen = new java.util.IdentityHashMap<>();
299        for (TSQLColumn c : cols) {
300            if (c == null) {
301                continue;
302            }
303            if (seen.put(c, Boolean.TRUE) != null) {
304                continue;
305            }
306            String n = c.getNameKeepCase();
307            if (n == null || n.isEmpty()) {
308                n = c.getName();
309            }
310            if (n == null || n.isEmpty()) {
311                continue;
312            }
313            names.add(n);
314        }
315        if (names.isEmpty()) {
316            return null;
317        }
318        return Collections.unmodifiableList(names);
319    }
320
321    @Override
322    public RelationBinding bindRelation(TTable table) {
323        if (table == null) {
324            return null;
325        }
326        // Slice 5 added FROM-clause subqueries: a TTable of type
327        // ETableSource.subquery binds as RelationKind.SUBQUERY using its
328        // alias as the qualifiedName (no globally-visible name exists).
329        // Slice 74 extended this to admit anonymous (unaliased) FROM
330        // subqueries by synthesizing a position-keyed alias via
331        // FromSubqueryNaming.synthAliasFor.
332        // NOTE: aliases are matched case-insensitively elsewhere in the
333        // builder (see e.g. cte alias lookup) — quoted/case-sensitive
334        // aliases share the same slice-3 limitation.
335        if (table.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
336            String alias = table.getAliasName();
337            if (alias == null || alias.isEmpty()) {
338                alias = FromSubqueryNaming.synthAliasFor(table);
339            }
340            if (alias == null || alias.isEmpty()) {
341                return null;
342            }
343            return new RelationBinding(RelationKind.SUBQUERY, alias);
344        }
345        // Otherwise only base tables (ETableSource.objectname) are bound.
346        // Other source kinds (function, rowList, etc.) return null so the
347        // builder fails fast.
348        if (table.getTableType() != gudusoft.gsqlparser.ETableSource.objectname) {
349            return null;
350        }
351        String name = table.getName();
352        if (name == null || name.isEmpty()) {
353            return null;
354        }
355        if (cteNamesInScope.contains(name.toLowerCase(Locale.ROOT))) {
356            return new RelationBinding(RelationKind.CTE, name);
357        }
358        return new RelationBinding(RelationKind.TABLE, name);
359    }
360
361    @Override
362    public ColumnBinding bindColumn(TObjectName columnRef) {
363        if (columnRef == null) {
364            return null;
365        }
366        String columnName = columnRef.getColumnNameOnly();
367        if (columnName == null || columnName.isEmpty() || "*".equals(columnName)) {
368            return null;
369        }
370        // Effective in-statement alias: prefer the prefix actually written in
371        // the SQL (e.g. `e` in `e.id`); fall back to the resolved source-table
372        // name when the column was written unqualified.
373        // Slice 74: when the source table is an unaliased FROM-subquery,
374        // route through FromSubqueryNaming so the ColumnRef.relationAlias
375        // matches the synth name used by buildRelation / processDirectSubqueryTable
376        // (otherwise we'd emit `relationAlias = "subquery"`, which no
377        // relation map knows about, and projection lookups would fail
378        // with "references unknown relation 'subquery'").
379        String relationAlias = columnRef.getTableString();
380        if (relationAlias == null || relationAlias.isEmpty()) {
381            if (columnRef.getSourceTable() != null) {
382                gudusoft.gsqlparser.nodes.TTable st = columnRef.getSourceTable();
383                String sourceAlias = st.getAliasName();
384                if (sourceAlias != null && !sourceAlias.isEmpty()) {
385                    relationAlias = sourceAlias;
386                } else if (st.getTableType() == gudusoft.gsqlparser.ETableSource.subquery) {
387                    relationAlias = FromSubqueryNaming.synthAliasFor(st);
388                } else {
389                    relationAlias = st.getName();
390                }
391            }
392        }
393        if (relationAlias == null || relationAlias.isEmpty()) {
394            return null;
395        }
396
397        ResolutionResult resolution = columnRef.getResolution();
398        ResolutionStatus status = resolution == null ? ResolutionStatus.NOT_FOUND : resolution.getStatus();
399        String finalTable = null;
400        if (resolution != null && resolution.getStatus() == ResolutionStatus.EXACT_MATCH) {
401            ColumnSource source = resolution.getColumnSource();
402            if (source != null && source.getFinalTable() != null) {
403                finalTable = source.getFinalTable().getName();
404            }
405        }
406        // Slice 93 — Phase-1 source-table fallback for Hive multi-insert
407        // sub-SELECTs. Resolver2 does not traverse secondary multi-insert
408        // branches, so their columns have resolution == null (Phase 2 did
409        // not run). When Phase 1's linkColumnToTable has set sourceTable,
410        // trust that and promote the status to EXACT_MATCH so
411        // collectColumnRefs admits the binding.
412        //
413        // CRITICAL DISCRIMINATORS:
414        //  1. Only fire when resolution == null (Phase 2 did not run).
415        //     Do NOT fire on an explicit NOT_FOUND/AMBIGUOUS status from
416        //     Resolver2 — that would overrule Resolver2's deliberate
417        //     rejections (round-2 codex Q1 BLOCKING).
418        //  2. If the column has a SQL-written qualifier (e.g. `s.id`),
419        //     the qualifier MUST match Phase 1's chosen source table by
420        //     name or alias (case-insensitive). Otherwise Phase 1 may have
421        //     heuristically picked a source the user did not name, and
422        //     promoting would silently mis-bind (round-3 codex P0 BLOCKING).
423        //  3. The source table must have a non-empty resolvable name; an
424        //     anonymous source table is not a trustworthy fallback target.
425        if (sourceTableFallback && resolution == null
426                && columnRef.getSourceTable() != null
427                && qualifierMatchesSource(columnRef)) {
428            status = ResolutionStatus.EXACT_MATCH;
429            // finalTable stays null — Phase 1 only knows the source table,
430            // not the catalog's final binding. Downstream consumers should
431            // tolerate finalTable == null on fallback-promoted bindings.
432        }
433        // Slice 117 — tolerant-outer-binding fallback for the UPDATE
434        // SET-RHS scalar-subquery extractor. When the binding is still
435        // non-EXACT_MATCH at this point (Resolver2 marked the ref as
436        // NOT_FOUND because the qualifier resolves to neither an inner
437        // local relation nor a Phase-1 sourceTable), AND the ref carries
438        // a non-empty SQL-written qualifier, AND that qualifier is NOT
439        // in the inner local FROM aliases, promote to a synthetic
440        // EXACT_MATCH binding with (qualifier, columnName). The slice-11
441        // promoter then sees the resulting ColumnRef and synthesises an
442        // OUTER_REFERENCE relation against the enclosing scope.
443        //
444        // Qualifiers IN the inner local FROM aliases fall through to
445        // strict binding so real typos (e.g. `o.bad_col` where `o` is the
446        // inner FROM alias) still reject as COLUMN_BINDING_NON_EXACT.
447        // Unqualified refs also fall through (their binding is genuinely
448        // ambiguous between inner and outer; the caller throws the same
449        // diagnostic).
450        if (status != ResolutionStatus.EXACT_MATCH
451                && !tolerantInnerLocalAliases.isEmpty()) {
452            String qual = columnRef.getTableString();
453            if (qual != null && !qual.isEmpty()
454                    && !tolerantInnerLocalAliases.contains(
455                            qual.toLowerCase(Locale.ROOT))) {
456                // Use the SQL-written qualifier as the relationAlias so
457                // promoteCorrelatedRefsToOuterReference looks up the
458                // enclosing scope by the user-written name (matches the
459                // slice-14 alias-preserving convention).
460                return new ColumnBinding(qual, columnName, /*finalTable=*/ null,
461                        ResolutionStatus.EXACT_MATCH);
462            }
463        }
464        return new ColumnBinding(relationAlias, columnName, finalTable, status);
465    }
466
467    /**
468     * Slice 93 — safety predicate for the {@link #sourceTableFallback}
469     * path. Returns true when it's safe to trust Phase 1's
470     * {@code sourceTable} on a column reference whose Phase 2 resolution
471     * is null.
472     *
473     * <p>Safe when:
474     * <ul>
475     *   <li>The source table has a non-empty name (anonymous tables are
476     *       not trustworthy fallback targets), AND</li>
477     *   <li>Either the column reference is unqualified (single-source
478     *       FROMs in Hive multi-insert make Phase 1's choice unambiguous),
479     *       or the qualifier matches the source's name or alias
480     *       (case-insensitive) — a mismatched qualifier means Phase 1
481     *       picked a different source than the user named.</li>
482     * </ul>
483     */
484    private static boolean qualifierMatchesSource(TObjectName columnRef) {
485        gudusoft.gsqlparser.nodes.TTable st = columnRef.getSourceTable();
486        if (st == null) {
487            return false;
488        }
489        String srcName = st.getName();
490        String srcAlias = st.getAliasName();
491        boolean hasIdentifiableSource = (srcName != null && !srcName.isEmpty())
492                || (srcAlias != null && !srcAlias.isEmpty());
493        if (!hasIdentifiableSource) {
494            return false;
495        }
496        String qual = columnRef.getTableString();
497        if (qual == null || qual.isEmpty()) {
498            // Unqualified — Phase 1's choice stands. In single-source FROM
499            // contexts (the only Hive multi-insert shape currently
500            // admitted) this is unambiguous.
501            return true;
502        }
503        // Qualified — qualifier must match source name or alias.
504        return qual.equalsIgnoreCase(srcName) || qual.equalsIgnoreCase(srcAlias);
505    }
506
507    /**
508     * Slice 19: detect alias-bound PARTITION BY / OVER ORDER BY refs.
509     *
510     * <p>The check fires only when ALL of:
511     * <ol>
512     *   <li>{@code columnRef} is unqualified ({@code getTableToken() == null});
513     *       a qualified ref like {@code e.doubled} explicitly names a FROM
514     *       relation, not a SELECT alias.</li>
515     *   <li>The resolver's binding lacks definite FROM-scope evidence
516     *       (i.e. {@code !hasDefiniteEvidence()}); the discriminator only
517     *       fires for the heuristic {@code inferred_from_usage} fallback
518     *       in {@code TableNamespace.resolveColumn}.</li>
519     *   <li>Some result column in {@code enclosingSelect}'s result-column
520     *       list exposes the same name (case-insensitive) AND its
521     *       expression is a calculated expression (anything but a simple
522     *       column reference / star).</li>
523     * </ol>
524     *
525     * <p>If multiple result columns share the exposed name and at least
526     * one is calculated, the method returns {@code true} — order-
527     * independent rejection keeps the slice invariant deterministic.
528     *
529     * <p>Classification reuses {@code ColumnSource.isCalculatedColumn()}
530     * by constructing a transient {@code ColumnSource} pinned to the
531     * candidate {@code TResultColumn}; the helper inspects only the
532     * definition-node expression and is independent of namespace state.
533     */
534    @Override
535    public boolean isCalculatedProjectionAliasFallback(TObjectName columnRef,
536                                                       TSelectSqlStatement enclosingSelect) {
537        if (columnRef == null || enclosingSelect == null) {
538            return false;
539        }
540        // Unqualified-only.
541        if (columnRef.getTableToken() != null) {
542            return false;
543        }
544        String columnName = columnRef.getColumnNameOnly();
545        if (columnName == null || columnName.isEmpty()) {
546            return false;
547        }
548        // Definite-evidence guard: skip when the resolver has positive
549        // FROM-scope evidence (DDL / SQLEnv / explicit metadata).
550        ResolutionResult resolution = columnRef.getResolution();
551        if (resolution == null || resolution.getStatus() != ResolutionStatus.EXACT_MATCH) {
552            return false;
553        }
554        ColumnSource source = resolution.getColumnSource();
555        if (source == null) {
556            return false;
557        }
558        if (source.hasDefiniteEvidence()) {
559            return false;
560        }
561        // AST walk: any matching exposed name on a calculated expression?
562        TResultColumnList rcl = enclosingSelect.getResultColumnList();
563        if (rcl == null) {
564            return false;
565        }
566        for (int i = 0; i < rcl.size(); i++) {
567            TResultColumn rc = rcl.getResultColumn(i);
568            if (rc == null) {
569                continue;
570            }
571            String exposed;
572            if (rc.getColumnAlias() != null && !rc.getColumnAlias().isEmpty()) {
573                exposed = rc.getColumnAlias();
574            } else {
575                exposed = rc.getColumnNameOnly();
576            }
577            if (exposed == null || exposed.isEmpty()) {
578                continue;
579            }
580            if (!exposed.equalsIgnoreCase(columnName)) {
581                continue;
582            }
583            // Reuse ColumnSource's calculated-column classification by
584            // pinning the definition node to this result column. The
585            // transient source has no namespace; isCalculatedColumn()
586            // looks only at the definition expression.
587            ColumnSource transientSource = new ColumnSource(
588                    null, exposed, rc, 0.0, "slice19_alias_classifier");
589            if (transientSource.isCalculatedColumn()) {
590                return true;
591            }
592        }
593        return false;
594    }
595}