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}