001package gudusoft.gsqlparser.sqlenv.compat;
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.CatalogDiagnosticSink;
008import gudusoft.gsqlparser.catalog.diagnostic.CatalogException;
009import gudusoft.gsqlparser.catalog.input.CatalogLoadOptions;
010import gudusoft.gsqlparser.catalog.input.CatalogLoadResult;
011import gudusoft.gsqlparser.catalog.input.CatalogModelValidator;
012import gudusoft.gsqlparser.catalog.input.CatalogValidationResult;
013import gudusoft.gsqlparser.catalog.input.model.CatalogModel;
014import gudusoft.gsqlparser.catalog.input.model.ColumnModel;
015import gudusoft.gsqlparser.catalog.input.model.RoutineModel;
016import gudusoft.gsqlparser.catalog.input.model.SchemaModel;
017import gudusoft.gsqlparser.catalog.input.model.SequenceModel;
018import gudusoft.gsqlparser.catalog.input.model.SynonymModel;
019import gudusoft.gsqlparser.catalog.input.model.TableModel;
020import gudusoft.gsqlparser.catalog.input.model.UnifiedCatalogModel;
021import gudusoft.gsqlparser.catalog.input.model.ViewModel;
022import gudusoft.gsqlparser.catalog.runtime.CatalogIdentifierPolicy;
023import gudusoft.gsqlparser.catalog.runtime.CatalogObjectKind;
024import gudusoft.gsqlparser.catalog.runtime.CatalogQualifiedName;
025import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
026import gudusoft.gsqlparser.sqlenv.IdentifierInputForm;
027import gudusoft.gsqlparser.sqlenv.SqlNameGenerator;
028import gudusoft.gsqlparser.sqlenv.TDDLSQLEnv;
029import gudusoft.gsqlparser.sqlenv.TSQLCatalog;
030import gudusoft.gsqlparser.sqlenv.TSQLEnv;
031import gudusoft.gsqlparser.sqlenv.TSQLSchema;
032import gudusoft.gsqlparser.sqlenv.TSQLTable;
033
034import java.util.ArrayList;
035import java.util.List;
036
037/**
038 * Eager bridge facade: walks a {@link UnifiedCatalogModel} and writes every catalog/schema/
039 * table/view/routine into a {@link TSQLEnv} via the existing public mutation API.
040 *
041 * <p>Plan §8.2 / §12 (T1C.2). Per spike T0.3 inventory the loader uses only public TSQLEnv
042 * mutation methods and the public catalog-tree API ({@link TSQLEnv#getSQLCatalog},
043 * {@link TSQLCatalog#getSchema}, {@link TSQLSchema#createTable}, etc.) — no reflection,
044 * no private-field access. The catalog-tree path mirrors {@code SqlflowSQLEnv} so
045 * schema-less dialects (MySQL, where {@link TSQLEnv#supportSchema(EDbVendor)} is
046 * {@code false}) route through {@link TSQLEnv#DEFAULT_SCHEMA_NAME} the same way the
047 * legacy loader does.</p>
048 *
049 * <p>Sequences are not modeled in {@link TSQLEnv} (no {@code dotSequence} member of
050 * {@link ESQLDataObjectType}); they are represented in the runtime layer instead. The
051 * loader records an INFO diagnostic for each skipped sequence so callers can correlate
052 * the gap.</p>
053 */
054public final class SQLEnvCatalogLoader {
055
056    public SQLEnvCatalogLoader() {
057    }
058
059    /**
060     * Walk {@code model} and apply it to {@code env}. Validation runs first; default mode
061     * fails on ERROR-severity diagnostics and strict mode also fails on WARN, matching
062     * {@code DefaultCatalogLoader} (plan §15).
063     *
064     * <p>On a validation failure this method throws {@link CatalogException} carrying the
065     * counts of ERROR / WARN diagnostics — {@link CatalogLoadResult} is only returned on
066     * success. This matches {@code CatalogLoaders.DefaultCatalogLoader.load(...)}; the
067     * sink (when set on {@code options}) still receives every diagnostic before the
068     * exception fires so callers can correlate. On non-validation success the returned
069     * result carries the populated env plus any informational diagnostics emitted by the
070     * loader (e.g. unrepresented sequences).</p>
071     */
072    public CatalogLoadResult loadIntoSQLEnv(TSQLEnv env, UnifiedCatalogModel model,
073                                            CatalogLoadOptions options) {
074        if (env == null) {
075            throw new IllegalArgumentException("SQLEnvCatalogLoader.loadIntoSQLEnv: env is required");
076        }
077        if (model == null) {
078            throw new IllegalArgumentException("SQLEnvCatalogLoader.loadIntoSQLEnv: model is required");
079        }
080        if (options == null) {
081            throw new IllegalArgumentException(
082                "SQLEnvCatalogLoader.loadIntoSQLEnv: options is required");
083        }
084        if (env.getDBVendor() != options.vendor()) {
085            throw new IllegalArgumentException(
086                "SQLEnvCatalogLoader.loadIntoSQLEnv: env.vendor=" + env.getDBVendor()
087                    + " does not match options.vendor=" + options.vendor());
088        }
089        if (model.vendor() != options.vendor()) {
090            throw new IllegalArgumentException(
091                "SQLEnvCatalogLoader.loadIntoSQLEnv: model.vendor=" + model.vendor()
092                    + " does not match options.vendor=" + options.vendor());
093        }
094
095        List<CatalogDiagnostic> diagnostics = validate(model, options);
096        applyDefaults(env, model, options);
097        boolean dialectHasSchema = TSQLEnv.supportSchema(env.getDBVendor());
098        for (CatalogModel c : model.catalogs()) {
099            applyCatalog(env, c, model.nameForm(), dialectHasSchema, diagnostics, options.diagnosticSink());
100        }
101        return CatalogLoadResult.ok(env, diagnostics);
102    }
103
104    /**
105     * Convenience: spin up a fresh {@link TSQLEnv} (concrete {@link TDDLSQLEnv} subclass
106     * with empty defaults) and apply {@code model} to it. Throws {@link CatalogException}
107     * on failure to match {@code DefaultCatalogLoader.load(...)} semantics.
108     */
109    public TSQLEnv loadToSQLEnv(UnifiedCatalogModel model, CatalogLoadOptions options) {
110        if (model == null) {
111            throw new IllegalArgumentException("SQLEnvCatalogLoader.loadToSQLEnv: model is required");
112        }
113        if (options == null) {
114            throw new IllegalArgumentException(
115                "SQLEnvCatalogLoader.loadToSQLEnv: options is required");
116        }
117        if (model.vendor() != options.vendor()) {
118            throw new IllegalArgumentException(
119                "SQLEnvCatalogLoader.loadToSQLEnv: model.vendor=" + model.vendor()
120                    + " does not match options.vendor=" + options.vendor());
121        }
122        TSQLEnv env = newSQLEnv(options.vendor());
123        CatalogLoadResult result = loadIntoSQLEnv(env, model, options);
124        if (!result.ok()) {
125            throw new CatalogException(
126                "SQLEnvCatalogLoader.loadToSQLEnv: model failed validation ("
127                    + countOf(result.diagnostics(), CatalogDiagnosticSeverity.ERROR) + " ERROR, "
128                    + countOf(result.diagnostics(), CatalogDiagnosticSeverity.WARN) + " WARN"
129                    + (options.strict() ? ", strict mode" : "") + ")");
130        }
131        return env;
132    }
133
134    // ---- internals -------------------------------------------------------
135
136    /**
137     * Construct a concrete {@link TSQLEnv} since the base class is abstract. Phase 1 picks
138     * {@link TDDLSQLEnv} because it is the project's existing concrete subclass that has a
139     * trivial constructor and is already used by tests. The bridge work in P1D introduces a
140     * dedicated subclass; until then, this is the simplest path that does not extend
141     * {@code TSQLEnv} from the new package.
142     */
143    private static TSQLEnv newSQLEnv(EDbVendor vendor) {
144        // Pass null for sql so TDDLSQLEnv stays in its inert / un-initialized state —
145        // the loader never asks the env to parse DDL, it only mutates the catalog tree.
146        return new TDDLSQLEnv(null, null, null, vendor, null);
147    }
148
149    private List<CatalogDiagnostic> validate(UnifiedCatalogModel model,
150                                             CatalogLoadOptions options) {
151        CatalogValidationResult validation = new CatalogModelValidator().validate(model, options);
152        List<CatalogDiagnostic> diagnostics = new ArrayList<CatalogDiagnostic>(validation.diagnostics());
153        if (options.diagnosticSink() != null) {
154            for (CatalogDiagnostic d : diagnostics) {
155                options.diagnosticSink().accept(d);
156            }
157        }
158        int errors = countOf(diagnostics, CatalogDiagnosticSeverity.ERROR);
159        int warns = countOf(diagnostics, CatalogDiagnosticSeverity.WARN);
160        // Plan §15 — default mode: ERROR diagnostics fail the load; strict mode escalates
161        // WARN diagnostics to load failure too. Mirrors DefaultCatalogLoader.validate.
162        if (errors > 0 || (options.strict() && warns > 0)) {
163            throw new CatalogException(
164                "SQLEnvCatalogLoader: model failed validation ("
165                    + errors + " ERROR, " + warns + " WARN"
166                    + (options.strict() ? ", strict mode" : "") + ")");
167        }
168        return diagnostics;
169    }
170
171    private static void applyDefaults(TSQLEnv env, UnifiedCatalogModel model,
172                                      CatalogLoadOptions options) {
173        // Options take precedence over the model's defaults — they're the per-call knobs.
174        // The model's defaults are names from the same input, so they carry its form. The
175        // options are the caller's own per-call knobs and stay SQL text, as they always were.
176        EDbVendor vendor = env.getDBVendor();
177        IdentifierInputForm form = model.nameForm();
178        String catalog = nonEmpty(options.defaultCatalog(), defaultName(vendor, form,
179            SqlNameGenerator.Segment.catalog(model.defaults().defaultCatalog())));
180        String schema = nonEmpty(options.defaultSchema(), defaultName(vendor, form,
181            SqlNameGenerator.Segment.schema(model.defaults().defaultSchema())));
182        String server = nonEmpty(options.defaultServer(), model.defaults().defaultServer());
183        if (catalog != null) env.setDefaultCatalogName(catalog);
184        if (schema != null) env.setDefaultSchemaName(schema);
185        if (server != null) env.setDefaultServerName(server);
186    }
187
188    /**
189     * The SQL spelling of a default catalog / schema name. Unlike a registered object name it
190     * is read back by callers ({@code getDefaultCatalogName()}), so a stored name is delimited
191     * only where the bare word would name another object: {@code analytics} stays as it is,
192     * Oracle {@code Hr} becomes {@code "Hr"}.
193     */
194    private static String defaultName(EDbVendor vendor, IdentifierInputForm form,
195                                      SqlNameGenerator.Segment segment) {
196        String stored = segment.getStored();
197        if (stored == null || stored.isEmpty() || form != IdentifierInputForm.CATALOG_STORED) {
198            return stored;
199        }
200        SqlNameGenerator.Result r = SqlNameGenerator.generate(vendor,
201            SqlNameGenerator.QuotePolicy.WHEN_NEEDED, segment);
202        return r.isOk() ? r.getSql() : TSQLEnv.toLexical(vendor, segment.getRole(), stored, form);
203    }
204
205    private static String nonEmpty(String first, String fallback) {
206        if (first != null && !first.isEmpty()) return first;
207        if (fallback != null && !fallback.isEmpty()) return fallback;
208        return null;
209    }
210
211    /**
212     * The spelling to register for a model name. The registration entry points read every name
213     * as SQL text, so a {@link IdentifierInputForm#CATALOG_STORED} name is first turned into the
214     * delimited SQL spelling of the same stored name ({@link TSQLEnv#toLexical}).
215     */
216    private static String lex(EDbVendor vendor, IdentifierInputForm form, ESQLDataObjectType type,
217                              String name) {
218        return TSQLEnv.toLexical(vendor, type, name, form);
219    }
220
221    private static void applyCatalog(TSQLEnv env, CatalogModel c, IdentifierInputForm form,
222                                     boolean dialectHasSchema,
223                                     List<CatalogDiagnostic> diagnostics,
224                                     CatalogDiagnosticSink sink) {
225        TSQLCatalog catalog = env.getSQLCatalog(
226            lex(env.getDBVendor(), form, ESQLDataObjectType.dotCatalog, c.name()), true);
227        for (SchemaModel s : c.schemas()) {
228            applySchema(catalog, s, form, dialectHasSchema, diagnostics, sink);
229        }
230    }
231
232    private static void applySchema(TSQLCatalog catalog, SchemaModel s, IdentifierInputForm form,
233                                    boolean dialectHasSchema,
234                                    List<CatalogDiagnostic> diagnostics,
235                                    CatalogDiagnosticSink sink) {
236        // Schema-less dialect: route every object through the dialect's DEFAULT schema bucket
237        // exactly the way SqlflowSQLEnv does. The model's schema name (if non-empty) is
238        // discarded in that case — it has no meaningful place to land in a single-tier env.
239        String schemaName = (!dialectHasSchema || s.name() == null || s.name().isEmpty())
240            ? TSQLEnv.DEFAULT_SCHEMA_NAME : s.name();
241        EDbVendor vendor = catalog.getSqlEnv().getDBVendor();
242        TSQLSchema schema = catalog.getSchema(
243            lex(vendor, form, ESQLDataObjectType.dotSchema, schemaName), true);
244
245        for (TableModel t : s.tables()) {
246            TSQLTable tbl = schema.createTable(lex(vendor, form, ESQLDataObjectType.dotTable, t.name()));
247            if (tbl != null) {
248                for (ColumnModel col : t.columns()) {
249                    tbl.addColumn(lex(vendor, form, ESQLDataObjectType.dotColumn, col.name()));
250                }
251            }
252        }
253        for (ViewModel v : s.views()) {
254            TSQLTable view = schema.createTable(lex(vendor, form, ESQLDataObjectType.dotTable, v.name()));
255            if (view != null) {
256                view.setView(true);
257                if (v.definition() != null) view.setDefinition(v.definition());
258                for (ColumnModel col : v.columns()) {
259                    view.addColumn(lex(vendor, form, ESQLDataObjectType.dotColumn, col.name()));
260                }
261            }
262        }
263        for (RoutineModel r : s.routines()) {
264            applyRoutine(schema, r, form);
265        }
266        for (SynonymModel syn : s.synonyms()) {
267            applySynonym(schema, syn, form);
268        }
269        for (SequenceModel sq : s.sequences()) {
270            // Sequences have no TSQLEnv representation. Record a WARN per plan §15
271            // ("partial catalog" row): an unrepresented object surfaces as WARN so
272            // strict-mode callers can escalate. The runtime snapshot still carries the
273            // sequence — only the TSQLEnv view is missing it.
274            CatalogDiagnostic d = CatalogDiagnostic.builder()
275                .severity(CatalogDiagnosticSeverity.WARN)
276                .code(CatalogDiagnosticCode.CATALOG_LOAD_UNSUPPORTED_KIND)
277                .message("Sequence '" + sq.name()
278                    + "' has no TSQLEnv representation; runtime-only")
279                .build();
280            diagnostics.add(d);
281            if (sink != null) sink.accept(d);
282        }
283    }
284
285    private static void applyRoutine(TSQLSchema schema, RoutineModel r, IdentifierInputForm form) {
286        EDbVendor vendor = schema.getSqlEnv().getDBVendor();
287        switch (r.kind()) {
288            case FUNCTION:
289                schema.createFunction(lex(vendor, form, ESQLDataObjectType.dotFunction, r.name()));
290                break;
291            case PROCEDURE:
292            case ROUTINE:
293                schema.createProcedure(lex(vendor, form, ESQLDataObjectType.dotProcedure, r.name()));
294                break;
295            case PACKAGE:
296                schema.createOraclePackage(lex(vendor, form, ESQLDataObjectType.dotOraclePackage, r.name()));
297                break;
298            default:
299                throw new IllegalStateException(
300                    "RoutineModel.kind must be FUNCTION/PROCEDURE/PACKAGE/ROUTINE; got " + r.kind());
301        }
302    }
303
304    /**
305     * Register a synonym carrying its base target. {@link SynonymModel#targetQualifiedName()}
306     * is a dotted reference ({@code object}, {@code schema.object}, or
307     * {@code database.schema.object}); parse it (quote-aware, per vendor) into its
308     * trailing 1-3 segments and record them as the synonym's base so name resolution
309     * can dereference the synonym to its base table/view.
310     *
311     * <p>Only the synonym's own name follows the model's {@code nameForm}. The target is a
312     * dotted reference, which is SQL text by construction: a stored name may itself contain a
313     * dot, so stored names joined with dots could not be split back.</p>
314     */
315    private static void applySynonym(TSQLSchema schema, SynonymModel syn, IdentifierInputForm form) {
316        EDbVendor vendor = schema.getSqlEnv().getDBVendor();
317        String synonymName = lex(vendor, form, ESQLDataObjectType.dotSynonyms, syn.name());
318        String target = syn.targetQualifiedName();
319        if (target == null || target.isEmpty()) {
320            schema.createSynonyms(synonymName);
321            return;
322        }
323        List<String> segments;
324        try {
325            CatalogQualifiedName parsed =
326                CatalogIdentifierPolicy.parse(target, CatalogObjectKind.SYNONYM, null, vendor);
327            segments = parsed.raw();
328        } catch (RuntimeException ex) {
329            // Malformed target: keep the synonym name resolvable, drop only the base.
330            schema.createSynonyms(synonymName);
331            return;
332        }
333        int n = segments.size();
334        String sourceName = n >= 1 ? segments.get(n - 1) : null;
335        String sourceSchema = n >= 2 ? segments.get(n - 2) : null;
336        String sourceDatabase = n >= 3 ? segments.get(n - 3) : null;
337        schema.createSynonyms(synonymName, sourceDatabase, sourceSchema, sourceName);
338    }
339
340    private static int countOf(List<CatalogDiagnostic> diagnostics,
341                               CatalogDiagnosticSeverity severity) {
342        int n = 0;
343        for (CatalogDiagnostic d : diagnostics) {
344            if (d.severity() == severity) n++;
345        }
346        return n;
347    }
348}