001package gudusoft.gsqlparser.sqlenv.compat;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnostic;
005import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnosticSink;
006import gudusoft.gsqlparser.catalog.input.model.IdentifierConfig;
007import gudusoft.gsqlparser.catalog.runtime.CatalogContext;
008import gudusoft.gsqlparser.catalog.runtime.CatalogEntry;
009import gudusoft.gsqlparser.catalog.runtime.CatalogIdentifierPolicy;
010import gudusoft.gsqlparser.catalog.runtime.CatalogObjectKind;
011import gudusoft.gsqlparser.catalog.runtime.CatalogQualifiedName;
012import gudusoft.gsqlparser.catalog.runtime.CatalogResolutionResult;
013import gudusoft.gsqlparser.catalog.runtime.CatalogResolver;
014import gudusoft.gsqlparser.catalog.runtime.CatalogRuntime;
015import gudusoft.gsqlparser.catalog.runtime.CatalogSearchPath;
016import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
017import gudusoft.gsqlparser.sqlenv.TSQLCatalog;
018import gudusoft.gsqlparser.sqlenv.TSQLEnv;
019import gudusoft.gsqlparser.sqlenv.TSQLSchemaObject;
020import gudusoft.gsqlparser.sqlenv.catalog.ICatalogProvider;
021
022import java.util.ArrayList;
023import java.util.List;
024
025/**
026 * Lazy bridge from the legacy {@link ICatalogProvider} hook to a new-world
027 * {@link CatalogRuntime}. Plan §5.3 / §8.3.
028 *
029 * <p>Read path: {@link #findObject} delegates to the wrapped provider first; on miss it asks
030 * the runtime resolver, materializes the hit via {@link CatalogEntryToSQLEnvMapper}, writes
031 * the resulting {@link TSQLSchemaObject} into the delegate's overlay so subsequent lookups
032 * within the same parse never re-cross the bridge, and returns it. On miss-after-runtime
033 * the method returns {@code null} (preserves today's "missing object" semantics).</p>
034 *
035 * <p>Write path: {@link #addObject} writes straight to the delegate. DDL discovered
036 * mid-parse and temp-table registrations therefore go to the existing legacy overlay
037 * (the {@code CatalogStoreProvider} default for a {@link TSQLEnv}); they never touch the
038 * immutable {@code CatalogSnapshot} owned by the runtime — the runtime overlay stays
039 * untouched here on purpose, the bridge's job is to feed the legacy lookup path.</p>
040 *
041 * <p>All other {@link ICatalogProvider} methods delegate verbatim to the wrapped provider.
042 * Construction validates that the runtime, delegate, mapper and target env are non-null.
043 * Lookups silently return {@code null} on null arguments to mirror the legacy contract:
044 * {@code TSQLEnv.doSearchSchemaObject} only invokes the provider when at least three
045 * normalized segments are present, but defensive null handling here matches the rest of
046 * the {@link ICatalogProvider} contract.</p>
047 */
048public final class CatalogBackedCatalogProvider implements ICatalogProvider {
049
050    private final CatalogRuntime runtime;
051    private final ICatalogProvider delegate;
052    private final CatalogEntryToSQLEnvMapper mapper;
053    private final TSQLEnv targetEnv;
054    private final IdentifierConfig identifierConfig;
055    private final CatalogDiagnosticSink diagnosticSink;
056
057    public CatalogBackedCatalogProvider(CatalogRuntime runtime, ICatalogProvider delegate,
058                                        CatalogEntryToSQLEnvMapper mapper, TSQLEnv targetEnv) {
059        this(runtime, delegate, mapper, targetEnv,
060            /* identifierConfig */ null, /* diagnosticSink */ null);
061    }
062
063    /**
064     * Convenience constructor without a diagnostic sink. Equivalent to
065     * {@link #CatalogBackedCatalogProvider(CatalogRuntime, ICatalogProvider,
066     * CatalogEntryToSQLEnvMapper, TSQLEnv, IdentifierConfig, CatalogDiagnosticSink)}
067     * with {@code diagnosticSink=null}.
068     */
069    public CatalogBackedCatalogProvider(CatalogRuntime runtime, ICatalogProvider delegate,
070                                        CatalogEntryToSQLEnvMapper mapper, TSQLEnv targetEnv,
071                                        IdentifierConfig identifierConfig) {
072        this(runtime, delegate, mapper, targetEnv, identifierConfig, /* diagnosticSink */ null);
073    }
074
075    /**
076     * Construct a bridge provider with an explicit {@link IdentifierConfig} and an
077     * optional {@link CatalogDiagnosticSink}. When {@code identifierConfig} is
078     * {@code null} the bridge falls back to {@link IdentifierConfig#defaultsFor(EDbVendor)};
079     * non-null configs are honored so snapshot keys built under (e.g.) MySQL
080     * {@code lower_case_table_names=2} or MSSQL collation overrides match at lookup time.
081     *
082     * <p>Diagnostics emitted by the runtime resolver (fetch-cap WARNs/ERRORs, partial
083     * results, fetch failures) are forwarded to {@code diagnosticSink} when configured.
084     * The {@link ICatalogProvider#findObject} contract still returns {@code null} on
085     * miss (no behavior change for legacy callers), but a configured sink lets a
086     * caller observe what the runtime saw — required for plan §10.6 fetch-cap
087     * diagnostics to be visible at the bridge layer.</p>
088     */
089    public CatalogBackedCatalogProvider(CatalogRuntime runtime, ICatalogProvider delegate,
090                                        CatalogEntryToSQLEnvMapper mapper, TSQLEnv targetEnv,
091                                        IdentifierConfig identifierConfig,
092                                        CatalogDiagnosticSink diagnosticSink) {
093        if (runtime == null) {
094            throw new IllegalArgumentException(
095                "CatalogBackedCatalogProvider: runtime must not be null");
096        }
097        if (delegate == null) {
098            throw new IllegalArgumentException(
099                "CatalogBackedCatalogProvider: delegate must not be null");
100        }
101        if (mapper == null) {
102            throw new IllegalArgumentException(
103                "CatalogBackedCatalogProvider: mapper must not be null");
104        }
105        if (targetEnv == null) {
106            throw new IllegalArgumentException(
107                "CatalogBackedCatalogProvider: targetEnv must not be null");
108        }
109        if (runtime.vendor() != targetEnv.getDBVendor()) {
110            throw new IllegalArgumentException(
111                "CatalogBackedCatalogProvider: runtime.vendor=" + runtime.vendor()
112                    + " does not match targetEnv.vendor=" + targetEnv.getDBVendor());
113        }
114        if (identifierConfig != null && identifierConfig.vendor() != runtime.vendor()) {
115            throw new IllegalArgumentException(
116                "CatalogBackedCatalogProvider: identifierConfig.vendor=" + identifierConfig.vendor()
117                    + " does not match runtime.vendor=" + runtime.vendor());
118        }
119        this.runtime = runtime;
120        this.delegate = delegate;
121        this.mapper = mapper;
122        this.targetEnv = targetEnv;
123        this.identifierConfig = identifierConfig != null
124            ? identifierConfig : IdentifierConfig.defaultsFor(runtime.vendor());
125        this.diagnosticSink = diagnosticSink;
126    }
127
128    @Override
129    public TSQLCatalog getCatalog(String catalogName) {
130        return delegate.getCatalog(catalogName);
131    }
132
133    @Override
134    public TSQLCatalog createCatalog(String catalogName) {
135        return delegate.createCatalog(catalogName);
136    }
137
138    @Override
139    public List<TSQLCatalog> getAllCatalogs() {
140        return delegate.getAllCatalogs();
141    }
142
143    @Override
144    public String getDefaultCatalogName() {
145        return delegate.getDefaultCatalogName();
146    }
147
148    @Override
149    public void setDefaultCatalogName(String name) {
150        delegate.setDefaultCatalogName(name);
151    }
152
153    @Override
154    public TSQLSchemaObject findObject(String catalog, String schema, String objectName,
155                                       ESQLDataObjectType type) {
156        // 1. Delegate first — overlay/cache/last-good answer takes priority over
157        //    re-crossing the bridge. This matches the §10.5 lookup precedence: the
158        //    legacy delegate's overlay is the bridge's "Phase 1" before the snapshot.
159        TSQLSchemaObject existing = delegate.findObject(catalog, schema, objectName, type);
160        if (existing != null) {
161            return existing;
162        }
163        if (objectName == null || objectName.isEmpty()) {
164            return null;
165        }
166        CatalogObjectKind kind = mapType(type);
167        if (kind == null) {
168            return null;
169        }
170
171        // 2. Build a CatalogQualifiedName from the segments TSQLEnv passes us. The
172        //    segments are already normalized by IdentifierService.normalizeSegment
173        //    upstream (see TSQLEnv.doSearchSchemaObject), so going through the parse
174        //    path again would (a) re-normalize an already-normalized value (mostly
175        //    harmless but wasteful), and (b) split on dots inside quoted-embedded-dot
176        //    names like Oracle "a.b"."c" that have already been segmented correctly
177        //    upstream. Use the dedicated factory that takes already-split segments.
178        //
179        //    fromAlreadyNormalizedSegments only rejects null/empty inputs, and
180        //    buildSegments guarantees at least one non-null/non-empty entry (the
181        //    object name was non-null at the top of the method), so the factory
182        //    cannot throw on this call site. If it ever does, that's a real bug
183        //    in the upstream segment-builder — let it propagate so it's visible
184        //    in diagnostics instead of being indistinguishable from a normal miss.
185        EDbVendor vendor = runtime.vendor();
186        List<String> segments = buildSegments(catalog, schema, objectName);
187        CatalogQualifiedName name =
188            CatalogIdentifierPolicy.fromAlreadyNormalizedSegments(segments, kind, vendor);
189
190        // 3. Ask the runtime resolver. The resolver walks overlay → lazy cache →
191        //    snapshot → on-miss provider fetch (when LAZY/AUTO) and applies the
192        //    candidate-expansion ladder per plan §9.3 (defaults, search path, kind
193        //    widening). The bridge is invoked only when TSQLEnv has at least three
194        //    segments, so candidate expansion is mostly a no-op here — but the kind-
195        //    widening step is what lets a TABLE lookup match a VIEW snapshot entry.
196        CatalogContext ctx = CatalogContext.builder()
197            .vendor(vendor)
198            .activeCatalog(catalog)
199            .activeSchema(schema)
200            .searchPath(CatalogSearchPath.empty())
201            .identifierConfig(identifierConfig)
202            .build();
203        CatalogResolver resolver = runtime.resolver();
204        CatalogResolutionResult result = resolver.resolve(ctx, name);
205        // Forward diagnostics from the resolver to the configured sink (if any) so
206        // fetch-cap WARN/ERROR, partial-result INFO, and fetch-failed WARN are
207        // visible at the bridge layer. Does nothing when no sink was configured —
208        // findObject's null-on-miss contract is unaffected either way.
209        forwardDiagnostics(result);
210        if (!result.resolved() || !result.binding().isPresent()) {
211            return null;
212        }
213
214        // 4. Materialize the runtime entry into the legacy schema-object shape via
215        //    the mapper. Use CatalogRuntime.findEntry so we see the actual entry from
216        //    overlay/lazyCache/snapshot (the binding alone does not carry properties);
217        //    pull column children too so the materialized TSQLTable carries the columns
218        //    the resolver's column push-down will read.
219        CatalogQualifiedName resolvedName = result.binding().get().resolvedName();
220        CatalogObjectKind resolvedKind = result.binding().get().kind();
221        CatalogEntry entry = runtime.findEntry(resolvedName, resolvedKind);
222        if (entry == null) {
223            // Defensive: a resolver hit without a backing entry shouldn't happen with
224            // the in-tree CatalogRuntime, but third-party CatalogResolver impls may
225            // return a binding without populating the snapshot or lazy cache. Build a
226            // synthetic entry from the binding so the mapper still has a CatalogEntry
227            // to dispatch on. Columns will be empty for synthetic entries.
228            entry = syntheticEntry(result);
229        }
230        List<CatalogEntry> columns = (resolvedKind == CatalogObjectKind.TABLE
231            || resolvedKind == CatalogObjectKind.VIEW
232            || resolvedKind == CatalogObjectKind.MATERIALIZED_VIEW)
233            ? runtime.findChildren(entry.id(), CatalogObjectKind.COLUMN)
234            : new ArrayList<CatalogEntry>();
235        TSQLSchemaObject materialized = mapper.toSQLSchemaObject(entry, targetEnv, columns);
236        // The mapper's addTable/addView/addProcedure paths invoke
237        // TSQLEnv.putSchemaObject which already calls catalogProvider.addObject on the
238        // delegate — no explicit cache write-back is needed here, doing one would
239        // double-insert into CatalogStore.objectsByName.
240        return materialized;
241    }
242
243    @Override
244    public void addObject(TSQLSchemaObject object) {
245        // DDL/temp-table writes must go to the delegate (the wrapped TSQLEnv's
246        // overlay), never to the immutable snapshot. The runtime's CatalogOverlay is
247        // a separate concept used only by callers that target the new SPI directly;
248        // legacy callers writing through TSQLEnv keep using the legacy overlay so
249        // their reads (which still go through the legacy code path) see their
250        // writes immediately.
251        delegate.addObject(object);
252    }
253
254    @Override
255    public boolean removeObject(TSQLSchemaObject object) {
256        return delegate.removeObject(object);
257    }
258
259    @Override
260    public void clear() {
261        delegate.clear();
262    }
263
264    @Override
265    public int size() {
266        return delegate.size();
267    }
268
269    /**
270     * Map a legacy {@link ESQLDataObjectType} to the new {@link CatalogObjectKind}.
271     * Returns {@code null} for kinds that have no first-class runtime representation
272     * (column, parameter, dblink, datatype, unknown) — the bridge then preserves
273     * today's "missing object" semantics by returning null from {@link #findObject}.
274     */
275    static CatalogObjectKind mapType(ESQLDataObjectType type) {
276        if (type == null) {
277            return null;
278        }
279        switch (type) {
280            case dotTable:
281                // The runtime resolver already widens TABLE → VIEW / MATERIALIZED_VIEW
282                // (plan §9.3) so a relation reference always finds a snapshot hit
283                // regardless of whether it was registered as a table or a view.
284                return CatalogObjectKind.TABLE;
285            case dotFunction:
286                return CatalogObjectKind.FUNCTION;
287            case dotProcedure:
288                return CatalogObjectKind.PROCEDURE;
289            case dotOraclePackage:
290                return CatalogObjectKind.PACKAGE;
291            case dotTrigger:
292                return CatalogObjectKind.TRIGGER;
293            case dotSynonyms:
294                return CatalogObjectKind.SYNONYM;
295            case dotRoutine:
296                return CatalogObjectKind.ROUTINE;
297            default:
298                // dotColumn, dotParameter, dotDblink, dotDataType, dotCatalog,
299                // dotSchema, dotUnknown — no direct CatalogEntry materialization
300                // path. The legacy resolver doesn't ask the bridge for these in
301                // practice (column lookup goes through table-then-walk).
302                return null;
303        }
304    }
305
306    private void forwardDiagnostics(CatalogResolutionResult result) {
307        if (diagnosticSink == null || result == null) {
308            return;
309        }
310        List<CatalogDiagnostic> diags = result.diagnostics();
311        if (diags == null || diags.isEmpty()) {
312            return;
313        }
314        for (CatalogDiagnostic d : diags) {
315            try {
316                diagnosticSink.accept(d);
317            } catch (RuntimeException ignored) {
318                // A misbehaving sink must not break catalog lookup. Diagnostics are
319                // best-effort — if the sink throws, fall through to null-on-miss.
320            }
321        }
322    }
323
324    /**
325     * Build a list of already-normalized segments from the three legacy {@code findObject}
326     * parameters. Drops null/empty entries so partial qualifications still produce a
327     * valid 1- or 2-segment list. The resulting segments are fed verbatim to
328     * {@link CatalogIdentifierPolicy#fromAlreadyNormalizedSegments} — no joining, no
329     * re-splitting on dots, so quoted-embedded-dot names parsed correctly upstream
330     * survive the round-trip.
331     */
332    static List<String> buildSegments(String catalog, String schema, String object) {
333        List<String> out = new ArrayList<String>(3);
334        if (catalog != null && !catalog.isEmpty()) out.add(catalog);
335        if (schema != null && !schema.isEmpty()) out.add(schema);
336        out.add(object);
337        return out;
338    }
339
340    /**
341     * Build a synthetic {@link CatalogEntry} for a binding when the actual entry is not
342     * recoverable (e.g. third-party resolver impl that returns a binding without a
343     * matching snapshot entry). The synthetic entry carries the binding's id, name,
344     * kind, and properties — enough for the mapper to materialize the legacy object.
345     */
346    private static CatalogEntry syntheticEntry(CatalogResolutionResult result) {
347        final gudusoft.gsqlparser.catalog.runtime.CatalogBinding b = result.binding().get();
348        return new CatalogEntry() {
349            @Override
350            public gudusoft.gsqlparser.catalog.runtime.CatalogObjectId id() {
351                return b.objectId();
352            }
353
354            @Override
355            public CatalogQualifiedName name() {
356                return b.resolvedName();
357            }
358
359            @Override
360            public CatalogObjectKind kind() {
361                return b.kind();
362            }
363
364            @Override
365            public java.util.Map<String, Object> properties() {
366                return b.properties();
367            }
368        };
369    }
370}