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}