001package gudusoft.gsqlparser.ir.semantic.binding;
002
003import gudusoft.gsqlparser.ir.semantic.ColumnRef;
004import gudusoft.gsqlparser.nodes.TTable;
005
006import java.util.ArrayList;
007import java.util.Collections;
008import java.util.HashMap;
009import java.util.IdentityHashMap;
010import java.util.LinkedHashMap;
011import java.util.List;
012import java.util.Locale;
013import java.util.Map;
014
015/**
016 * Slice 65 — per-{@code SELECT} merged-key scope for {@code JOIN ... USING}.
017 *
018 * <p>Slice 64 admitted {@code JOIN ... USING (k)} at the predicate side
019 * ({@code joinColumnRefs}). Slice 65 lifts the output / non-JOIN-ON
020 * clause side: unqualified references to a USING merged key (in
021 * SELECT / WHERE / GROUP BY / HAVING / ORDER BY) resolve to the merged
022 * source list of every relation in the USING(k) equivalence class.
023 *
024 * <p>Equivalence classes are per-key (DSU over USING joins); multiple
025 * disconnected classes can exist for the same key name. {@link #has}
026 * tells whether a name is a USING key; {@link #isAmbiguous} signals
027 * either disconnected-class ambiguity or an out-of-class same-named
028 * column (caller throws with {@link #ambiguityReason}).
029 *
030 * <p>Each {@code SemanticIRBuilder.buildSelectStatementImpl} invocation
031 * resets its provider's using scope to {@link #EMPTY} at entry, then
032 * optionally narrows the provider with its own scope. This prevents an
033 * enclosing SELECT's USING from leaking into recursive nested builds
034 * (predicate-subquery bodies, scalar-subquery bodies, set-op branch
035 * bodies, CTE bodies, FROM-subquery bodies).
036 */
037public final class UsingScope {
038
039    public static final UsingScope EMPTY = new UsingScope(
040            Collections.<String, List<MergedKeyEntry>>emptyMap(),
041            Collections.<String, String>emptyMap());
042
043    /**
044     * One equivalence class of FROM-clause relations connected by a
045     * specific USING key.
046     */
047    public static final class EquivalenceClass {
048        private final String key;
049        private final List<TTable> members;
050
051        public EquivalenceClass(String key, List<TTable> members) {
052            if (key == null || key.isEmpty()) {
053                throw new IllegalArgumentException("key must not be null/empty");
054            }
055            if (members == null || members.isEmpty()) {
056                throw new IllegalArgumentException(
057                        "equivalence class must have at least one member");
058            }
059            this.key = key.toLowerCase(Locale.ROOT);
060            this.members = Collections.unmodifiableList(new ArrayList<>(members));
061        }
062
063        public String getKey() { return key; }
064        public List<TTable> getMembers() { return members; }
065    }
066
067    /**
068     * One merged-key entry: a class and its FROM-ordered merged source
069     * column refs (narrowed by catalog when available).
070     */
071    public static final class MergedKeyEntry {
072        private final EquivalenceClass equivClass;
073        private final List<ColumnRef> sources;
074
075        public MergedKeyEntry(EquivalenceClass equivClass, List<ColumnRef> sources) {
076            if (equivClass == null) {
077                throw new IllegalArgumentException("equivClass must not be null");
078            }
079            if (sources == null) {
080                throw new IllegalArgumentException("sources must not be null");
081            }
082            this.equivClass = equivClass;
083            this.sources = Collections.unmodifiableList(new ArrayList<>(sources));
084        }
085
086        public EquivalenceClass getEquivClass() { return equivClass; }
087        public List<ColumnRef> getSources() { return sources; }
088    }
089
090    private final Map<String, List<MergedKeyEntry>> entriesByName;
091    private final Map<String, String> ambiguityReasonByName;
092    /**
093     * Slice — catalog-less {@code NATURAL JOIN} degrade fallback. Ordered
094     * (FROM-order) list of relation aliases that participate in a
095     * NATURAL join whose shared-key set could not be computed (no catalog
096     * metadata). Under NATURAL semantics the join keys merge into a single
097     * output column, so an unqualified reference to such a column is
098     * unambiguous; but without a catalog the binder can't enumerate the
099     * keys to pick a side. When this list is non-empty, an unqualified
100     * column reference that fails to bind is resolved deterministically to
101     * the left-side (first) relation rather than rejected as
102     * {@code COLUMN_BINDING_NON_EXACT} — honoring the degrade-not-fail
103     * principle (GSP R6). Empty for non-NATURAL FROM clauses, so genuine
104     * unqualified ambiguity in plain joins still errors.
105     */
106    private final List<String> naturalDegradeRelations;
107
108    public UsingScope(Map<String, List<MergedKeyEntry>> entriesByName,
109                      Map<String, String> ambiguityReasonByName) {
110        this(entriesByName, ambiguityReasonByName,
111                Collections.<String>emptyList());
112    }
113
114    public UsingScope(Map<String, List<MergedKeyEntry>> entriesByName,
115                      Map<String, String> ambiguityReasonByName,
116                      List<String> naturalDegradeRelations) {
117        Map<String, List<MergedKeyEntry>> entriesCopy =
118                new LinkedHashMap<>();
119        for (Map.Entry<String, List<MergedKeyEntry>> e : entriesByName.entrySet()) {
120            String key = e.getKey() == null ? null : e.getKey().toLowerCase(Locale.ROOT);
121            if (key == null || key.isEmpty() || e.getValue() == null || e.getValue().isEmpty()) {
122                continue;
123            }
124            entriesCopy.put(key, Collections.unmodifiableList(new ArrayList<>(e.getValue())));
125        }
126        this.entriesByName = Collections.unmodifiableMap(entriesCopy);
127        Map<String, String> reasonCopy = new HashMap<>();
128        for (Map.Entry<String, String> e : ambiguityReasonByName.entrySet()) {
129            String key = e.getKey() == null ? null : e.getKey().toLowerCase(Locale.ROOT);
130            if (key == null || key.isEmpty() || e.getValue() == null) continue;
131            reasonCopy.put(key, e.getValue());
132        }
133        this.ambiguityReasonByName = Collections.unmodifiableMap(reasonCopy);
134        List<String> degradeCopy = new ArrayList<>();
135        if (naturalDegradeRelations != null) {
136            for (String a : naturalDegradeRelations) {
137                if (a != null && !a.isEmpty()) degradeCopy.add(a);
138            }
139        }
140        this.naturalDegradeRelations = Collections.unmodifiableList(degradeCopy);
141    }
142
143    /** Convenience constructor for empty-ambiguity scopes. */
144    private UsingScope(Map<String, List<MergedKeyEntry>> entriesByName,
145                       boolean noAmbiguity) {
146        this(entriesByName, Collections.<String, String>emptyMap());
147    }
148
149    /** True iff {@code name} matches a USING key in this scope (case-insensitive). */
150    public boolean has(String name) {
151        if (name == null) return false;
152        return entriesByName.containsKey(name.toLowerCase(Locale.ROOT));
153    }
154
155    /**
156     * True iff a bare unqualified {@code name} reference is ambiguous —
157     * either because two disconnected USING classes carry the same key
158     * name, or because catalog declares the same column on a relation
159     * outside the (single) equivalence class.
160     */
161    public boolean isAmbiguous(String name) {
162        if (name == null) return false;
163        return ambiguityReasonByName.containsKey(name.toLowerCase(Locale.ROOT));
164    }
165
166    /** Diagnostic text for an ambiguous name. Returns {@code null} when not ambiguous. */
167    public String ambiguityReason(String name) {
168        if (name == null) return null;
169        return ambiguityReasonByName.get(name.toLowerCase(Locale.ROOT));
170    }
171
172    /** All entries for a USING key. Empty when {@code !has(name)}. */
173    public List<MergedKeyEntry> entriesFor(String name) {
174        if (name == null) return Collections.emptyList();
175        List<MergedKeyEntry> entries = entriesByName.get(name.toLowerCase(Locale.ROOT));
176        return entries == null ? Collections.<MergedKeyEntry>emptyList() : entries;
177    }
178
179    /**
180     * Returns the {@link MergedKeyEntry} whose equivalence class
181     * contains {@code table} by object identity, or {@code null} if
182     * {@code table} is not in any class for {@code name}.
183     *
184     * <p>{@code TTable} instances do not override {@code equals}, so
185     * identity check is the only safe membership test.
186     */
187    public MergedKeyEntry entryContaining(String name, TTable table) {
188        if (name == null || table == null) return null;
189        List<MergedKeyEntry> entries = entriesByName.get(name.toLowerCase(Locale.ROOT));
190        if (entries == null) return null;
191        for (MergedKeyEntry entry : entries) {
192            for (TTable member : entry.getEquivClass().getMembers()) {
193                if (member == table) return entry;
194            }
195        }
196        return null;
197    }
198
199    /**
200     * Flattened sources of the SINGLE entry for {@code name}.
201     *
202     * @throws IllegalStateException when the scope has zero or multiple
203     *         entries for {@code name} — callers must check
204     *         {@link #has} and {@link #isAmbiguous} first.
205     */
206    public List<ColumnRef> mergedSourcesFor(String name) {
207        if (name == null) {
208            throw new IllegalArgumentException("name must not be null");
209        }
210        List<MergedKeyEntry> entries = entriesByName.get(name.toLowerCase(Locale.ROOT));
211        if (entries == null || entries.isEmpty()) {
212            throw new IllegalStateException(
213                    "no merged-key entries for '" + name + "' (caller should check has() first)");
214        }
215        if (entries.size() > 1) {
216            throw new IllegalStateException(
217                    "multiple disconnected equivalence classes for '" + name
218                            + "' (caller should check isAmbiguous() first)");
219        }
220        return entries.get(0).getSources();
221    }
222
223    /**
224     * Whether this scope carries no information at all — neither merged-key
225     * entries nor a NATURAL-degrade fallback. Callers install the scope on
226     * the provider only when this is {@code false}.
227     */
228    public boolean isEmpty() {
229        return entriesByName.isEmpty() && naturalDegradeRelations.isEmpty();
230    }
231
232    /**
233     * True iff this scope carries at least one merged-key equivalence class —
234     * i.e. the FROM clause has a {@code JOIN ... USING} (or a catalog-resolved
235     * {@code NATURAL JOIN}) that produced merged keys. Distinct from
236     * {@link #hasNaturalDegrade()}, which covers the catalog-less NATURAL case
237     * where no keys could be enumerated. Used to anchor the degrade of an
238     * otherwise-fatal unqualified column-binding miss: a USING join is a
239     * structural reason an unqualified non-key column cannot be placed without
240     * a catalog, so the analysis degrades rather than aborts.
241     */
242    public boolean hasMergedKeys() {
243        return !entriesByName.isEmpty();
244    }
245
246    /**
247     * True iff this FROM clause contains a catalog-less {@code NATURAL JOIN}
248     * whose shared keys could not be enumerated, enabling the degrade
249     * fallback for unqualified column references. See
250     * {@link #naturalDegradeRelations}.
251     */
252    public boolean hasNaturalDegrade() {
253        return !naturalDegradeRelations.isEmpty();
254    }
255
256    /**
257     * The deterministic left-side relation alias to which an unbindable
258     * unqualified reference degrades, or {@code null} when there is no
259     * NATURAL-degrade fallback. This is the first (leftmost in FROM order)
260     * NATURAL-join relation.
261     */
262    public String naturalDegradeFallbackAlias() {
263        return naturalDegradeRelations.isEmpty()
264                ? null : naturalDegradeRelations.get(0);
265    }
266}