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.ConstraintModel;
014import gudusoft.gsqlparser.catalog.input.model.DefaultsConfig;
015import gudusoft.gsqlparser.catalog.input.model.IdentifierConfig;
016import gudusoft.gsqlparser.catalog.input.model.IndexModel;
017import gudusoft.gsqlparser.catalog.input.model.PolicyTagModel;
018import gudusoft.gsqlparser.catalog.input.model.RoutineModel;
019import gudusoft.gsqlparser.catalog.input.model.SchemaModel;
020import gudusoft.gsqlparser.catalog.input.model.SequenceModel;
021import gudusoft.gsqlparser.catalog.input.model.SynonymModel;
022import gudusoft.gsqlparser.catalog.input.model.TableModel;
023import gudusoft.gsqlparser.catalog.input.model.UnifiedCatalogModel;
024import gudusoft.gsqlparser.catalog.input.model.ViewModel;
025import gudusoft.gsqlparser.catalog.runtime.CatalogObjectKind;
026import gudusoft.gsqlparser.sqlenv.IdentifierInputForm;
027import gudusoft.gsqlparser.sqlenv.TSQLEnv;
028import gudusoft.gsqlparser.util.json.JSON;
029
030import java.io.BufferedReader;
031import java.io.IOException;
032import java.io.InputStream;
033import java.io.InputStreamReader;
034import java.io.Reader;
035import java.nio.charset.StandardCharsets;
036import java.nio.file.Files;
037import java.util.Collections;
038import java.util.List;
039import java.util.Map;
040
041/**
042 * Reader for {@link CatalogInputKind#JSON_MANIFEST} sources. Parses a JSON document into
043 * {@link UnifiedCatalogModel} via {@link JSON#parseObject(String)} (the in-tree
044 * {@code gudusoft.gsqlparser.util.json}, no new compile-scope dependency — see spike
045 * {@code docs/designs/catalog-input-interface-spikes/json-dependency.md}).
046 *
047 * <p>The reader walks the parsed {@code Map}/{@code List} tree manually rather than using
048 * reflection-based mapping. Builders apply per-field validation; missing required fields
049 * fail with {@link CatalogInputException}; unknown fields are tolerated for forward
050 * compatibility with manifests that add new keys.</p>
051 *
052 * <p>Manifest schema (key fields, top-level keys are case-sensitive):</p>
053 * <pre>{@code
054 * {
055 *   "apiVersion": "1",                    // optional, defaults to "1"
056 *   "vendor": "oracle",                   // required; matches EDbVendor name with or
057 *                                         // without the "dbv" prefix (case-insensitive)
058 *   "source": { "name": "...", "readMillis": 0 },
059 *   "identifier": { ...IdentifierConfig fields... },
060 *   "defaults": { "catalog": "...", "schema": "...", "server": "..." },
061 *   "catalogs": [
062 *     { "name": "...", "schemas": [
063 *         { "name": "...",
064 *           "tables":     [{ "name": "...", "columns": [...], "constraints": [...],
065 *                            "indexes": [...], "properties": {} }],
066 *           "views":      [{ "name": "...", "definition": "...",
067 *                            "materialized": false, "columns": [...] }],
068 *           "routines":   [{ "name": "...", "kind": "FUNCTION|PROCEDURE|PACKAGE|ROUTINE",
069 *                            "returns": "...", "parameters": [...] }],
070 *           "synonyms":   [{ "name": "...", "target": "schema.object" }],
071 *           "sequences":  [{ "name": "...", "startsWith": 1, "incrementBy": 1 }]
072 *         }
073 *     ]}
074 *   ]
075 * }
076 * }</pre>
077 */
078public final class JsonManifestCatalogInputReader implements CatalogInputReader {
079
080    public JsonManifestCatalogInputReader() {
081    }
082
083    @Override
084    public CatalogInputKind kind() {
085        return CatalogInputKind.JSON_MANIFEST;
086    }
087
088    @Override
089    public boolean supports(CatalogInputSource source, CatalogLoadOptions options) {
090        if (source == null || source.inMemoryModel() != null) {
091            return false;
092        }
093        CatalogInputKind declared = source.declaredKind();
094        if (declared == CatalogInputKind.JSON_MANIFEST || declared == CatalogInputKind.JSON) {
095            return true;
096        }
097        // Default-claim by extension when caller didn't declare a kind. Both Path and
098        // URL inputs are first-class — for URL we walk the file portion of the URI to
099        // ignore a query string or fragment.
100        if (declared == null) {
101            String n = null;
102            if (source.path() != null) {
103                n = source.path().toString();
104            } else if (source.url() != null) {
105                n = source.url().getPath();
106            }
107            if (n != null) {
108                // Only claim plain JSON. JSONC (.jsonc) carries // and /* */ comments
109                // that the in-tree gudusoft.gsqlparser.util.json parser does not handle;
110                // claiming it here would route to a reader that fails on parse. Phase 2
111                // can add a JSONC-stripping reader if needed.
112                return endsWithIgnoreAscii(n, ".json");
113            }
114        }
115        return false;
116    }
117
118    @Override
119    public UnifiedCatalogModel read(CatalogInputSource source, CatalogLoadOptions options)
120            throws CatalogInputException {
121        if (source == null) {
122            throw new CatalogInputException("JsonManifestCatalogInputReader: source is required");
123        }
124        long start = System.currentTimeMillis();
125        String text = readAll(source);
126        Object parsed;
127        try {
128            parsed = JSON.parseObject(text);
129        } catch (RuntimeException ex) {
130            throw new CatalogInputException(
131                "Failed to parse JSON manifest from " + source.name() + ": " + ex.getMessage(),
132                ex);
133        }
134        if (!(parsed instanceof Map)) {
135            throw new CatalogInputException(
136                "JSON manifest root must be an object (got " + typeOf(parsed) + ")");
137        }
138        @SuppressWarnings("unchecked")
139        Map<Object, Object> root = (Map<Object, Object>) parsed;
140
141        try {
142            EDbVendor vendor = parseVendor(asString(root, "vendor", true));
143            UnifiedCatalogModel.Builder mb = UnifiedCatalogModel.builder().vendor(vendor);
144
145            String apiVersion = asString(root, "apiVersion", false);
146            if (apiVersion != null) mb.apiVersion(apiVersion);
147
148            IdentifierConfig identifier = parseIdentifier(asMap(root, "identifier"), vendor);
149            if (identifier != null) mb.identifierConfig(identifier);
150
151            DefaultsConfig defaults = parseDefaults(asMap(root, "defaults"));
152            if (defaults != null) mb.defaults(defaults);
153
154            // A manifest is a catalog export, so its names are the stored names unless the
155            // file says otherwise. A misspelt value throws: guessing the form silently
156            // resolves the wrong objects (design doc, section 6).
157            IdentifierInputForm declaredForm = TSQLEnv.parseNameForm(root.get("nameForm"), null);
158            if (declaredForm != null) {
159                mb.nameForm(declaredForm);
160            } else {
161                mb.defaultNameForm(IdentifierInputForm.CATALOG_STORED);
162            }
163
164            Map<Object, Object> sourceObj = asMap(root, "source");
165            CatalogSourceInfo info = parseSourceInfo(sourceObj, source, start);
166            mb.sourceInfo(info);
167
168            for (Object cobj : asList(root, "catalogs")) {
169                mb.addCatalog(parseCatalog(asMapStrict(cobj, "catalogs[i]")));
170            }
171            return mb.build();
172        } catch (IllegalArgumentException ex) {
173            // Model builders enforce structural invariants (non-empty names, vendor
174            // consistency, etc.) by throwing IllegalArgumentException. Surface those
175            // through the reader's checked-exception channel so callers using
176            // try/catch on CatalogInputException don't get caught off guard.
177            throw new CatalogInputException(
178                "Malformed JSON manifest from " + source.name() + ": " + ex.getMessage(), ex);
179        }
180    }
181
182    // ---------- catalog / schema / leaf parsers ----------
183
184    private CatalogModel parseCatalog(Map<Object, Object> obj) throws CatalogInputException {
185        CatalogModel.Builder b = CatalogModel.builder().name(asString(obj, "name", true));
186        for (Object sobj : asList(obj, "schemas")) {
187            b.addSchema(parseSchema(asMapStrict(sobj, "catalog.schemas[i]")));
188        }
189        return b.build();
190    }
191
192    private SchemaModel parseSchema(Map<Object, Object> obj) throws CatalogInputException {
193        // Allow "" — schema-less dialects (MySQL etc.) — but require the field to be present.
194        String schemaName = asString(obj, "name", true);
195        SchemaModel.Builder b = SchemaModel.builder().name(schemaName);
196        for (Object t : asList(obj, "tables")) {
197            b.addTable(parseTable(asMapStrict(t, "schema.tables[i]")));
198        }
199        for (Object v : asList(obj, "views")) {
200            b.addView(parseView(asMapStrict(v, "schema.views[i]")));
201        }
202        for (Object r : asList(obj, "routines")) {
203            b.addRoutine(parseRoutine(asMapStrict(r, "schema.routines[i]")));
204        }
205        for (Object sy : asList(obj, "synonyms")) {
206            b.addSynonym(parseSynonym(asMapStrict(sy, "schema.synonyms[i]")));
207        }
208        for (Object sq : asList(obj, "sequences")) {
209            b.addSequence(parseSequence(asMapStrict(sq, "schema.sequences[i]")));
210        }
211        return b.build();
212    }
213
214    private TableModel parseTable(Map<Object, Object> obj) throws CatalogInputException {
215        TableModel.Builder tb = TableModel.builder().name(asString(obj, "name", true));
216        for (Object c : asList(obj, "columns")) {
217            tb.addColumn(parseColumn(asMapStrict(c, "table.columns[i]")));
218        }
219        for (Object cs : asList(obj, "constraints")) {
220            tb.addConstraint(parseConstraint(asMapStrict(cs, "table.constraints[i]")));
221        }
222        for (Object ix : asList(obj, "indexes")) {
223            tb.addIndex(parseIndex(asMapStrict(ix, "table.indexes[i]")));
224        }
225        Map<Object, Object> props = asMap(obj, "properties");
226        if (props != null) {
227            for (Map.Entry<Object, Object> e : props.entrySet()) {
228                tb.property(stringify(e.getKey()), e.getValue());
229            }
230        }
231        return tb.build();
232    }
233
234    private ViewModel parseView(Map<Object, Object> obj) throws CatalogInputException {
235        ViewModel.Builder vb = ViewModel.builder().name(asString(obj, "name", true));
236        String def = asString(obj, "definition", false);
237        if (def != null) vb.definition(def);
238        Boolean mat = asBoolean(obj, "materialized");
239        if (mat != null && mat) vb.materialized(true);
240        for (Object c : asList(obj, "columns")) {
241            vb.addColumn(parseColumn(asMapStrict(c, "view.columns[i]")));
242        }
243        return vb.build();
244    }
245
246    private ColumnModel parseColumn(Map<Object, Object> obj) throws CatalogInputException {
247        ColumnModel.Builder cb = ColumnModel.builder().name(asString(obj, "name", true));
248        String dt = asString(obj, "dataType", false);
249        if (dt == null) dt = asString(obj, "type", false);  // tolerate "type" alias
250        if (dt != null) cb.dataType(dt);
251        Boolean nullable = asBoolean(obj, "nullable");
252        if (nullable != null) cb.nullable(nullable);
253        for (Object t : asList(obj, "policyTags")) {
254            cb.addPolicyTag(parsePolicyTag(t));
255        }
256        for (Object t : asList(obj, "tags")) {
257            // "tags" is a shorthand for plain string policy tags.
258            cb.addPolicyTag(parsePolicyTag(t));
259        }
260        return cb.build();
261    }
262
263    private PolicyTagModel parsePolicyTag(Object obj) throws CatalogInputException {
264        if (obj instanceof String) {
265            return PolicyTagModel.builder().name((String) obj).build();
266        }
267        if (obj instanceof Map) {
268            @SuppressWarnings("unchecked")
269            Map<Object, Object> m = (Map<Object, Object>) obj;
270            PolicyTagModel.Builder pb = PolicyTagModel.builder().name(asString(m, "name", true));
271            String namespace = asString(m, "namespace", false);
272            if (namespace != null) pb.namespace(namespace);
273            return pb.build();
274        }
275        throw new CatalogInputException("policyTag entry must be a string or object");
276    }
277
278    private ConstraintModel parseConstraint(Map<Object, Object> obj) throws CatalogInputException {
279        ConstraintModel.Builder cb = ConstraintModel.builder()
280            .type(asString(obj, "type", true));
281        String name = asString(obj, "name", false);
282        if (name != null) cb.name(name);
283        for (Object c : asList(obj, "columns")) {
284            cb.addColumn(stringify(c));
285        }
286        // FK target side (optional; validator enforces FK-specific semantics).
287        String referencedTable = asString(obj, "referencedTable", false);
288        if (referencedTable != null) cb.referencedTable(referencedTable);
289        for (Object c : asList(obj, "referencedColumns")) {
290            cb.addReferencedColumn(stringify(c));
291        }
292        return cb.build();
293    }
294
295    private IndexModel parseIndex(Map<Object, Object> obj) throws CatalogInputException {
296        String name = asString(obj, "name", true);
297        IndexModel.Builder ib = IndexModel.builder().name(name);
298        Boolean unique = asBoolean(obj, "unique");
299        if (unique != null && unique) ib.unique(true);
300        for (Object c : asList(obj, "columns")) {
301            ib.addColumn(stringify(c));
302        }
303        try {
304            return ib.build();
305        } catch (IllegalArgumentException ex) {
306            // IndexModel rejects empty columns lists; surface that through the
307            // reader's checked-exception channel so callers can branch on it.
308            throw new CatalogInputException(
309                "table.indexes[" + name + "]: " + ex.getMessage(), ex);
310        }
311    }
312
313    private RoutineModel parseRoutine(Map<Object, Object> obj) throws CatalogInputException {
314        RoutineModel.Builder rb = RoutineModel.builder().name(asString(obj, "name", true));
315        String kindStr = asString(obj, "kind", true);
316        rb.kind(parseRoutineKind(kindStr));
317        String returns = asString(obj, "returns", false);
318        if (returns != null) rb.returns(returns);
319        for (Object p : asList(obj, "parameters")) {
320            rb.addParameter(parseColumn(asMapStrict(p, "routine.parameters[i]")));
321        }
322        return rb.build();
323    }
324
325    private SynonymModel parseSynonym(Map<Object, Object> obj) throws CatalogInputException {
326        return SynonymModel.builder()
327            .name(asString(obj, "name", true))
328            .targetQualifiedName(asString(obj, "target", true))
329            .build();
330    }
331
332    private SequenceModel parseSequence(Map<Object, Object> obj) throws CatalogInputException {
333        SequenceModel.Builder sb = SequenceModel.builder().name(asString(obj, "name", true));
334        Long startsWith = asLong(obj, "startsWith");
335        if (startsWith != null) sb.startsWith(startsWith);
336        Long incrementBy = asLong(obj, "incrementBy");
337        if (incrementBy != null) sb.incrementBy(incrementBy);
338        return sb.build();
339    }
340
341    private IdentifierConfig parseIdentifier(Map<Object, Object> obj, EDbVendor vendor)
342            throws CatalogInputException {
343        if (obj == null) return null;
344        // Start from the vendor default so a manifest that overrides only one field
345        // (e.g. {"preserveQuotedCase": false}) doesn't accidentally drop the vendor's
346        // fold rules. Each present field replaces the default for that single key;
347        // absent fields stay at their vendor default.
348        IdentifierConfig defaults = IdentifierConfig.defaultsFor(vendor);
349        IdentifierConfig.Builder ib = IdentifierConfig.builder().vendor(vendor)
350            .foldUnquotedToUpper(defaults.foldUnquotedToUpper())
351            .foldUnquotedToLower(defaults.foldUnquotedToLower())
352            .preserveQuotedCase(defaults.preserveQuotedCase())
353            .stripQuotedDelimiters(defaults.stripQuotedDelimiters())
354            .tableCaseSensitive(defaults.tableCaseSensitive())
355            .columnCaseSensitive(defaults.columnCaseSensitive())
356            .mysqlLowerCaseTableNames(defaults.mysqlLowerCaseTableNames())
357            .mssqlCollation(defaults.mssqlCollation());
358        Boolean foldUpper = asBoolean(obj, "foldUnquotedToUpper");
359        if (foldUpper != null) {
360            ib.foldUnquotedToUpper(foldUpper);
361            // The Builder rejects setting both fold flags to true, so reset the opposite.
362            if (foldUpper) ib.foldUnquotedToLower(false);
363        }
364        Boolean foldLower = asBoolean(obj, "foldUnquotedToLower");
365        if (foldLower != null) {
366            ib.foldUnquotedToLower(foldLower);
367            if (foldLower) ib.foldUnquotedToUpper(false);
368        }
369        Boolean preserveQuoted = asBoolean(obj, "preserveQuotedCase");
370        if (preserveQuoted != null) ib.preserveQuotedCase(preserveQuoted);
371        Boolean stripDelim = asBoolean(obj, "stripQuotedDelimiters");
372        if (stripDelim != null) ib.stripQuotedDelimiters(stripDelim);
373        Boolean tableCaseSensitive = asBoolean(obj, "tableCaseSensitive");
374        if (tableCaseSensitive != null) ib.tableCaseSensitive(tableCaseSensitive);
375        Boolean columnCaseSensitive = asBoolean(obj, "columnCaseSensitive");
376        if (columnCaseSensitive != null) ib.columnCaseSensitive(columnCaseSensitive);
377        Long lctn = asLong(obj, "mysqlLowerCaseTableNames");
378        if (lctn != null) ib.mysqlLowerCaseTableNames(lctn.intValue());
379        String collation = asString(obj, "mssqlCollation", false);
380        if (collation != null) ib.mssqlCollation(collation);
381        return ib.build();
382    }
383
384    private DefaultsConfig parseDefaults(Map<Object, Object> obj) throws CatalogInputException {
385        if (obj == null) return null;
386        DefaultsConfig.Builder db = DefaultsConfig.builder();
387        String c = asString(obj, "catalog", false);
388        if (c != null) db.defaultCatalog(c);
389        String s = asString(obj, "schema", false);
390        if (s != null) db.defaultSchema(s);
391        String srv = asString(obj, "server", false);
392        if (srv != null) db.defaultServer(srv);
393        return db.build();
394    }
395
396    private CatalogSourceInfo parseSourceInfo(Map<Object, Object> obj,
397                                              CatalogInputSource source,
398                                              long startMillis) throws CatalogInputException {
399        CatalogSourceInfo.Builder sb = CatalogSourceInfo.builder()
400            .kind(source.declaredKind() != null ? source.declaredKind() : CatalogInputKind.JSON_MANIFEST);
401        Long readMillis = null;
402        if (obj != null) {
403            String name = asString(obj, "name", false);
404            if (name != null) sb.name(name);
405            readMillis = asLong(obj, "readMillis");
406        } else {
407            sb.name(source.name() != null ? source.name() : "<json>");
408        }
409        // Track wall-clock parse duration when manifest didn't carry one.
410        sb.readMillis(readMillis != null ? readMillis : (System.currentTimeMillis() - startMillis));
411        return sb.build();
412    }
413
414    // ---------- input → string ----------
415
416    private String readAll(CatalogInputSource source) throws CatalogInputException {
417        try {
418            if (source.inMemoryModel() != null) {
419                throw new CatalogInputException(
420                    "JsonManifestCatalogInputReader cannot read in-memory model sources");
421            }
422            if (source.path() != null) {
423                byte[] b = Files.readAllBytes(source.path());
424                return new String(b, StandardCharsets.UTF_8);
425            }
426            byte[] sourceBytes = source.bytes();   // defensive copy from the source
427            if (sourceBytes != null) {
428                return new String(sourceBytes, StandardCharsets.UTF_8);
429            }
430            if (source.url() != null) {
431                try (InputStream in = source.url().openStream();
432                     Reader r = new InputStreamReader(in, StandardCharsets.UTF_8)) {
433                    return drain(r);
434                }
435            }
436            if (source.reader() != null) {
437                return drain(source.reader());
438            }
439            throw new CatalogInputException(
440                "JsonManifestCatalogInputReader: source has no readable backing");
441        } catch (IOException io) {
442            throw new CatalogInputException(
443                "Failed to read JSON manifest from " + source.name() + ": " + io.getMessage(), io);
444        }
445    }
446
447    private static String drain(Reader r) throws IOException {
448        BufferedReader br = (r instanceof BufferedReader) ? (BufferedReader) r : new BufferedReader(r);
449        StringBuilder sb = new StringBuilder();
450        char[] buf = new char[4096];
451        int n;
452        while ((n = br.read(buf)) > 0) {
453            sb.append(buf, 0, n);
454        }
455        return sb.toString();
456    }
457
458    // ---------- shared field accessors ----------
459
460    private static String asString(Map<Object, Object> obj, String key, boolean required)
461            throws CatalogInputException {
462        Object v = obj.get(key);
463        if (v == null) {
464            if (required) {
465                throw new CatalogInputException(
466                    "JSON manifest missing required field '" + key + "'");
467            }
468            return null;
469        }
470        return v instanceof String ? (String) v : v.toString();
471    }
472
473    private static Boolean asBoolean(Map<Object, Object> obj, String key) {
474        Object v = obj.get(key);
475        if (v == null) return null;
476        if (v instanceof Boolean) return (Boolean) v;
477        // Tolerate string forms emitted by some JSON encoders.
478        String s = v.toString();
479        if ("true".equals(s)) return Boolean.TRUE;
480        if ("false".equals(s)) return Boolean.FALSE;
481        return null;
482    }
483
484    private static Long asLong(Map<Object, Object> obj, String key) {
485        Object v = obj.get(key);
486        if (v == null) return null;
487        if (v instanceof Number) return ((Number) v).longValue();
488        try {
489            return Long.parseLong(v.toString());
490        } catch (NumberFormatException nfe) {
491            return null;
492        }
493    }
494
495    @SuppressWarnings("unchecked")
496    private static Map<Object, Object> asMap(Map<Object, Object> obj, String key) {
497        Object v = obj.get(key);
498        return v instanceof Map ? (Map<Object, Object>) v : null;
499    }
500
501    @SuppressWarnings("unchecked")
502    private static Map<Object, Object> asMapStrict(Object o, String location)
503            throws CatalogInputException {
504        if (!(o instanceof Map)) {
505            throw new CatalogInputException(location + " must be a JSON object (got "
506                + typeOf(o) + ")");
507        }
508        return (Map<Object, Object>) o;
509    }
510
511    /**
512     * Accessor for an array-typed field. A missing field returns the empty list, but a
513     * present non-list value is malformed and surfaces as {@link CatalogInputException}
514     * — silently dropping it would leave the caller's snapshot incomplete with no signal.
515     */
516    @SuppressWarnings("unchecked")
517    private static List<Object> asList(Map<Object, Object> obj, String key)
518            throws CatalogInputException {
519        Object v = obj.get(key);
520        if (v == null) return Collections.emptyList();
521        if (v instanceof List) return (List<Object>) v;
522        throw new CatalogInputException(
523            "JSON manifest field '" + key + "' must be an array (got " + typeOf(v) + ")");
524    }
525
526    private static String stringify(Object o) {
527        return o == null ? null : (o instanceof String ? (String) o : o.toString());
528    }
529
530    private static String typeOf(Object o) {
531        return o == null ? "null" : o.getClass().getSimpleName();
532    }
533
534    private static EDbVendor parseVendor(String raw) throws CatalogInputException {
535        if (raw == null || raw.isEmpty()) {
536            throw new CatalogInputException("JSON manifest 'vendor' is required");
537        }
538        // Try direct enum match first (e.g. "dbvoracle").
539        for (EDbVendor v : EDbVendor.values()) {
540            if (v.name().equals(raw)) return v;
541        }
542        // Then try with the "dbv" prefix prepended (e.g. "oracle" → "dbvoracle").
543        for (EDbVendor v : EDbVendor.values()) {
544            if (v.name().equals("dbv" + raw)) return v;
545        }
546        // Hand-coded ASCII case-insensitive fallback so users can write "Oracle" or "ORACLE".
547        for (EDbVendor v : EDbVendor.values()) {
548            String n = v.name();
549            if (asciiEqualsIgnoreCase(n, raw) || asciiEqualsIgnoreCase(n, "dbv" + raw)) {
550                return v;
551            }
552        }
553        throw new CatalogInputException(
554            "Unknown vendor '" + raw + "'; expected an EDbVendor name (e.g. 'oracle' or 'dbvoracle')");
555    }
556
557    /**
558     * Parse a routine.kind string into the appropriate {@link CatalogObjectKind}. Only
559     * the four routine-kind values are accepted — TABLE / VIEW / SCHEMA etc. are
560     * structurally invalid here and surface through the reader's
561     * {@link CatalogInputException} channel rather than letting the model builder throw
562     * an unchecked {@link IllegalArgumentException} downstream.
563     */
564    private static CatalogObjectKind parseRoutineKind(String raw) throws CatalogInputException {
565        if (raw == null) {
566            throw new CatalogInputException("routine.kind is required");
567        }
568        for (CatalogObjectKind k : ROUTINE_KINDS) {
569            if (k.name().equals(raw)) return k;
570        }
571        // ASCII case-insensitive fallback (forbidden-apis bans equalsIgnoreCase).
572        for (CatalogObjectKind k : ROUTINE_KINDS) {
573            if (asciiEqualsIgnoreCase(k.name(), raw)) return k;
574        }
575        throw new CatalogInputException(
576            "Unknown routine kind '" + raw + "'; expected FUNCTION / PROCEDURE / PACKAGE / ROUTINE");
577    }
578
579    private static final CatalogObjectKind[] ROUTINE_KINDS = new CatalogObjectKind[]{
580        CatalogObjectKind.FUNCTION,
581        CatalogObjectKind.PROCEDURE,
582        CatalogObjectKind.PACKAGE,
583        CatalogObjectKind.ROUTINE,
584    };
585
586    /**
587     * ASCII-only case-insensitive compare. The forbidden-apis Maven plugin (plan §9.5)
588     * bans {@link String#equalsIgnoreCase(String)} inside {@code catalog/**} because
589     * it's normally a flag for misuse on identifier folding. JSON keyword parsing
590     * genuinely benefits from a relaxed match (manifests are written by humans), so we
591     * hand-code the comparison instead of routing through {@code IdentifierService}
592     * (which would be misleading: this is enum-tag matching, not identifier semantics).
593     */
594    private static boolean asciiEqualsIgnoreCase(String a, String b) {
595        if (a == null || b == null) return a == b;
596        int len = a.length();
597        if (b.length() != len) return false;
598        for (int i = 0; i < len; i++) {
599            char ca = a.charAt(i);
600            char cb = b.charAt(i);
601            if (ca == cb) continue;
602            char la = (ca >= 'A' && ca <= 'Z') ? (char) (ca + 32) : ca;
603            char lb = (cb >= 'A' && cb <= 'Z') ? (char) (cb + 32) : cb;
604            if (la != lb) return false;
605        }
606        return true;
607    }
608
609    private static boolean endsWithIgnoreAscii(String s, String suffix) {
610        if (s.length() < suffix.length()) return false;
611        return asciiEqualsIgnoreCase(s.substring(s.length() - suffix.length()), suffix);
612    }
613
614    /** ServiceLoader-discoverable factory. */
615    public static final class Factory implements CatalogInputReaderFactory {
616
617        public Factory() {
618            // Required no-arg constructor for ServiceLoader.
619        }
620
621        @Override
622        public CatalogInputKind kind() {
623            return CatalogInputKind.JSON_MANIFEST;
624        }
625
626        @Override
627        public CatalogInputReader create() {
628            return new JsonManifestCatalogInputReader();
629        }
630    }
631}