001package gudusoft.gsqlparser.resolver2.model; 002 003import gudusoft.gsqlparser.nodes.TTable; 004import gudusoft.gsqlparser.resolver2.matcher.DefaultNameMatcher; 005import gudusoft.gsqlparser.resolver2.matcher.INameMatcher; 006import gudusoft.gsqlparser.resolver2.matcher.VendorNameMatcher; 007import gudusoft.gsqlparser.resolver2.namespace.INamespace; 008import gudusoft.gsqlparser.resolver2.scope.GlobalScope; 009import gudusoft.gsqlparser.resolver2.scope.IScope; 010import gudusoft.gsqlparser.resolver2.scope.SelectScope; 011import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 012 013import java.util.LinkedHashMap; 014import java.util.List; 015import java.util.Map; 016 017/** 018 * Index structure for fast lookups in FromScope. 019 * 020 * <p>This class optimizes O(N) linear scans to O(1) hash lookups for common 021 * operations like finding tables by alias/name and finding candidate namespaces 022 * for star column inference.</p> 023 * 024 * <p>The index is built lazily on first access and cached for subsequent lookups. 025 * This avoids the overhead of building the index when it's not needed.</p> 026 * 027 * <p><b>Performance improvement:</b> For queries with N tables and M column references, 028 * reduces complexity from O(N*M) to O(N + M).</p> 029 * 030 * <p><b>Slice S2 — vendor-aware identifier compares.</b> Pre-S2 keys were 031 * built via raw {@link String#toLowerCase()} which broke quoted-sensitive 032 * dialects (Oracle / Postgres / Snowflake / Hive / Teradata quoted) and 033 * BigQuery's case-sensitive table rule. S2 stores aliases / display names 034 * <em>raw</em> (preserving quotes and original case) and routes lookups 035 * through {@link IdentifierService} via the supplied {@link INameMatcher} 036 * with {@link ESQLDataObjectType#dotTable}. The exact-text fast probe 037 * still handles same-as-written queries in O(1); cross-case / quoted-vs- 038 * unquoted lookups fall through to a vendor-aware linear scan. Storing 039 * the raw form is critical: a normalized key would lose quote provenance 040 * — codex round-1 review caught that comparing the stripped-and-folded 041 * stored key against the raw input would false-positive an unquoted 042 * probe against a quoted alias on strict-case dialects.</p> 043 * 044 * @since 3.1.0.9 045 */ 046public class FromScopeIndex { 047 048 /** 049 * Map from <em>raw</em> alias text (with quotes preserved, original 050 * case) to ScopeChild. Lookup uses an exact-text fast probe first; 051 * mismatches fall through to a vendor-aware matcher scan. Storing 052 * raw text preserves quote provenance so {@link IdentifierService} 053 * can decide quoted-vs-unquoted compare authoritatively in the 054 * matcher scan (codex round-1 review). 055 */ 056 private final Map<String, ScopeChild> aliasMap; 057 058 /** 059 * Map from <em>raw</em> namespace display name to ScopeChild. 060 * Used when lookup by alias fails but table name might match. 061 */ 062 private final Map<String, ScopeChild> nameMap; 063 064 /** 065 * First namespace that supports star columns or dynamic inference. 066 * Used for unqualified column resolution when no table prefix is specified. 067 */ 068 private final INamespace preferredStarNamespace; 069 070 /** 071 * Total number of children in the FromScope. 072 */ 073 private final int childCount; 074 075 /** 076 * Name matcher used to normalize keys and to fall back on a vendor-aware 077 * linear scan when the stored key normalization disagrees with the 078 * lookup-time normalization (rare). 079 */ 080 private final INameMatcher nameMatcher; 081 082 /** 083 * Creates an index from the given list of scope children using a 084 * vendor-agnostic {@link DefaultNameMatcher}. Prefer the matcher-aware 085 * constructor; this overload exists for synthetic / unit-test scopes 086 * that have no GlobalScope above them. 087 * 088 * @param children The list of ScopeChild from a FromScope 089 */ 090 public FromScopeIndex(List<ScopeChild> children) { 091 this(children, new DefaultNameMatcher()); 092 } 093 094 /** 095 * Creates an index from the given list of scope children, using the 096 * supplied matcher to normalize alias / table-name keys per-vendor. 097 * 098 * @param children The list of ScopeChild from a FromScope 099 * @param nameMatcher Vendor-aware matcher (typically the GlobalScope's) 100 */ 101 public FromScopeIndex(List<ScopeChild> children, INameMatcher nameMatcher) { 102 this.nameMatcher = nameMatcher != null ? nameMatcher : new DefaultNameMatcher(); 103 // Use LinkedHashMap so the matcher-driven fallback scan (when the 104 // lookup-time normalization disagrees with stored keys, e.g. a 105 // quoted identifier in scope alongside an unquoted one) walks 106 // entries in insertion order, matching the deterministic behaviour 107 // of FromScope.getChildren(). 108 this.aliasMap = new LinkedHashMap<>(); 109 this.nameMap = new LinkedHashMap<>(); 110 this.childCount = children != null ? children.size() : 0; 111 112 INamespace firstStar = null; 113 114 if (children != null) { 115 for (ScopeChild child : children) { 116 // S2: store raw alias / display-name text (quotes preserved). 117 String alias = child.getAlias(); 118 if (alias != null && !alias.isEmpty()) { 119 aliasMap.put(alias, child); 120 } 121 122 // Also index by namespace display name 123 INamespace ns = child.getNamespace(); 124 if (ns != null) { 125 String displayName = ns.getDisplayName(); 126 if (displayName != null && !displayName.isEmpty()) { 127 nameMap.put(displayName, child); 128 } 129 130 // Track first star/dynamic namespace 131 if (firstStar == null && 132 (ns.hasStarColumn() || ns.supportsDynamicInference())) { 133 firstStar = ns; 134 } 135 } 136 } 137 } 138 139 this.preferredStarNamespace = firstStar; 140 } 141 142 /** 143 * Finds a ScopeChild by its alias (vendor-aware). 144 * 145 * @param alias The alias to search for 146 * @return The matching ScopeChild, or null if not found 147 */ 148 public ScopeChild findByAlias(String alias) { 149 if (alias == null || alias.isEmpty()) { 150 return null; 151 } 152 // O(1) exact-text fast probe (same case + same quote state as the 153 // alias was originally written). 154 ScopeChild byExact = aliasMap.get(alias); 155 if (byExact != null) { 156 return byExact; 157 } 158 // Slow-path: vendor-aware matcher scan against raw stored aliases 159 // (with quotes preserved), so {@link IdentifierService} can decide 160 // quoted-vs-unquoted compare authoritatively. 161 return findByMatcher(aliasMap.values(), ScopeChild::getAlias, alias); 162 } 163 164 /** 165 * Finds a ScopeChild by alias first, then by namespace name (vendor-aware). 166 * 167 * @param qualifier The qualifier (alias or table name) to search for 168 * @return The matching ScopeChild, or null if not found 169 */ 170 public ScopeChild findByQualifier(String qualifier) { 171 if (qualifier == null || qualifier.isEmpty()) { 172 return null; 173 } 174 175 ScopeChild result = findByAlias(qualifier); 176 if (result != null) { 177 return result; 178 } 179 180 ScopeChild byNameExact = nameMap.get(qualifier); 181 if (byNameExact != null) { 182 return byNameExact; 183 } 184 return findByMatcher(nameMap.values(), 185 child -> child.getNamespace() != null ? child.getNamespace().getDisplayName() : null, 186 qualifier); 187 } 188 189 /** 190 * Walk the children comparing the raw, quote-preserving identifier 191 * supplied by {@code rawAccessor} against the input via the matcher 192 * with {@link ESQLDataObjectType#dotTable}. 193 * 194 * <p><strong>Invariant:</strong> compare raw alias / display name 195 * (with quotes) — NOT a normalized stored key — so 196 * {@link IdentifierService} can resolve quoted-vs-unquoted semantics 197 * authoritatively. 198 */ 199 private ScopeChild findByMatcher(java.util.Collection<ScopeChild> children, 200 java.util.function.Function<ScopeChild, String> rawAccessor, 201 String input) { 202 if (children.isEmpty()) { 203 return null; 204 } 205 for (ScopeChild child : children) { 206 String storedRaw = rawAccessor.apply(child); 207 if (storedRaw == null || storedRaw.isEmpty()) continue; 208 if (nameMatcher instanceof VendorNameMatcher) { 209 if (((VendorNameMatcher) nameMatcher).matches(storedRaw, input, ESQLDataObjectType.dotTable)) { 210 return child; 211 } 212 } else if (nameMatcher.matches(storedRaw, input)) { 213 return child; 214 } 215 } 216 return null; 217 } 218 219 /** 220 * Gets the TTable from a ScopeChild by qualifier, if the namespace represents a table. 221 * 222 * @param qualifier The qualifier (alias or table name) to search for 223 * @return The TTable if found and the namespace is a table, null otherwise 224 */ 225 public TTable findTableByQualifier(String qualifier) { 226 ScopeChild child = findByAlias(qualifier); 227 if (child != null) { 228 INamespace ns = child.getNamespace(); 229 if (ns != null && ns.getNode() instanceof TTable) { 230 return (TTable) ns.getNode(); 231 } 232 } 233 return null; 234 } 235 236 /** 237 * Gets the first namespace that supports star columns or dynamic inference. 238 * Used for resolving unqualified column references. 239 * 240 * @return The preferred star namespace, or null if none exists 241 */ 242 public INamespace getPreferredStarNamespace() { 243 return preferredStarNamespace; 244 } 245 246 /** 247 * Finds a candidate namespace for column resolution. 248 * 249 * <p>If a table prefix is provided, looks up by that prefix. 250 * Otherwise, returns the first star/dynamic namespace for unqualified columns.</p> 251 * 252 * @param tablePrefix The table prefix (alias or name), or null for unqualified columns 253 * @return The candidate namespace, or null if not found 254 */ 255 public INamespace findCandidateNamespace(String tablePrefix) { 256 if (tablePrefix != null && !tablePrefix.isEmpty()) { 257 // Qualified reference - find by prefix 258 ScopeChild child = findByQualifier(tablePrefix); 259 if (child != null) { 260 return child.getNamespace(); 261 } 262 return null; 263 } else { 264 // Unqualified reference - use star namespace 265 return preferredStarNamespace; 266 } 267 } 268 269 /** 270 * Gets the total number of children indexed. 271 * 272 * @return The child count 273 */ 274 public int getChildCount() { 275 return childCount; 276 } 277 278 /** 279 * Checks if the index is empty. 280 * 281 * @return true if no children are indexed 282 */ 283 public boolean isEmpty() { 284 return childCount == 0; 285 } 286 287 /** 288 * Creates an index from a scope, extracting the FromScope if needed. 289 * Walks the scope chain to find the {@link GlobalScope}'s name matcher 290 * so vendor-specific identifier rules apply (S2). 291 * 292 * @param scope The scope (SelectScope, UpdateScope, or FromScope) 293 * @return A new FromScopeIndex, or null if no FromScope is found 294 */ 295 public static FromScopeIndex fromScope(IScope scope) { 296 if (scope == null) { 297 return null; 298 } 299 300 IScope fromScope = null; 301 302 if (scope instanceof SelectScope) { 303 fromScope = ((SelectScope) scope).getFromScope(); 304 } else if (scope instanceof gudusoft.gsqlparser.resolver2.scope.UpdateScope) { 305 fromScope = ((gudusoft.gsqlparser.resolver2.scope.UpdateScope) scope).getFromScope(); 306 } else if (scope instanceof gudusoft.gsqlparser.resolver2.scope.FromScope) { 307 fromScope = scope; 308 } 309 310 if (fromScope == null) { 311 return null; 312 } 313 314 return new FromScopeIndex(fromScope.getChildren(), findNameMatcher(fromScope)); 315 } 316 317 /** 318 * Walk the parent chain to find the {@link GlobalScope}'s name matcher. 319 */ 320 private static INameMatcher findNameMatcher(IScope cursor) { 321 while (cursor != null) { 322 if (cursor instanceof GlobalScope) { 323 INameMatcher m = ((GlobalScope) cursor).getNameMatcher(); 324 if (m != null) { 325 return m; 326 } 327 break; 328 } 329 IScope next = cursor.getParent(); 330 if (next == cursor) break; 331 cursor = next; 332 } 333 return new DefaultNameMatcher(); 334 } 335 336 @Override 337 public String toString() { 338 return String.format("FromScopeIndex(aliases=%d, names=%d, hasStar=%b)", 339 aliasMap.size(), nameMap.size(), preferredStarNamespace != null); 340 } 341}