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}