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