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}