001package gudusoft.gsqlparser.catalog.runtime; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnostic; 005import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnosticCode; 006import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnosticSeverity; 007import gudusoft.gsqlparser.catalog.diagnostic.CatalogException; 008import gudusoft.gsqlparser.catalog.input.CatalogLoadingMode; 009import gudusoft.gsqlparser.catalog.input.model.DefaultsConfig; 010import gudusoft.gsqlparser.catalog.input.model.IdentifierConfig; 011 012import java.util.ArrayList; 013import java.util.Collections; 014import java.util.List; 015import java.util.Optional; 016 017/** 018 * Top-level runtime: owns the active {@link CatalogProvider}, holds the current 019 * {@link CatalogSnapshot}, exposes the {@link CatalogResolver}, and manages the mutable 020 * {@link CatalogOverlay}. 021 * 022 * <p>Plan §5.1 / §5.4 / §7.2. Resolver lookup precedence:</p> 023 * <ol> 024 * <li>{@link CatalogOverlay} — DDL discovered mid-batch, temp tables, addTable.</li> 025 * <li>{@link CatalogSnapshot} — immutable snapshot from the last 026 * {@link CatalogProvider#snapshot(CatalogQuery)} call.</li> 027 * <li>Provider on-miss fetch (LAZY/AUTO modes only) — {@code provider.snapshot(...)} 028 * is asked for the missing name and the result merged into the cached snapshot.</li> 029 * </ol> 030 * 031 * <p>Read-only consumers (resolver, future SQLGuard, future Semantic IR) target the 032 * snapshot/resolver; live sources implement {@link CatalogProvider}. The runtime is 033 * single-threaded by default; concurrent parsers should use one runtime per analysis run.</p> 034 */ 035public final class CatalogRuntime implements AutoCloseable { 036 037 private final CatalogProvider provider; 038 private final EDbVendor vendor; 039 private final CatalogLoadingMode loadingMode; 040 private final long ttlMillis; 041 private final int maxFetchesPerAnalysis; 042 private final boolean includeColumns; 043 private final boolean includeViews; 044 private final boolean includeRoutines; 045 private final InMemoryCatalogOverlay overlay = new InMemoryCatalogOverlay(); 046 /** Accumulated lazy fetch hits. Separate from {@link #snapshot} so a partial provider 047 * return from a single name lookup does not discard previously cached lazy results. */ 048 private final InMemoryCatalogOverlay lazyCache = new InMemoryCatalogOverlay(); 049 /** Children captured during a lazy provider fetch. Indexed by parent id → kind → 050 * list of child entries. Populated by the lazy resolver path so the bridge can 051 * materialize columns / partitions / etc. when the legacy resolver asks for them. */ 052 private final java.util.Map<CatalogObjectId, 053 java.util.Map<CatalogObjectKind, List<CatalogEntry>>> lazyChildren = 054 new java.util.HashMap<CatalogObjectId, 055 java.util.Map<CatalogObjectKind, List<CatalogEntry>>>(); 056 private final ResolverImpl resolver = new ResolverImpl(); 057 058 private CatalogSnapshot snapshot; 059 private int fetches; 060 061 private CatalogRuntime(Builder b) { 062 if (b.provider == null) { 063 throw new IllegalArgumentException("CatalogRuntime.provider is required"); 064 } 065 if (b.vendor == null) { 066 throw new IllegalArgumentException("CatalogRuntime.vendor is required"); 067 } 068 this.provider = b.provider; 069 this.vendor = b.vendor; 070 this.loadingMode = b.loadingMode != null ? b.loadingMode : CatalogLoadingMode.EAGER; 071 this.ttlMillis = b.ttlMillis; 072 this.maxFetchesPerAnalysis = b.maxFetchesPerAnalysis; 073 this.includeColumns = b.includeColumns; 074 this.includeViews = b.includeViews; 075 this.includeRoutines = b.includeRoutines; 076 if (b.initialSnapshot != null) { 077 this.snapshot = b.initialSnapshot; 078 } 079 } 080 081 public static Builder builder() { 082 return new Builder(); 083 } 084 085 public CatalogProvider provider() { 086 return provider; 087 } 088 089 public CatalogOverlay overlay() { 090 return overlay; 091 } 092 093 public CatalogResolver resolver() { 094 return resolver; 095 } 096 097 public EDbVendor vendor() { 098 return vendor; 099 } 100 101 public CatalogLoadingMode loadingMode() { 102 return loadingMode; 103 } 104 105 /** 106 * Current cached snapshot. May be {@code null} until {@link #snapshot(CatalogQuery)} 107 * or {@link #refresh(CatalogQuery)} is called. 108 */ 109 public CatalogSnapshot snapshot() { 110 return snapshot; 111 } 112 113 /** 114 * Eagerly request a snapshot for the given query and cache it. EAGER and LAZY modes 115 * both hit this method — the difference is whether the query has explicit 116 * {@code requestedNames} or not. Clears the lazy fetch cache because previously 117 * fetched per-name results may now be stale relative to the new snapshot. 118 */ 119 public CatalogSnapshot snapshot(CatalogQuery query) { 120 this.snapshot = provider.snapshot(query); 121 this.lazyCache.clear(); 122 this.lazyChildren.clear(); 123 return this.snapshot; 124 } 125 126 /** 127 * Bypass any cached snapshot and force a fresh fetch. Also clears the lazy fetch 128 * cache so previously cached per-name lookups do not mask the refreshed data. 129 */ 130 public CatalogSnapshot refresh(CatalogQuery query) { 131 this.snapshot = provider.refresh(query); 132 this.lazyCache.clear(); 133 this.lazyChildren.clear(); 134 return this.snapshot; 135 } 136 137 /** 138 * Reset per-analysis state: clears the overlay, the lazy fetch cache, and the 139 * fetch counter. Snapshot is preserved across batches by default; callers that 140 * want a clean slate should also call {@link #refresh(CatalogQuery)}. 141 */ 142 public void resetForNewAnalysis() { 143 overlay.clear(); 144 lazyCache.clear(); 145 lazyChildren.clear(); 146 fetches = 0; 147 } 148 149 /** 150 * Walk overlay → lazy cache → snapshot in resolver-precedence order and return the 151 * first matching {@link CatalogEntry}. Used by the legacy {@code TSQLEnv} bridge 152 * after a successful {@link CatalogResolver#resolve} to recover the underlying entry 153 * (the binding alone does not carry properties+children). Returns {@code null} when 154 * no layer holds the entry. 155 */ 156 public CatalogEntry findEntry(CatalogQualifiedName name, CatalogObjectKind kind) { 157 if (name == null || kind == null) { 158 return null; 159 } 160 Optional<CatalogEntry> hit = overlay.find(name, kind); 161 if (hit.isPresent()) return hit.get(); 162 hit = lazyCache.find(name, kind); 163 if (hit.isPresent()) return hit.get(); 164 if (snapshot != null) { 165 hit = snapshot.find(name, kind); 166 if (hit.isPresent()) return hit.get(); 167 } 168 return null; 169 } 170 171 /** 172 * Children of {@code parent} restricted to {@code kind}, drawing from the lazy 173 * children map captured at fetch time and (additively) the cached snapshot. Used by 174 * the bridge to materialize columns when the legacy resolver asks for a table/view 175 * via {@link gudusoft.gsqlparser.sqlenv.catalog.ICatalogProvider#findObject}; the 176 * lazy resolver path preserves children at fetch time so the bridge can attach them 177 * to the materialized {@code TSQLTable}. 178 */ 179 public List<CatalogEntry> findChildren(CatalogObjectId parent, CatalogObjectKind kind) { 180 if (parent == null || kind == null) { 181 return java.util.Collections.emptyList(); 182 } 183 List<CatalogEntry> out = new ArrayList<CatalogEntry>(); 184 java.util.Map<CatalogObjectKind, List<CatalogEntry>> kids = lazyChildren.get(parent); 185 if (kids != null) { 186 List<CatalogEntry> fromLazy = kids.get(kind); 187 if (fromLazy != null) { 188 out.addAll(fromLazy); 189 } 190 } 191 if (snapshot != null) { 192 out.addAll(snapshot.children(parent, kind)); 193 } 194 return out; 195 } 196 197 @Override 198 public void close() { 199 try { 200 provider.close(); 201 } catch (CatalogException ignored) { 202 // The provider's close() contract permits CatalogException; nothing useful 203 // we can do at runtime shutdown beyond swallowing it. Adapters that need 204 // teardown diagnostics should surface them via their own channel. 205 } 206 } 207 208 private final class ResolverImpl implements CatalogResolver { 209 210 @Override 211 public CatalogResolutionResult resolve(CatalogContext ctx, CatalogQualifiedName name) { 212 if (ctx == null || name == null) { 213 return CatalogResolutionResult.miss(Collections.<CatalogDiagnostic>emptyList()); 214 } 215 CatalogObjectKind kind = name.kind(); 216 // Build candidate fully-qualified names per plan §9.3: defaults fill missing 217 // catalog/schema, then the search path widens further. Walking candidates lets 218 // overlay/snapshot/lazy/provider all see the same expansion ladder. 219 List<CatalogQualifiedName> candidates = expandCandidates(name, ctx); 220 // Plan §9.3: a TABLE lookup must also try the snapshot's VIEW / MATERIALIZED_VIEW 221 // buckets — the legacy bridge path's ESQLDataObjectType has dotTable but no 222 // view type, so a relation reference is always asked as TABLE. 223 List<CatalogObjectKind> kinds = widenKinds(kind); 224 225 // Phase 1: overlay. 226 for (CatalogQualifiedName candidate : candidates) { 227 for (CatalogObjectKind k : kinds) { 228 Optional<CatalogEntry> hit = overlay.find(candidate.withKind(k), k); 229 if (hit.isPresent()) { 230 return CatalogResolutionResult.ok(CatalogBindings.of(hit.get())); 231 } 232 } 233 } 234 // Phase 2: lazy cache (previously fetched on-miss results). 235 for (CatalogQualifiedName candidate : candidates) { 236 for (CatalogObjectKind k : kinds) { 237 Optional<CatalogEntry> hit = lazyCache.find(candidate.withKind(k), k); 238 if (hit.isPresent()) { 239 return CatalogResolutionResult.ok(CatalogBindings.of(hit.get())); 240 } 241 } 242 } 243 // Phase 3: cached snapshot. 244 if (snapshot != null) { 245 for (CatalogQualifiedName candidate : candidates) { 246 for (CatalogObjectKind k : kinds) { 247 Optional<CatalogEntry> hit = snapshot.find(candidate.withKind(k), k); 248 if (hit.isPresent()) { 249 return CatalogResolutionResult.ok(CatalogBindings.of(hit.get())); 250 } 251 } 252 } 253 } 254 // Phase 4: provider on-miss fetch (LAZY/AUTO only). 255 if (loadingMode != CatalogLoadingMode.EAGER) { 256 List<CatalogDiagnostic> diags = new ArrayList<CatalogDiagnostic>(); 257 if (maxFetchesPerAnalysis > 0 && fetches >= maxFetchesPerAnalysis) { 258 // Count the attempt against the cap so the WARN→ERROR escalation can 259 // actually trigger. Without this increment, fetches stays pinned at 260 // maxFetchesPerAnalysis and the 2× threshold is never reached. 261 fetches++; 262 diags.add(CatalogDiagnostic.builder() 263 .severity(fetches >= 2 * maxFetchesPerAnalysis 264 ? CatalogDiagnosticSeverity.ERROR : CatalogDiagnosticSeverity.WARN) 265 .code(CatalogDiagnosticCode.CATALOG_RUNTIME_FETCH_LIMIT_EXCEEDED) 266 .message("CatalogRuntime fetch cap (" + maxFetchesPerAnalysis 267 + ") exceeded for name=" + name) 268 .name(name) 269 .build()); 270 return CatalogResolutionResult.miss(diags); 271 } 272 CatalogQuery.Builder qb = CatalogQuery.builder() 273 .vendor(vendor) 274 .defaults(DefaultsConfig.builder() 275 .defaultCatalog(ctx.activeCatalog()) 276 .defaultSchema(ctx.activeSchema()) 277 .build()) 278 .searchPath(ctx.searchPath()) 279 .addRequestedKind(kind) 280 .includeColumns(includeColumns) 281 .includeViews(includeViews) 282 .includeRoutines(includeRoutines) 283 .loadingMode(loadingMode) 284 .ttlMillis(ttlMillis) 285 .maxFetchesPerAnalysis(maxFetchesPerAnalysis); 286 for (CatalogQualifiedName candidate : candidates) { 287 qb.addRequestedName(candidate); 288 } 289 fetches++; 290 CatalogSnapshot fetched; 291 try { 292 fetched = provider.snapshot(qb.build()); 293 } catch (CatalogException ex) { 294 diags.add(CatalogDiagnostic.builder() 295 .severity(CatalogDiagnosticSeverity.WARN) 296 .code(CatalogDiagnosticCode.CATALOG_RUNTIME_FETCH_FAILED) 297 .message("Provider fetch failed for name=" + name + ": " + ex.getMessage()) 298 .name(name) 299 .build()); 300 return CatalogResolutionResult.miss(diags); 301 } 302 // Carry over the fetched snapshot's own diagnostics. Provider-level 303 // build/load issues (recorded by the snapshot builder, e.g. unsupported- 304 // kind INFOs from the static-file readers) are otherwise dropped on the 305 // floor before they can reach the bridge's diagnostic sink. 306 List<CatalogDiagnostic> fetchedDiags = fetched.diagnostics(); 307 if (fetchedDiags != null && !fetchedDiags.isEmpty()) { 308 diags.addAll(fetchedDiags); 309 } 310 // Walk candidates over the fetched snapshot; first hit wins. Any hit is 311 // also folded into lazyCache so a subsequent resolve for a different 312 // name does not lose this one when the provider returns only its 313 // requested entries. Walk widened kinds too so a relation referenced as 314 // TABLE matches a VIEW or MATERIALIZED_VIEW entry. 315 for (CatalogQualifiedName candidate : candidates) { 316 for (CatalogObjectKind k : kinds) { 317 Optional<CatalogEntry> hit = fetched.find(candidate.withKind(k), k); 318 if (hit.isPresent()) { 319 CatalogEntry entry = hit.get(); 320 lazyCache.put(entry); 321 // Preserve children from the fetched snapshot so the bridge can 322 // materialize columns/partitions/etc. when the legacy resolver 323 // asks for the same name. The fetched snapshot itself is 324 // discarded after this resolve, so children must be copied to 325 // an indexed cache that survives. 326 captureLazyChildren(fetched, entry); 327 return CatalogResolutionResult.ok(CatalogBindings.of(entry), diags); 328 } 329 } 330 } 331 diags.add(CatalogDiagnostic.builder() 332 .severity(CatalogDiagnosticSeverity.INFO) 333 .code(CatalogDiagnosticCode.CATALOG_RUNTIME_PARTIAL_RESULT) 334 .message("Provider returned no entry for name=" + name + ", kind=" + kind) 335 .name(name) 336 .build()); 337 return CatalogResolutionResult.miss(diags); 338 } 339 return CatalogResolutionResult.miss(Collections.<CatalogDiagnostic>emptyList()); 340 } 341 342 /** 343 * Build the candidate-name ladder per plan §9.3: original first, then 344 * {@code activeSchema.name}, then {@code activeCatalog.activeSchema.name}, then 345 * each entry of the search path. Two-segment inputs only get a catalog prefix. 346 * Three-or-more segment inputs are treated as already fully qualified. 347 */ 348 private List<CatalogQualifiedName> expandCandidates(CatalogQualifiedName name, 349 CatalogContext ctx) { 350 List<CatalogQualifiedName> out = new ArrayList<CatalogQualifiedName>(); 351 out.add(name); 352 int segs = name.size(); 353 if (segs >= 3) { 354 return out; 355 } 356 String catalog = nullIfEmpty(ctx.activeCatalog()); 357 String schema = nullIfEmpty(ctx.activeSchema()); 358 IdentifierConfig cfg = ctx.identifierConfig(); 359 String localRaw = name.raw().get(name.size() - 1); 360 CatalogObjectKind kind = name.kind(); 361 362 if (segs == 1) { 363 if (schema != null) { 364 addCandidate(out, schema + "." + localRaw, kind, cfg); 365 if (catalog != null) { 366 addCandidate(out, catalog + "." + schema + "." + localRaw, kind, cfg); 367 } 368 } 369 // Schemaless dialects (MySQL, Hive, Teradata, Impala — see 370 // TSQLEnv.supportSchema) materialize tables as catalog.table; an 371 // unqualified lookup must therefore also try catalog.localRaw with 372 // no schema in the middle. 373 if (catalog != null && (schema == null || schema.isEmpty())) { 374 addCandidate(out, catalog + "." + localRaw, kind, cfg); 375 } 376 for (String sp : ctx.searchPath().segments()) { 377 if (sp == null || sp.isEmpty()) continue; 378 addCandidate(out, sp + "." + localRaw, kind, cfg); 379 if (catalog != null) { 380 addCandidate(out, catalog + "." + sp + "." + localRaw, kind, cfg); 381 } 382 } 383 } else if (segs == 2) { 384 String maybeSchema = name.raw().get(0); 385 if (catalog != null) { 386 addCandidate(out, catalog + "." + maybeSchema + "." + localRaw, kind, cfg); 387 } 388 } 389 return out; 390 } 391 392 private void addCandidate(List<CatalogQualifiedName> out, String raw, 393 CatalogObjectKind kind, IdentifierConfig cfg) { 394 try { 395 out.add(CatalogIdentifierPolicy.parse(raw, kind, cfg, vendor)); 396 } catch (IllegalArgumentException ignored) { 397 // Skip malformed candidates — they aren't valid names anyway. 398 } 399 } 400 401 private String nullIfEmpty(String s) { 402 return (s == null || s.isEmpty()) ? null : s; 403 } 404 405 /** 406 * Plan §9.3 widened kind matrix: a relation reference parsed as 407 * {@link CatalogObjectKind#TABLE TABLE} should also see VIEW and 408 * MATERIALIZED_VIEW entries, since the legacy bridge path always asks for 409 * relations as TABLE. Other kinds are returned as-is. 410 */ 411 private List<CatalogObjectKind> widenKinds(CatalogObjectKind kind) { 412 if (kind == CatalogObjectKind.TABLE) { 413 List<CatalogObjectKind> out = new ArrayList<CatalogObjectKind>(3); 414 out.add(CatalogObjectKind.TABLE); 415 out.add(CatalogObjectKind.VIEW); 416 out.add(CatalogObjectKind.MATERIALIZED_VIEW); 417 return out; 418 } 419 return Collections.singletonList(kind); 420 } 421 } 422 423 /** 424 * Copy {@code parent}'s children from a transient {@code fetched} snapshot into 425 * {@link #lazyChildren} so subsequent {@link #findChildren} calls (and the bridge 426 * mapper) can recover columns for a lazy-resolved entry. The bridge currently 427 * materializes COLUMN children only; if the runtime grows new child kinds (e.g. 428 * parameters on a routine) this list is the place to extend. 429 */ 430 private void captureLazyChildren(CatalogSnapshot fetched, CatalogEntry parent) { 431 if (fetched == null || parent == null || parent.id() == null) { 432 return; 433 } 434 CatalogObjectKind[] childKinds = { CatalogObjectKind.COLUMN }; 435 for (CatalogObjectKind ck : childKinds) { 436 List<CatalogEntry> kids = fetched.children(parent.id(), ck); 437 if (kids != null && !kids.isEmpty()) { 438 java.util.Map<CatalogObjectKind, List<CatalogEntry>> byKind = lazyChildren.get(parent.id()); 439 if (byKind == null) { 440 byKind = new java.util.HashMap<CatalogObjectKind, List<CatalogEntry>>(); 441 lazyChildren.put(parent.id(), byKind); 442 } 443 byKind.put(ck, new ArrayList<CatalogEntry>(kids)); 444 } 445 } 446 } 447 448 public static final class Builder { 449 450 private CatalogProvider provider; 451 private EDbVendor vendor; 452 private CatalogLoadingMode loadingMode; 453 private long ttlMillis; 454 private int maxFetchesPerAnalysis; 455 private boolean includeColumns = true; 456 private boolean includeViews = true; 457 private boolean includeRoutines = true; 458 private CatalogSnapshot initialSnapshot; 459 460 private Builder() { 461 } 462 463 public Builder provider(CatalogProvider v) { 464 this.provider = v; 465 return this; 466 } 467 468 public Builder vendor(EDbVendor v) { 469 this.vendor = v; 470 return this; 471 } 472 473 public Builder loadingMode(CatalogLoadingMode v) { 474 this.loadingMode = v; 475 return this; 476 } 477 478 public Builder ttlMillis(long v) { 479 this.ttlMillis = v; 480 return this; 481 } 482 483 public Builder maxFetchesPerAnalysis(int v) { 484 this.maxFetchesPerAnalysis = v; 485 return this; 486 } 487 488 public Builder includeColumns(boolean v) { 489 this.includeColumns = v; 490 return this; 491 } 492 493 public Builder includeViews(boolean v) { 494 this.includeViews = v; 495 return this; 496 } 497 498 public Builder includeRoutines(boolean v) { 499 this.includeRoutines = v; 500 return this; 501 } 502 503 public Builder initialSnapshot(CatalogSnapshot v) { 504 this.initialSnapshot = v; 505 return this; 506 } 507 508 public CatalogRuntime build() { 509 return new CatalogRuntime(this); 510 } 511 } 512}