001package gudusoft.gsqlparser.catalog.input.readers;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.catalog.input.CatalogInputException;
005import gudusoft.gsqlparser.catalog.input.CatalogInputKind;
006import gudusoft.gsqlparser.catalog.input.CatalogInputReader;
007import gudusoft.gsqlparser.catalog.input.CatalogInputReaderFactory;
008import gudusoft.gsqlparser.catalog.input.CatalogInputSource;
009import gudusoft.gsqlparser.catalog.input.CatalogLoadOptions;
010import gudusoft.gsqlparser.catalog.input.model.CatalogModel;
011import gudusoft.gsqlparser.catalog.input.model.CatalogSourceInfo;
012import gudusoft.gsqlparser.catalog.input.model.ColumnModel;
013import gudusoft.gsqlparser.catalog.input.model.DefaultsConfig;
014import gudusoft.gsqlparser.catalog.input.model.RoutineModel;
015import gudusoft.gsqlparser.catalog.input.model.SchemaModel;
016import gudusoft.gsqlparser.catalog.input.model.TableModel;
017import gudusoft.gsqlparser.catalog.input.model.UnifiedCatalogModel;
018import gudusoft.gsqlparser.catalog.input.model.ViewModel;
019import gudusoft.gsqlparser.catalog.runtime.CatalogObjectKind;
020import gudusoft.gsqlparser.util.json.JSON;
021
022import java.io.BufferedReader;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.InputStreamReader;
026import java.io.Reader;
027import java.nio.charset.StandardCharsets;
028import java.nio.file.Files;
029import java.util.List;
030import java.util.Map;
031
032/**
033 * Reader for {@link CatalogInputKind#SQLFLOW_JSON} sources — the JSON shape the legacy
034 * {@code SqlflowSQLEnv} consumes (plan §11 T2.A.3 / second Phase 2.A static-file adapter).
035 *
036 * <p>Per plan §6 / §11.2 this reader funnels the SqlFlow export shape into the same
037 * {@link UnifiedCatalogModel} the JSON-manifest and SQLDep readers use; the eager bridge
038 * then materializes a {@code TSQLEnv} byte-for-byte equivalent (for the SELECT smoke
039 * surface) to what the legacy {@code SqlflowSQLEnv} would build (parity test in
040 * {@code SqlflowLegacyParityTest}). The legacy path remains untouched per plan §8.5.</p>
041 *
042 * <h3>SqlFlow export schema</h3>
043 *
044 * <p>The root JSON is the SqlFlow "server" object. Three layouts depending on dialect:</p>
045 *
046 * <h4>Layout A — catalogs and schemas (Oracle / MSSQL / PostgreSQL / BigQuery / …)</h4>
047 * <pre>{@code
048 * {
049 *   "name": "<server label>",
050 *   "dbVendor": "dbvoracle",                  // optional vendor override (informational)
051 *   "databases": [
052 *     {
053 *       "name": "ORCL",
054 *       "schemas": [
055 *         {
056 *           "name": "HR",
057 *           "tables":     [{"name": "EMPLOYEES", "type": "TABLE",
058 *                            "columns": [{"name": "EMPLOYEE_ID"}, ...]}, ...],
059 *           "views":      [{"name": "EMP_DETAILS_VIEW", "type": "VIEW",
060 *                            "columns": [...]}, ...],
061 *           "packages":   [{"name": "HR_PKG",
062 *                            "procedures": [...], "functions": [...], "triggers": [...]}],
063 *           "procedures": [{"name": "ADD_EMP"}],
064 *           "functions":  [{"name": "GET_SAL"}],
065 *           "triggers":   [{"name": "AUDIT_TRG"}]
066 *         }
067 *       ]
068 *     }
069 *   ]
070 * }
071 * }</pre>
072 *
073 * <h4>Layout B — catalogs only (MySQL / Teradata / Hive / Impala — {@code supportSchema=false})</h4>
074 * <pre>{@code
075 * {
076 *   "databases": [
077 *     {
078 *       "name": "mydb",
079 *       "tables":   [...],
080 *       "views":    [...],
081 *       "packages": [...],
082 *       "procedures": [...],
083 *       "functions": [...],
084 *       "triggers": [...]
085 *     }
086 *   ]
087 * }
088 * }</pre>
089 *
090 * <h4>Layout C — schemas only (no catalogs)</h4>
091 *
092 * <p>The legacy {@code SqlflowSQLEnv} guards Layout C with {@code supportCatalog(vendor)},
093 * which currently returns {@code true} for every dialect. This branch is therefore dead
094 * in production, but a SqlFlow export pinned to that shape is still tolerated here for
095 * defensive reasons — the model is built under the {@code TSQLEnv.DEFAULT_DB_NAME}
096 * catalog the way the legacy code would.</p>
097 *
098 * <h3>Mapping rules</h3>
099 *
100 * <ul>
101 *   <li>{@code tables[]} and {@code views[]} are walked in <strong>insertion order</strong>
102 *       (tables first, views second), matching the legacy {@code appendTables} method.
103 *       For each entry the {@code type} field is examined: if it contains the substring
104 *       {@code "view"} (case-insensitive ASCII) the entry is recorded as a
105 *       {@link ViewModel}; otherwise as a {@link TableModel}. Array membership is
106 *       informational only — the {@code type} field wins.</li>
107 *   <li>{@code procedures[]} → {@link RoutineModel} with {@link CatalogObjectKind#PROCEDURE}.</li>
108 *   <li>{@code functions[]} → {@link RoutineModel} with {@link CatalogObjectKind#FUNCTION}.</li>
109 *   <li>{@code packages[]} → {@link RoutineModel} with {@link CatalogObjectKind#PACKAGE}.
110 *       The package's nested {@code procedures[]} / {@code functions[]} / {@code triggers[]}
111 *       have no Phase 1 SPI representation and are dropped (the legacy loader binds them
112 *       inside the {@code TSQLEnv}'s package node — that nesting belongs in a follow-up
113 *       extension to {@link RoutineModel}). The empty package shell is still present so
114 *       callers that resolve {@code SCHEMA.PKG} succeed.</li>
115 *   <li>{@code triggers[]} (top-level or package-nested) — there is no Phase 1
116 *       {@code RoutineModel.kind} mapping for triggers and the {@code SQLEnvCatalogLoader}
117 *       does not synthesize them. They are dropped silently. SELECT-only smoke tests are
118 *       unaffected (triggers do not participate in {@code TSQLResolver2} formatter
119 *       output for {@code SELECT}/{@code JOIN} queries); future Phase 2 work that needs
120 *       trigger metadata should extend the SPI.</li>
121 *   <li>The {@code dbVendor} JSON field is informational. The reader honors
122 *       {@link CatalogLoadOptions#vendor()} as the single source of truth (defaulting to
123 *       {@link EDbVendor#dbvoracle} when the option is unset, matching SqlFlow's historical
124 *       Oracle-leaning default). A mismatch between the JSON's {@code dbVendor} and the
125 *       option's vendor is not flagged here — {@code CatalogModelValidator} catches the
126 *       harmful identifier-bypass cases through its normalize round-trip.</li>
127 * </ul>
128 *
129 * <h3>Dispatch</h3>
130 *
131 * <p>The reader is dispatch-by-{@link CatalogInputKind#SQLFLOW_JSON} only — callers must
132 * tag the source explicitly. There is no auto-claim heuristic on JSON structure (the
133 * SqlFlow shape and the SQLDep shape both top-level objects with a {@code databases}
134 * array would collide); the explicit kind tag is the contract.</p>
135 *
136 * <h3>Dotted-identifier defense</h3>
137 *
138 * <p>Identifier names with embedded dots are wrapped with the vendor's quote characters
139 * before storage. Without the wrap, {@code ModelBackedCatalogProvider} later concatenates
140 * segments with {@code .} into qualified strings (e.g. {@code catalog.schema.A.B}) that
141 * {@code CatalogIdentifierPolicy.parse} would split into four segments instead of three,
142 * and the runtime would never resolve the SQL reference {@code "A.B"}. Mirrors the
143 * {@code SQLDepSQLEnv} defense and the same wrapping policy as {@link SqldepCatalogInputReader}.
144 * The vendor → delimiter-pair table is duplicated from {@code TSQLEnv.delimitedChar} to
145 * keep {@code catalog/**} free of a compile-time dependency on the {@code sqlenv} runtime
146 * layer.</p>
147 */
148public final class SqlflowCatalogInputReader implements CatalogInputReader {
149
150    public SqlflowCatalogInputReader() {
151    }
152
153    @Override
154    public CatalogInputKind kind() {
155        return CatalogInputKind.SQLFLOW_JSON;
156    }
157
158    @Override
159    public boolean supports(CatalogInputSource source, CatalogLoadOptions options) {
160        if (source == null || source.inMemoryModel() != null) {
161            return false;
162        }
163        return source.declaredKind() == CatalogInputKind.SQLFLOW_JSON;
164    }
165
166    @Override
167    public UnifiedCatalogModel read(CatalogInputSource source, CatalogLoadOptions options)
168            throws CatalogInputException {
169        if (source == null) {
170            throw new CatalogInputException("SqlflowCatalogInputReader: source is required");
171        }
172        final EDbVendor vendor = (options != null && options.vendor() != null)
173            ? options.vendor()
174            : EDbVendor.dbvoracle;  // SqlFlow exports historically default to Oracle.
175
176        long start = System.currentTimeMillis();
177        String text = readAll(source);
178        Object parsed;
179        try {
180            parsed = JSON.parseObject(text);
181        } catch (RuntimeException ex) {
182            throw new CatalogInputException(
183                "Failed to parse SqlFlow JSON from " + source.name() + ": " + ex.getMessage(),
184                ex);
185        }
186        if (!(parsed instanceof Map)) {
187            throw new CatalogInputException(
188                "SqlFlow root must be an object (got " + typeOf(parsed) + ")");
189        }
190        @SuppressWarnings("unchecked")
191        Map<Object, Object> root = (Map<Object, Object>) parsed;
192
193        try {
194            // A file may declare its names as catalog-stored (root "nameForm"). Everything
195            // below, and the model it builds, reads names as SQL text, so convert once here;
196            // a misspelt declaration surfaces as CatalogInputException via the catch below.
197            gudusoft.gsqlparser.sqlenv.parser.sqlflow.SqlflowNameForm.toLexicalInPlace(vendor, root);
198
199            UnifiedCatalogModel.Builder mb = UnifiedCatalogModel.builder().vendor(vendor);
200            mb.sourceInfo(buildSourceInfo(source, start));
201
202            // Track defaults in one place so we can layer (options → JSON server name)
203            // without reading the model builder back. Writes to mb.defaults(...) happen
204            // exactly once at the end.
205            DefaultsConfig.Builder defaultsBuilder = DefaultsConfig.builder();
206            boolean anyDefault = seedOptionDefaults(defaultsBuilder, options);
207            boolean serverNameSeeded = options != null && options.defaultServer() != null
208                && !options.defaultServer().isEmpty();
209
210            // The canonical SqlFlow ingester export wraps one or more server objects in
211            // a {createdBy, servers:[…]} envelope (see TJSONSQLEnvParser.getJSONSQLEnv).
212            // Each entry of `servers[]` is exactly the shape SqlflowSQLEnv directly
213            // accepts. When the envelope is present, walk every server and merge its
214            // catalogs into a single UnifiedCatalogModel — the legacy parser produces
215            // one TSQLEnv per server, but the SPI's single-model contract folds them
216            // together. The bare server-object-as-root form (what SqlflowSQLEnv was
217            // historically constructed with directly) is also tolerated so callers that
218            // already unwrapped the envelope themselves keep working.
219            //
220            // Catalog-name dedup (codex P2 round 2): two servers that ship a database
221            // with the same name produce duplicate top-level CatalogModels, which the
222            // CatalogModelValidator flags as an ERROR. Track seen names and reject the
223            // ambiguous case explicitly with a clear message — the legacy parser builds
224            // one TSQLEnv per server so the conflict is naturally avoided; here it must
225            // be surfaced for the single-model contract.
226            java.util.HashSet<String> catalogNames = new java.util.HashSet<String>();
227            Object servers = root.get("servers");
228            if (servers != null) {
229                if (!(servers instanceof List)) {
230                    throw new CatalogInputException(
231                        "SqlFlow field 'servers' must be an array (got "
232                            + typeOf(servers) + ")");
233                }
234                for (Object sobj : (List<?>) servers) {
235                    if (!(sobj instanceof Map)) {
236                        throw new CatalogInputException(
237                            "SqlFlow servers[i] must be an object (got "
238                                + typeOf(sobj) + ")");
239                    }
240                    @SuppressWarnings("unchecked")
241                    Map<Object, Object> server = (Map<Object, Object>) sobj;
242                    // Codex P2: preserve the server's `name` as DefaultsConfig.defaultServer
243                    // when the caller did not supply one, mirroring the legacy
244                    // SqlflowSQLEnv.initSQLEnv() call to setDefaultServerName(server.get("name")).
245                    // First server wins to avoid clobbering when multiple servers ship in
246                    // one envelope (the legacy code makes one TSQLEnv per server, so the
247                    // analogue here is "first server's name represents this model").
248                    if (!serverNameSeeded) {
249                        String serverName = asString(server, "name", false);
250                        if (serverName != null && !serverName.isEmpty()) {
251                            defaultsBuilder.defaultServer(serverName);
252                            anyDefault = true;
253                            serverNameSeeded = true;
254                        }
255                    }
256                    // Codex P2 round 2: layout decision is per-server. The legacy parser
257                    // constructs each SqlflowSQLEnv with the server's own dbVendor; if the
258                    // JSON has dbVendor=dbvmysql but options.vendor=dbvoracle, the legacy
259                    // walks tables from databases[] directly while our previous single
260                    // precomputed layout would look for schemas[] and silently drop tables.
261                    // Honor the per-server vendor for supportsSchema only — the model's own
262                    // vendor stays options.vendor (the SPI single-model contract precludes
263                    // mixed-vendor models).
264                    EDbVendor serverVendor = readServerVendor(server, vendor);
265                    boolean serverSupportsSchema = supportSchema(serverVendor);
266                    parseServerBody(server, vendor, serverSupportsSchema, mb, catalogNames);
267                }
268            } else {
269                // No envelope — root IS the server body (legacy direct-construction shape).
270                // Try the same bare-root server-name capture as the envelope path so a
271                // SqlFlow JSON that was hand-edited down to a single server also preserves
272                // its `name` field.
273                if (!serverNameSeeded) {
274                    String serverName = asString(root, "name", false);
275                    if (serverName != null && !serverName.isEmpty()) {
276                        defaultsBuilder.defaultServer(serverName);
277                        anyDefault = true;
278                    }
279                }
280                EDbVendor serverVendor = readServerVendor(root, vendor);
281                boolean serverSupportsSchema = supportSchema(serverVendor);
282                parseServerBody(root, vendor, serverSupportsSchema, mb, catalogNames);
283            }
284
285            if (anyDefault) {
286                mb.defaults(defaultsBuilder.build());
287            }
288            return mb.build();
289        } catch (IllegalArgumentException ex) {
290            // Model builders enforce structural invariants (non-empty names, etc.) by
291            // throwing IllegalArgumentException. Surface those through the reader's
292            // checked-exception channel so callers using try/catch on
293            // CatalogInputException don't get caught off guard. Same posture as the
294            // SQLDep reader.
295            throw new CatalogInputException(
296                "Malformed SqlFlow export from " + source.name() + ": " + ex.getMessage(), ex);
297        }
298    }
299
300    /**
301     * Walk one SqlFlow server body — the JSON object that {@code SqlflowSQLEnv} would be
302     * constructed with directly. Same shape regardless of whether it came from the outer
303     * {@code servers[]} envelope or from a bare server-as-root JSON.
304     *
305     * <p>{@code seenCatalogNames} is the running set across server walks so a multi-server
306     * envelope can reject the ambiguous case (two servers ship a database with the same
307     * name) with a clear {@link CatalogInputException}, rather than producing a model
308     * that fails the validator's duplicate-catalog ERROR check downstream. Pass an empty
309     * set when called for a single-server body (validator still catches in-server
310     * duplicates).</p>
311     */
312    private void parseServerBody(Map<Object, Object> body, EDbVendor vendor,
313                                 boolean supportsSchema, UnifiedCatalogModel.Builder mb,
314                                 java.util.Set<String> seenCatalogNames)
315            throws CatalogInputException {
316        Object databases = body.get("databases");
317        if (databases != null) {
318            if (!(databases instanceof List)) {
319                throw new CatalogInputException(
320                    "SqlFlow field 'databases' must be an array (got "
321                        + typeOf(databases) + ")");
322            }
323            for (Object dobj : (List<?>) databases) {
324                if (!(dobj instanceof Map)) {
325                    throw new CatalogInputException(
326                        "SqlFlow databases[i] must be an object (got "
327                            + typeOf(dobj) + ")");
328                }
329                @SuppressWarnings("unchecked")
330                Map<Object, Object> dmap = (Map<Object, Object>) dobj;
331                CatalogModel catalog = parseDatabase(dmap, vendor, supportsSchema);
332                if (catalog != null) {
333                    if (!seenCatalogNames.add(catalog.name())) {
334                        throw new CatalogInputException(
335                            "SqlFlow envelope contains duplicate catalog name '"
336                                + catalog.name() + "' across servers; the SPI's single-model "
337                                + "contract cannot fold ambiguous catalogs. Split the export "
338                                + "into one file per server, or rename the duplicate "
339                                + "database before reading.");
340                    }
341                    mb.addCatalog(catalog);
342                }
343            }
344            return;
345        }
346        // Layout C — schemas-at-top. Defensive only; supportCatalog(vendor) returns
347        // true for every dialect today, so this branch is unreachable in production
348        // but the legacy SqlflowSQLEnv carries it.
349        Object schemas = body.get("schemas");
350        if (schemas != null) {
351            if (!(schemas instanceof List)) {
352                throw new CatalogInputException(
353                    "SqlFlow field 'schemas' must be an array (got "
354                        + typeOf(schemas) + ")");
355            }
356            CatalogModel catalog = parseSchemasAtTop((List<?>) schemas, vendor);
357            if (catalog != null) {
358                if (!seenCatalogNames.add(catalog.name())) {
359                    throw new CatalogInputException(
360                        "SqlFlow envelope contains duplicate catalog name '"
361                            + catalog.name() + "' across servers");
362                }
363                mb.addCatalog(catalog);
364            }
365        }
366    }
367
368    /**
369     * Resolve the per-server vendor used for the layout decision. The legacy parser
370     * constructs each {@code SqlflowSQLEnv} with the server's own {@code dbVendor} when
371     * present; we honor that for {@link #supportSchema} so a MySQL/Hive server in an
372     * envelope routed under an Oracle/PostgreSQL options.vendor doesn't silently drop
373     * tables that hang directly off {@code databases[i]}. Returns the supplied
374     * {@code defaultVendor} when the JSON omits or supplies an unparsable {@code dbVendor}.
375     */
376    private static EDbVendor readServerVendor(Map<Object, Object> server,
377                                              EDbVendor defaultVendor) {
378        Object v = server.get("dbVendor");
379        if (!(v instanceof String)) {
380            return defaultVendor;
381        }
382        String s = ((String) v).trim();
383        if (s.isEmpty()) {
384            return defaultVendor;
385        }
386        try {
387            return EDbVendor.valueOf(s);
388        } catch (IllegalArgumentException ex) {
389            // Unrecognized vendor name in the JSON — fall back to the option vendor;
390            // CatalogModelValidator's identifier-bypass check will surface any
391            // identifier-fold mismatch downstream. Don't throw: the legacy parser
392            // would have done the same EDbVendor.valueOf() and let the caller see the
393            // resulting IllegalArgumentException; we choose tolerance over failure.
394            return defaultVendor;
395        }
396    }
397
398
399    // ---------- catalog / schema / table parsing ----------
400
401    /**
402     * Walk one {@code databases[i]} entry into a {@link CatalogModel}. Two sub-shapes:
403     *
404     * <ul>
405     *   <li>{@code supportsSchema}: {@code databases[i].schemas[j]} arrays carry the
406     *       per-schema object listings.</li>
407     *   <li>{@code !supportsSchema}: object listings (tables / views / etc.) hang off
408     *       {@code databases[i]} directly; one synthetic schema named
409     *       {@link gudusoft.gsqlparser.sqlenv.TSQLEnv#DEFAULT_SCHEMA_NAME} is produced so
410     *       the loader's catalog-tree walk has somewhere to plant them. The legacy
411     *       {@code SqlflowSQLEnv} does the same routing in
412     *       {@code sqlCatalog.getSchema(TSQLEnv.DEFAULT_SCHEMA_NAME, true)}.</li>
413     * </ul>
414     */
415    private CatalogModel parseDatabase(Map<Object, Object> dmap, EDbVendor vendor,
416                                       boolean supportsSchema)
417            throws CatalogInputException {
418        String dbName = wrapDottedName(asString(dmap, "name", true), vendor);
419        CatalogModel.Builder cb = CatalogModel.builder().name(dbName);
420        if (supportsSchema) {
421            Object schemas = dmap.get("schemas");
422            if (schemas != null) {
423                if (!(schemas instanceof List)) {
424                    throw new CatalogInputException(
425                        "SqlFlow database '" + dbName + "' field 'schemas' must be an array (got "
426                            + typeOf(schemas) + ")");
427                }
428                for (Object sobj : (List<?>) schemas) {
429                    if (!(sobj instanceof Map)) {
430                        throw new CatalogInputException(
431                            "SqlFlow database '" + dbName + "' schemas[i] must be an object (got "
432                                + typeOf(sobj) + ")");
433                    }
434                    @SuppressWarnings("unchecked")
435                    Map<Object, Object> smap = (Map<Object, Object>) sobj;
436                    String schemaName = wrapDottedName(asString(smap, "name", true), vendor);
437                    cb.addSchema(parseSchemaBody(smap, schemaName, vendor));
438                }
439            }
440        } else {
441            // Catalogs-only dialect: synthesize one default-named schema bucket.
442            cb.addSchema(parseSchemaBody(dmap, "", vendor));
443        }
444        return cb.build();
445    }
446
447    /**
448     * Defensive Layout-C fallback. Builds a single synthetic catalog whose name is
449     * {@code TSQLEnv.DEFAULT_DB_NAME} ({@code "$_-_$"}) — matching the legacy
450     * {@code sqlCatalog = getSQLCatalog(TSQLEnv.DEFAULT_DB_NAME, true)} call. Each
451     * top-level schema becomes a {@link SchemaModel} under it.
452     */
453    private CatalogModel parseSchemasAtTop(List<?> schemas, EDbVendor vendor)
454            throws CatalogInputException {
455        CatalogModel.Builder cb = CatalogModel.builder()
456            .name(gudusoft.gsqlparser.sqlenv.TSQLEnv.DEFAULT_DB_NAME);
457        for (Object sobj : schemas) {
458            if (!(sobj instanceof Map)) {
459                throw new CatalogInputException(
460                    "SqlFlow top-level schemas[i] must be an object (got " + typeOf(sobj) + ")");
461            }
462            @SuppressWarnings("unchecked")
463            Map<Object, Object> smap = (Map<Object, Object>) sobj;
464            String schemaName = wrapDottedName(asString(smap, "name", true), vendor);
465            cb.addSchema(parseSchemaBody(smap, schemaName, vendor));
466        }
467        return cb.build();
468    }
469
470    /**
471     * Walk a schema-body map (either a {@code schemas[i]} entry, or a {@code databases[i]}
472     * entry in the schema-less case) into a {@link SchemaModel}. Handles tables, views,
473     * top-level packages, top-level procedures/functions, and silently skips triggers and
474     * package-nested children (see class Javadoc for the rationale).
475     *
476     * <p>The {@code schemaName} is wrapped upstream so the dotted-identifier defense covers
477     * the schema layer too.</p>
478     */
479    private SchemaModel parseSchemaBody(Map<Object, Object> body, String schemaName,
480                                        EDbVendor vendor)
481            throws CatalogInputException {
482        SchemaModel.Builder sb = SchemaModel.builder().name(schemaName);
483
484        // Tables and views are walked in legacy order: tables first, then views. The
485        // SqlflowSQLEnv code combines them into a single list via addAll(), so the
486        // produced TSQLEnv ordering is tables-then-views — preserve that here so the
487        // parity test sees the same insertion order.
488        Object tables = body.get("tables");
489        if (tables != null) {
490            if (!(tables instanceof List)) {
491                throw new CatalogInputException(
492                    "SqlFlow field 'tables' must be an array (got " + typeOf(tables) + ")");
493            }
494            for (Object tobj : (List<?>) tables) {
495                if (!(tobj instanceof Map)) {
496                    throw new CatalogInputException(
497                        "SqlFlow tables[i] must be an object (got " + typeOf(tobj) + ")");
498                }
499                @SuppressWarnings("unchecked")
500                Map<Object, Object> tmap = (Map<Object, Object>) tobj;
501                addTableOrView(sb, tmap, vendor);
502            }
503        }
504        Object views = body.get("views");
505        if (views != null) {
506            if (!(views instanceof List)) {
507                throw new CatalogInputException(
508                    "SqlFlow field 'views' must be an array (got " + typeOf(views) + ")");
509            }
510            for (Object vobj : (List<?>) views) {
511                if (!(vobj instanceof Map)) {
512                    throw new CatalogInputException(
513                        "SqlFlow views[i] must be an object (got " + typeOf(vobj) + ")");
514                }
515                @SuppressWarnings("unchecked")
516                Map<Object, Object> vmap = (Map<Object, Object>) vobj;
517                addTableOrView(sb, vmap, vendor);
518            }
519        }
520
521        // Top-level routines.
522        Object procedures = body.get("procedures");
523        if (procedures != null) {
524            for (Map<Object, Object> rmap : asMapList(procedures, "procedures")) {
525                String name = wrapDottedName(asString(rmap, "name", true), vendor);
526                if (rmap.containsKey("parameterBindings")) {
527                    // Mantis 4962: the presets are read into TSQLEnv by the SQLFlow metadata reader; the catalog
528                    // model has no place for them, so say so instead of dropping them in silence
529                    java.util.logging.Logger.getLogger(SqlflowCatalogInputReader.class.getName()).warning(
530                            "procedures[].parameterBindings of " + name + " is not read by the catalog input; pass"
531                                    + " the metadata as a TSQLEnv (SQLFlow metadata JSON) for parameter presets");
532                }
533                sb.addRoutine(RoutineModel.builder()
534                    .name(name)
535                    .kind(CatalogObjectKind.PROCEDURE)
536                    .build());
537            }
538        }
539        Object functions = body.get("functions");
540        if (functions != null) {
541            for (Map<Object, Object> rmap : asMapList(functions, "functions")) {
542                String name = wrapDottedName(asString(rmap, "name", true), vendor);
543                sb.addRoutine(RoutineModel.builder()
544                    .name(name)
545                    .kind(CatalogObjectKind.FUNCTION)
546                    .build());
547            }
548        }
549        Object packages = body.get("packages");
550        if (packages != null) {
551            for (Map<Object, Object> pmap : asMapList(packages, "packages")) {
552                // Package-nested procedures / functions / triggers are intentionally
553                // dropped: RoutineModel does not yet model package-nested children, and
554                // the SQLEnvCatalogLoader has no path to bind them. The empty package
555                // shell still resolves SCHEMA.PKG references.
556                String name = wrapDottedName(asString(pmap, "name", true), vendor);
557                sb.addRoutine(RoutineModel.builder()
558                    .name(name)
559                    .kind(CatalogObjectKind.PACKAGE)
560                    .build());
561            }
562        }
563
564        // Top-level triggers — see class Javadoc; silently dropped because no Phase 1
565        // RoutineModel.kind exists for triggers and the loader does not synthesize them.
566        // Walk the array shape just to fail cleanly on a malformed export.
567        Object triggers = body.get("triggers");
568        if (triggers != null && !(triggers instanceof List)) {
569            throw new CatalogInputException(
570                "SqlFlow field 'triggers' must be an array (got " + typeOf(triggers) + ")");
571        }
572
573        return sb.build();
574    }
575
576    private void addTableOrView(SchemaModel.Builder sb, Map<Object, Object> entry,
577                                EDbVendor vendor)
578            throws CatalogInputException {
579        String objName = wrapDottedName(asString(entry, "name", true), vendor);
580        String type = asString(entry, "type", false);
581        boolean isView = typeIndicatesView(type);
582        // Column names are NOT wrapped: ModelBackedCatalogProvider builds the qualified
583        // column form as `<tableQ>.<column>` where <tableQ> already has any required
584        // quoting. A column name with an embedded dot would still be ambiguous downstream
585        // — but the legacy loader has the same gap, so parity is preserved without the
586        // extra wrapping here. (Same posture as SqldepCatalogInputReader.)
587        List<ColumnModel> cols = parseColumns(entry, objName);
588        if (isView) {
589            ViewModel.Builder vb = ViewModel.builder().name(objName);
590            for (ColumnModel c : cols) {
591                vb.addColumn(c);
592            }
593            sb.addView(vb.build());
594        } else {
595            TableModel.Builder tb = TableModel.builder().name(objName);
596            for (ColumnModel c : cols) {
597                tb.addColumn(c);
598            }
599            sb.addTable(tb.build());
600        }
601    }
602
603    /**
604     * Case-insensitive ASCII substring check for {@code "view"} in the supplied {@code type}
605     * field — matches the legacy {@code SqlflowSQLEnv.appendTables()}'s
606     * {@code type.toLowerCase().indexOf("view") != -1} test without using
607     * {@link String#toLowerCase()}, which the {@code forbidden-apis} plugin bans inside
608     * {@code catalog/**}. {@link Character#toLowerCase(char)} is locale-insensitive for
609     * ASCII characters and is permitted.
610     */
611    private static boolean typeIndicatesView(String type) {
612        if (type == null) {
613            return false;
614        }
615        final int n = type.length();
616        for (int i = 0; i + 4 <= n; i++) {
617            if (Character.toLowerCase(type.charAt(i)) == 'v'
618                && Character.toLowerCase(type.charAt(i + 1)) == 'i'
619                && Character.toLowerCase(type.charAt(i + 2)) == 'e'
620                && Character.toLowerCase(type.charAt(i + 3)) == 'w') {
621                return true;
622            }
623        }
624        return false;
625    }
626
627    private static List<ColumnModel> parseColumns(Map<Object, Object> tmap, String tableName)
628            throws CatalogInputException {
629        Object columns = tmap.get("columns");
630        java.util.ArrayList<ColumnModel> out = new java.util.ArrayList<ColumnModel>();
631        if (columns == null) {
632            return out;
633        }
634        if (!(columns instanceof List)) {
635            throw new CatalogInputException(
636                "SqlFlow object '" + tableName + "' field 'columns' must be an array (got "
637                    + typeOf(columns) + ")");
638        }
639        for (Object cobj : (List<?>) columns) {
640            if (!(cobj instanceof Map)) {
641                throw new CatalogInputException(
642                    "SqlFlow object '" + tableName + "' columns[i] must be an object (got "
643                        + typeOf(cobj) + ")");
644            }
645            @SuppressWarnings("unchecked")
646            Map<Object, Object> cmap = (Map<Object, Object>) cobj;
647            String colName = asString(cmap, "name", true);
648            ColumnModel.Builder cb = ColumnModel.builder().name(colName);
649            String dt = asString(cmap, "dataType", false);
650            if (dt == null) dt = asString(cmap, "type", false);
651            if (dt != null) cb.dataType(dt);
652            out.add(cb.build());
653        }
654        return out;
655    }
656
657    /**
658     * Cast and walk a JSON value as a list of object maps. Throws {@link CatalogInputException}
659     * with a labelled error when the structure is wrong. Used for {@code procedures} /
660     * {@code functions} / {@code packages}.
661     */
662    @SuppressWarnings("unchecked")
663    private static List<Map<Object, Object>> asMapList(Object value, String fieldLabel)
664            throws CatalogInputException {
665        if (!(value instanceof List)) {
666            throw new CatalogInputException(
667                "SqlFlow field '" + fieldLabel + "' must be an array (got " + typeOf(value) + ")");
668        }
669        List<?> raw = (List<?>) value;
670        java.util.ArrayList<Map<Object, Object>> out = new java.util.ArrayList<Map<Object, Object>>(raw.size());
671        for (Object o : raw) {
672            if (!(o instanceof Map)) {
673                throw new CatalogInputException(
674                    "SqlFlow field '" + fieldLabel + "[i]' must be an object (got "
675                        + typeOf(o) + ")");
676            }
677            out.add((Map<Object, Object>) o);
678        }
679        return out;
680    }
681
682    // ---------- dotted-identifier wrapping ----------
683
684    /**
685     * Wrap a raw identifier with the vendor's open/close quote chars when it contains a
686     * dot. Same defense as {@link SqldepCatalogInputReader#wrapDottedName} — see that
687     * method for the rationale. Vendor → delimiter pair table is duplicated here to keep
688     * {@code catalog/**} free of a compile-time dependency on {@code sqlenv/**}; if a
689     * dialect adds a new {@code TSQLEnv.delimitedChar} entry, mirror it in both places.
690     */
691    private static String wrapDottedName(String raw, EDbVendor vendor) {
692        if (raw == null || raw.isEmpty() || raw.indexOf('.') == -1) {
693            return raw;
694        }
695        char open = vendorOpenDelimiter(vendor);
696        char close = vendorCloseDelimiter(vendor);
697        if (raw.length() >= 2
698            && raw.charAt(0) == open
699            && raw.charAt(raw.length() - 1) == close) {
700            return raw;
701        }
702        return open + raw + close;
703    }
704
705    private static char vendorOpenDelimiter(EDbVendor v) {
706        if (v == null) return '"';
707        switch (v) {
708            case dbvmssql:
709            case dbvazuresql:
710                return '[';
711            case dbvathena:
712            case dbvmysql:
713            case dbvbigquery:
714            case dbvcouchbase:
715            case dbvhive:
716            case dbvimpala:
717            case dbvdatabricks:
718                return '`';
719            case dbvdax:
720                return '\'';
721            default:
722                return '"';
723        }
724    }
725
726    private static char vendorCloseDelimiter(EDbVendor v) {
727        if (v == null) return '"';
728        switch (v) {
729            case dbvmssql:
730            case dbvazuresql:
731                return ']';
732            default:
733                return vendorOpenDelimiter(v);
734        }
735    }
736
737    // ---------- vendor schema-support ----------
738
739    /**
740     * Mirror of {@link gudusoft.gsqlparser.sqlenv.TSQLEnv#supportSchema(EDbVendor)}. Inlined
741     * here so {@code catalog/**} does not import from {@code sqlenv/**} on the read path
742     * (the loader path already uses {@code TSQLEnv.supportSchema} for the symmetric
743     * decision). Kept in sync — if a new schema-less dialect lands in the legacy method
744     * (unlikely; the list has been stable for years), reflect it here too.
745     */
746    private static boolean supportSchema(EDbVendor vendor) {
747        return !(vendor == EDbVendor.dbvmysql
748            || vendor == EDbVendor.dbvteradata
749            || vendor == EDbVendor.dbvhive
750            || vendor == EDbVendor.dbvimpala);
751    }
752
753    // ---------- defaults / source info ----------
754
755    /**
756     * Seed the supplied {@link DefaultsConfig.Builder} from {@code options} non-empty
757     * fields. Returns {@code true} if at least one default was contributed, so the caller
758     * knows whether to call {@link UnifiedCatalogModel.Builder#defaults} at the end. The
759     * server-name capture from JSON layers on top of this seed in the read path.
760     */
761    private static boolean seedOptionDefaults(DefaultsConfig.Builder db,
762                                              CatalogLoadOptions options) {
763        if (options == null) return false;
764        boolean any = false;
765        if (options.defaultCatalog() != null && !options.defaultCatalog().isEmpty()) {
766            db.defaultCatalog(options.defaultCatalog());
767            any = true;
768        }
769        if (options.defaultSchema() != null && !options.defaultSchema().isEmpty()) {
770            db.defaultSchema(options.defaultSchema());
771            any = true;
772        }
773        if (options.defaultServer() != null && !options.defaultServer().isEmpty()) {
774            db.defaultServer(options.defaultServer());
775            any = true;
776        }
777        return any;
778    }
779
780    private static CatalogSourceInfo buildSourceInfo(CatalogInputSource source, long startMillis) {
781        return CatalogSourceInfo.builder()
782            .kind(CatalogInputKind.SQLFLOW_JSON)
783            .name(source.name() != null ? source.name() : "<sqlflow>")
784            .readMillis(System.currentTimeMillis() - startMillis)
785            .build();
786    }
787
788    // ---------- input → string ----------
789
790    private static String readAll(CatalogInputSource source) throws CatalogInputException {
791        try {
792            if (source.inMemoryModel() != null) {
793                throw new CatalogInputException(
794                    "SqlflowCatalogInputReader cannot read in-memory model sources");
795            }
796            if (source.path() != null) {
797                byte[] b = Files.readAllBytes(source.path());
798                return new String(b, StandardCharsets.UTF_8);
799            }
800            byte[] sourceBytes = source.bytes();
801            if (sourceBytes != null) {
802                return new String(sourceBytes, StandardCharsets.UTF_8);
803            }
804            if (source.url() != null) {
805                try (InputStream in = source.url().openStream();
806                     Reader r = new InputStreamReader(in, StandardCharsets.UTF_8)) {
807                    return drain(r);
808                }
809            }
810            if (source.reader() != null) {
811                return drain(source.reader());
812            }
813            throw new CatalogInputException(
814                "SqlflowCatalogInputReader: source has no readable backing");
815        } catch (IOException io) {
816            throw new CatalogInputException(
817                "Failed to read SqlFlow export from " + source.name() + ": " + io.getMessage(),
818                io);
819        }
820    }
821
822    private static String drain(Reader r) throws IOException {
823        BufferedReader br = (r instanceof BufferedReader) ? (BufferedReader) r : new BufferedReader(r);
824        StringBuilder sb = new StringBuilder();
825        char[] buf = new char[4096];
826        int n;
827        while ((n = br.read(buf)) > 0) {
828            sb.append(buf, 0, n);
829        }
830        return sb.toString();
831    }
832
833    // ---------- shared field accessors ----------
834
835    private static String asString(Map<Object, Object> obj, String key, boolean required)
836            throws CatalogInputException {
837        Object v = obj.get(key);
838        if (v == null) {
839            if (required) {
840                throw new CatalogInputException(
841                    "SqlFlow entry missing required field '" + key + "'");
842            }
843            return null;
844        }
845        return v instanceof String ? (String) v : v.toString();
846    }
847
848    private static String typeOf(Object o) {
849        return o == null ? "null" : o.getClass().getSimpleName();
850    }
851
852    /** ServiceLoader-discoverable factory. Plan §13.1. */
853    public static final class Factory implements CatalogInputReaderFactory {
854
855        public Factory() {
856            // Required no-arg constructor for ServiceLoader.
857        }
858
859        @Override
860        public CatalogInputKind kind() {
861            return CatalogInputKind.SQLFLOW_JSON;
862        }
863
864        @Override
865        public CatalogInputReader create() {
866            return new SqlflowCatalogInputReader();
867        }
868    }
869}