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