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}