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    public UsingScope(Map<String, List<MergedKeyEntry>> entriesByName,
094                      Map<String, String> ambiguityReasonByName) {
095        Map<String, List<MergedKeyEntry>> entriesCopy =
096                new LinkedHashMap<>();
097        for (Map.Entry<String, List<MergedKeyEntry>> e : entriesByName.entrySet()) {
098            String key = e.getKey() == null ? null : e.getKey().toLowerCase(Locale.ROOT);
099            if (key == null || key.isEmpty() || e.getValue() == null || e.getValue().isEmpty()) {
100                continue;
101            }
102            entriesCopy.put(key, Collections.unmodifiableList(new ArrayList<>(e.getValue())));
103        }
104        this.entriesByName = Collections.unmodifiableMap(entriesCopy);
105        Map<String, String> reasonCopy = new HashMap<>();
106        for (Map.Entry<String, String> e : ambiguityReasonByName.entrySet()) {
107            String key = e.getKey() == null ? null : e.getKey().toLowerCase(Locale.ROOT);
108            if (key == null || key.isEmpty() || e.getValue() == null) continue;
109            reasonCopy.put(key, e.getValue());
110        }
111        this.ambiguityReasonByName = Collections.unmodifiableMap(reasonCopy);
112    }
113
114    /** Convenience constructor for empty-ambiguity scopes. */
115    private UsingScope(Map<String, List<MergedKeyEntry>> entriesByName,
116                       boolean noAmbiguity) {
117        this(entriesByName, Collections.<String, String>emptyMap());
118    }
119
120    /** True iff {@code name} matches a USING key in this scope (case-insensitive). */
121    public boolean has(String name) {
122        if (name == null) return false;
123        return entriesByName.containsKey(name.toLowerCase(Locale.ROOT));
124    }
125
126    /**
127     * True iff a bare unqualified {@code name} reference is ambiguous —
128     * either because two disconnected USING classes carry the same key
129     * name, or because catalog declares the same column on a relation
130     * outside the (single) equivalence class.
131     */
132    public boolean isAmbiguous(String name) {
133        if (name == null) return false;
134        return ambiguityReasonByName.containsKey(name.toLowerCase(Locale.ROOT));
135    }
136
137    /** Diagnostic text for an ambiguous name. Returns {@code null} when not ambiguous. */
138    public String ambiguityReason(String name) {
139        if (name == null) return null;
140        return ambiguityReasonByName.get(name.toLowerCase(Locale.ROOT));
141    }
142
143    /** All entries for a USING key. Empty when {@code !has(name)}. */
144    public List<MergedKeyEntry> entriesFor(String name) {
145        if (name == null) return Collections.emptyList();
146        List<MergedKeyEntry> entries = entriesByName.get(name.toLowerCase(Locale.ROOT));
147        return entries == null ? Collections.<MergedKeyEntry>emptyList() : entries;
148    }
149
150    /**
151     * Returns the {@link MergedKeyEntry} whose equivalence class
152     * contains {@code table} by object identity, or {@code null} if
153     * {@code table} is not in any class for {@code name}.
154     *
155     * <p>{@code TTable} instances do not override {@code equals}, so
156     * identity check is the only safe membership test.
157     */
158    public MergedKeyEntry entryContaining(String name, TTable table) {
159        if (name == null || table == null) return null;
160        List<MergedKeyEntry> entries = entriesByName.get(name.toLowerCase(Locale.ROOT));
161        if (entries == null) return null;
162        for (MergedKeyEntry entry : entries) {
163            for (TTable member : entry.getEquivClass().getMembers()) {
164                if (member == table) return entry;
165            }
166        }
167        return null;
168    }
169
170    /**
171     * Flattened sources of the SINGLE entry for {@code name}.
172     *
173     * @throws IllegalStateException when the scope has zero or multiple
174     *         entries for {@code name} — callers must check
175     *         {@link #has} and {@link #isAmbiguous} first.
176     */
177    public List<ColumnRef> mergedSourcesFor(String name) {
178        if (name == null) {
179            throw new IllegalArgumentException("name must not be null");
180        }
181        List<MergedKeyEntry> entries = entriesByName.get(name.toLowerCase(Locale.ROOT));
182        if (entries == null || entries.isEmpty()) {
183            throw new IllegalStateException(
184                    "no merged-key entries for '" + name + "' (caller should check has() first)");
185        }
186        if (entries.size() > 1) {
187            throw new IllegalStateException(
188                    "multiple disconnected equivalence classes for '" + name
189                            + "' (caller should check isAmbiguous() first)");
190        }
191        return entries.get(0).getSources();
192    }
193
194    /** Whether this scope has any merged-key entries. */
195    public boolean isEmpty() {
196        return entriesByName.isEmpty();
197    }
198}