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        // FK target side (optional; validator enforces FK-specific semantics).
275        String referencedTable = asString(obj, "referencedTable", false);
276        if (referencedTable != null) cb.referencedTable(referencedTable);
277        for (Object c : asList(obj, "referencedColumns")) {
278            cb.addReferencedColumn(stringify(c));
279        }
280        return cb.build();
281    }
282
283    private IndexModel parseIndex(Map<Object, Object> obj) throws CatalogInputException {
284        String name = asString(obj, "name", true);
285        IndexModel.Builder ib = IndexModel.builder().name(name);
286        Boolean unique = asBoolean(obj, "unique");
287        if (unique != null && unique) ib.unique(true);
288        for (Object c : asList(obj, "columns")) {
289            ib.addColumn(stringify(c));
290        }
291        try {
292            return ib.build();
293        } catch (IllegalArgumentException ex) {
294            // IndexModel rejects empty columns lists; surface that through the
295            // reader's checked-exception channel so callers can branch on it.
296            throw new CatalogInputException(
297                "table.indexes[" + name + "]: " + ex.getMessage(), ex);
298        }
299    }
300
301    private RoutineModel parseRoutine(Map<Object, Object> obj) throws CatalogInputException {
302        RoutineModel.Builder rb = RoutineModel.builder().name(asString(obj, "name", true));
303        String kindStr = asString(obj, "kind", true);
304        rb.kind(parseRoutineKind(kindStr));
305        String returns = asString(obj, "returns", false);
306        if (returns != null) rb.returns(returns);
307        for (Object p : asList(obj, "parameters")) {
308            rb.addParameter(parseColumn(asMapStrict(p, "routine.parameters[i]")));
309        }
310        return rb.build();
311    }
312
313    private SynonymModel parseSynonym(Map<Object, Object> obj) throws CatalogInputException {
314        return SynonymModel.builder()
315            .name(asString(obj, "name", true))
316            .targetQualifiedName(asString(obj, "target", true))
317            .build();
318    }
319
320    private SequenceModel parseSequence(Map<Object, Object> obj) throws CatalogInputException {
321        SequenceModel.Builder sb = SequenceModel.builder().name(asString(obj, "name", true));
322        Long startsWith = asLong(obj, "startsWith");
323        if (startsWith != null) sb.startsWith(startsWith);
324        Long incrementBy = asLong(obj, "incrementBy");
325        if (incrementBy != null) sb.incrementBy(incrementBy);
326        return sb.build();
327    }
328
329    private IdentifierConfig parseIdentifier(Map<Object, Object> obj, EDbVendor vendor)
330            throws CatalogInputException {
331        if (obj == null) return null;
332        // Start from the vendor default so a manifest that overrides only one field
333        // (e.g. {"preserveQuotedCase": false}) doesn't accidentally drop the vendor's
334        // fold rules. Each present field replaces the default for that single key;
335        // absent fields stay at their vendor default.
336        IdentifierConfig defaults = IdentifierConfig.defaultsFor(vendor);
337        IdentifierConfig.Builder ib = IdentifierConfig.builder().vendor(vendor)
338            .foldUnquotedToUpper(defaults.foldUnquotedToUpper())
339            .foldUnquotedToLower(defaults.foldUnquotedToLower())
340            .preserveQuotedCase(defaults.preserveQuotedCase())
341            .stripQuotedDelimiters(defaults.stripQuotedDelimiters())
342            .tableCaseSensitive(defaults.tableCaseSensitive())
343            .columnCaseSensitive(defaults.columnCaseSensitive())
344            .mysqlLowerCaseTableNames(defaults.mysqlLowerCaseTableNames())
345            .mssqlCollation(defaults.mssqlCollation());
346        Boolean foldUpper = asBoolean(obj, "foldUnquotedToUpper");
347        if (foldUpper != null) {
348            ib.foldUnquotedToUpper(foldUpper);
349            // The Builder rejects setting both fold flags to true, so reset the opposite.
350            if (foldUpper) ib.foldUnquotedToLower(false);
351        }
352        Boolean foldLower = asBoolean(obj, "foldUnquotedToLower");
353        if (foldLower != null) {
354            ib.foldUnquotedToLower(foldLower);
355            if (foldLower) ib.foldUnquotedToUpper(false);
356        }
357        Boolean preserveQuoted = asBoolean(obj, "preserveQuotedCase");
358        if (preserveQuoted != null) ib.preserveQuotedCase(preserveQuoted);
359        Boolean stripDelim = asBoolean(obj, "stripQuotedDelimiters");
360        if (stripDelim != null) ib.stripQuotedDelimiters(stripDelim);
361        Boolean tableCaseSensitive = asBoolean(obj, "tableCaseSensitive");
362        if (tableCaseSensitive != null) ib.tableCaseSensitive(tableCaseSensitive);
363        Boolean columnCaseSensitive = asBoolean(obj, "columnCaseSensitive");
364        if (columnCaseSensitive != null) ib.columnCaseSensitive(columnCaseSensitive);
365        Long lctn = asLong(obj, "mysqlLowerCaseTableNames");
366        if (lctn != null) ib.mysqlLowerCaseTableNames(lctn.intValue());
367        String collation = asString(obj, "mssqlCollation", false);
368        if (collation != null) ib.mssqlCollation(collation);
369        return ib.build();
370    }
371
372    private DefaultsConfig parseDefaults(Map<Object, Object> obj) throws CatalogInputException {
373        if (obj == null) return null;
374        DefaultsConfig.Builder db = DefaultsConfig.builder();
375        String c = asString(obj, "catalog", false);
376        if (c != null) db.defaultCatalog(c);
377        String s = asString(obj, "schema", false);
378        if (s != null) db.defaultSchema(s);
379        String srv = asString(obj, "server", false);
380        if (srv != null) db.defaultServer(srv);
381        return db.build();
382    }
383
384    private CatalogSourceInfo parseSourceInfo(Map<Object, Object> obj,
385                                              CatalogInputSource source,
386                                              long startMillis) throws CatalogInputException {
387        CatalogSourceInfo.Builder sb = CatalogSourceInfo.builder()
388            .kind(source.declaredKind() != null ? source.declaredKind() : CatalogInputKind.JSON_MANIFEST);
389        Long readMillis = null;
390        if (obj != null) {
391            String name = asString(obj, "name", false);
392            if (name != null) sb.name(name);
393            readMillis = asLong(obj, "readMillis");
394        } else {
395            sb.name(source.name() != null ? source.name() : "<json>");
396        }
397        // Track wall-clock parse duration when manifest didn't carry one.
398        sb.readMillis(readMillis != null ? readMillis : (System.currentTimeMillis() - startMillis));
399        return sb.build();
400    }
401
402    // ---------- input → string ----------
403
404    private String readAll(CatalogInputSource source) throws CatalogInputException {
405        try {
406            if (source.inMemoryModel() != null) {
407                throw new CatalogInputException(
408                    "JsonManifestCatalogInputReader cannot read in-memory model sources");
409            }
410            if (source.path() != null) {
411                byte[] b = Files.readAllBytes(source.path());
412                return new String(b, StandardCharsets.UTF_8);
413            }
414            byte[] sourceBytes = source.bytes();   // defensive copy from the source
415            if (sourceBytes != null) {
416                return new String(sourceBytes, StandardCharsets.UTF_8);
417            }
418            if (source.url() != null) {
419                try (InputStream in = source.url().openStream();
420                     Reader r = new InputStreamReader(in, StandardCharsets.UTF_8)) {
421                    return drain(r);
422                }
423            }
424            if (source.reader() != null) {
425                return drain(source.reader());
426            }
427            throw new CatalogInputException(
428                "JsonManifestCatalogInputReader: source has no readable backing");
429        } catch (IOException io) {
430            throw new CatalogInputException(
431                "Failed to read JSON manifest from " + source.name() + ": " + io.getMessage(), io);
432        }
433    }
434
435    private static String drain(Reader r) throws IOException {
436        BufferedReader br = (r instanceof BufferedReader) ? (BufferedReader) r : new BufferedReader(r);
437        StringBuilder sb = new StringBuilder();
438        char[] buf = new char[4096];
439        int n;
440        while ((n = br.read(buf)) > 0) {
441            sb.append(buf, 0, n);
442        }
443        return sb.toString();
444    }
445
446    // ---------- shared field accessors ----------
447
448    private static String asString(Map<Object, Object> obj, String key, boolean required)
449            throws CatalogInputException {
450        Object v = obj.get(key);
451        if (v == null) {
452            if (required) {
453                throw new CatalogInputException(
454                    "JSON manifest missing required field '" + key + "'");
455            }
456            return null;
457        }
458        return v instanceof String ? (String) v : v.toString();
459    }
460
461    private static Boolean asBoolean(Map<Object, Object> obj, String key) {
462        Object v = obj.get(key);
463        if (v == null) return null;
464        if (v instanceof Boolean) return (Boolean) v;
465        // Tolerate string forms emitted by some JSON encoders.
466        String s = v.toString();
467        if ("true".equals(s)) return Boolean.TRUE;
468        if ("false".equals(s)) return Boolean.FALSE;
469        return null;
470    }
471
472    private static Long asLong(Map<Object, Object> obj, String key) {
473        Object v = obj.get(key);
474        if (v == null) return null;
475        if (v instanceof Number) return ((Number) v).longValue();
476        try {
477            return Long.parseLong(v.toString());
478        } catch (NumberFormatException nfe) {
479            return null;
480        }
481    }
482
483    @SuppressWarnings("unchecked")
484    private static Map<Object, Object> asMap(Map<Object, Object> obj, String key) {
485        Object v = obj.get(key);
486        return v instanceof Map ? (Map<Object, Object>) v : null;
487    }
488
489    @SuppressWarnings("unchecked")
490    private static Map<Object, Object> asMapStrict(Object o, String location)
491            throws CatalogInputException {
492        if (!(o instanceof Map)) {
493            throw new CatalogInputException(location + " must be a JSON object (got "
494                + typeOf(o) + ")");
495        }
496        return (Map<Object, Object>) o;
497    }
498
499    /**
500     * Accessor for an array-typed field. A missing field returns the empty list, but a
501     * present non-list value is malformed and surfaces as {@link CatalogInputException}
502     * — silently dropping it would leave the caller's snapshot incomplete with no signal.
503     */
504    @SuppressWarnings("unchecked")
505    private static List<Object> asList(Map<Object, Object> obj, String key)
506            throws CatalogInputException {
507        Object v = obj.get(key);
508        if (v == null) return Collections.emptyList();
509        if (v instanceof List) return (List<Object>) v;
510        throw new CatalogInputException(
511            "JSON manifest field '" + key + "' must be an array (got " + typeOf(v) + ")");
512    }
513
514    private static String stringify(Object o) {
515        return o == null ? null : (o instanceof String ? (String) o : o.toString());
516    }
517
518    private static String typeOf(Object o) {
519        return o == null ? "null" : o.getClass().getSimpleName();
520    }
521
522    private static EDbVendor parseVendor(String raw) throws CatalogInputException {
523        if (raw == null || raw.isEmpty()) {
524            throw new CatalogInputException("JSON manifest 'vendor' is required");
525        }
526        // Try direct enum match first (e.g. "dbvoracle").
527        for (EDbVendor v : EDbVendor.values()) {
528            if (v.name().equals(raw)) return v;
529        }
530        // Then try with the "dbv" prefix prepended (e.g. "oracle" → "dbvoracle").
531        for (EDbVendor v : EDbVendor.values()) {
532            if (v.name().equals("dbv" + raw)) return v;
533        }
534        // Hand-coded ASCII case-insensitive fallback so users can write "Oracle" or "ORACLE".
535        for (EDbVendor v : EDbVendor.values()) {
536            String n = v.name();
537            if (asciiEqualsIgnoreCase(n, raw) || asciiEqualsIgnoreCase(n, "dbv" + raw)) {
538                return v;
539            }
540        }
541        throw new CatalogInputException(
542            "Unknown vendor '" + raw + "'; expected an EDbVendor name (e.g. 'oracle' or 'dbvoracle')");
543    }
544
545    /**
546     * Parse a routine.kind string into the appropriate {@link CatalogObjectKind}. Only
547     * the four routine-kind values are accepted — TABLE / VIEW / SCHEMA etc. are
548     * structurally invalid here and surface through the reader's
549     * {@link CatalogInputException} channel rather than letting the model builder throw
550     * an unchecked {@link IllegalArgumentException} downstream.
551     */
552    private static CatalogObjectKind parseRoutineKind(String raw) throws CatalogInputException {
553        if (raw == null) {
554            throw new CatalogInputException("routine.kind is required");
555        }
556        for (CatalogObjectKind k : ROUTINE_KINDS) {
557            if (k.name().equals(raw)) return k;
558        }
559        // ASCII case-insensitive fallback (forbidden-apis bans equalsIgnoreCase).
560        for (CatalogObjectKind k : ROUTINE_KINDS) {
561            if (asciiEqualsIgnoreCase(k.name(), raw)) return k;
562        }
563        throw new CatalogInputException(
564            "Unknown routine kind '" + raw + "'; expected FUNCTION / PROCEDURE / PACKAGE / ROUTINE");
565    }
566
567    private static final CatalogObjectKind[] ROUTINE_KINDS = new CatalogObjectKind[]{
568        CatalogObjectKind.FUNCTION,
569        CatalogObjectKind.PROCEDURE,
570        CatalogObjectKind.PACKAGE,
571        CatalogObjectKind.ROUTINE,
572    };
573
574    /**
575     * ASCII-only case-insensitive compare. The forbidden-apis Maven plugin (plan §9.5)
576     * bans {@link String#equalsIgnoreCase(String)} inside {@code catalog/**} because
577     * it's normally a flag for misuse on identifier folding. JSON keyword parsing
578     * genuinely benefits from a relaxed match (manifests are written by humans), so we
579     * hand-code the comparison instead of routing through {@code IdentifierService}
580     * (which would be misleading: this is enum-tag matching, not identifier semantics).
581     */
582    private static boolean asciiEqualsIgnoreCase(String a, String b) {
583        if (a == null || b == null) return a == b;
584        int len = a.length();
585        if (b.length() != len) return false;
586        for (int i = 0; i < len; i++) {
587            char ca = a.charAt(i);
588            char cb = b.charAt(i);
589            if (ca == cb) continue;
590            char la = (ca >= 'A' && ca <= 'Z') ? (char) (ca + 32) : ca;
591            char lb = (cb >= 'A' && cb <= 'Z') ? (char) (cb + 32) : cb;
592            if (la != lb) return false;
593        }
594        return true;
595    }
596
597    private static boolean endsWithIgnoreAscii(String s, String suffix) {
598        if (s.length() < suffix.length()) return false;
599        return asciiEqualsIgnoreCase(s.substring(s.length() - suffix.length()), suffix);
600    }
601
602    /** ServiceLoader-discoverable factory. */
603    public static final class Factory implements CatalogInputReaderFactory {
604
605        public Factory() {
606            // Required no-arg constructor for ServiceLoader.
607        }
608
609        @Override
610        public CatalogInputKind kind() {
611            return CatalogInputKind.JSON_MANIFEST;
612        }
613
614        @Override
615        public CatalogInputReader create() {
616            return new JsonManifestCatalogInputReader();
617        }
618    }
619}