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.DefaultsConfig; 014import gudusoft.gsqlparser.catalog.input.model.RoutineModel; 015import gudusoft.gsqlparser.catalog.input.model.SchemaModel; 016import gudusoft.gsqlparser.catalog.input.model.TableModel; 017import gudusoft.gsqlparser.catalog.input.model.UnifiedCatalogModel; 018import gudusoft.gsqlparser.catalog.input.model.ViewModel; 019import gudusoft.gsqlparser.catalog.runtime.CatalogObjectKind; 020import gudusoft.gsqlparser.util.json.JSON; 021 022import java.io.BufferedReader; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.InputStreamReader; 026import java.io.Reader; 027import java.nio.charset.StandardCharsets; 028import java.nio.file.Files; 029import java.util.List; 030import java.util.Map; 031 032/** 033 * Reader for {@link CatalogInputKind#SQLFLOW_JSON} sources — the JSON shape the legacy 034 * {@code SqlflowSQLEnv} consumes (plan §11 T2.A.3 / second Phase 2.A static-file adapter). 035 * 036 * <p>Per plan §6 / §11.2 this reader funnels the SqlFlow export shape into the same 037 * {@link UnifiedCatalogModel} the JSON-manifest and SQLDep readers use; the eager bridge 038 * then materializes a {@code TSQLEnv} byte-for-byte equivalent (for the SELECT smoke 039 * surface) to what the legacy {@code SqlflowSQLEnv} would build (parity test in 040 * {@code SqlflowLegacyParityTest}). The legacy path remains untouched per plan §8.5.</p> 041 * 042 * <h3>SqlFlow export schema</h3> 043 * 044 * <p>The root JSON is the SqlFlow "server" object. Three layouts depending on dialect:</p> 045 * 046 * <h4>Layout A — catalogs and schemas (Oracle / MSSQL / PostgreSQL / BigQuery / …)</h4> 047 * <pre>{@code 048 * { 049 * "name": "<server label>", 050 * "dbVendor": "dbvoracle", // optional vendor override (informational) 051 * "databases": [ 052 * { 053 * "name": "ORCL", 054 * "schemas": [ 055 * { 056 * "name": "HR", 057 * "tables": [{"name": "EMPLOYEES", "type": "TABLE", 058 * "columns": [{"name": "EMPLOYEE_ID"}, ...]}, ...], 059 * "views": [{"name": "EMP_DETAILS_VIEW", "type": "VIEW", 060 * "columns": [...]}, ...], 061 * "packages": [{"name": "HR_PKG", 062 * "procedures": [...], "functions": [...], "triggers": [...]}], 063 * "procedures": [{"name": "ADD_EMP"}], 064 * "functions": [{"name": "GET_SAL"}], 065 * "triggers": [{"name": "AUDIT_TRG"}] 066 * } 067 * ] 068 * } 069 * ] 070 * } 071 * }</pre> 072 * 073 * <h4>Layout B — catalogs only (MySQL / Teradata / Hive / Impala — {@code supportSchema=false})</h4> 074 * <pre>{@code 075 * { 076 * "databases": [ 077 * { 078 * "name": "mydb", 079 * "tables": [...], 080 * "views": [...], 081 * "packages": [...], 082 * "procedures": [...], 083 * "functions": [...], 084 * "triggers": [...] 085 * } 086 * ] 087 * } 088 * }</pre> 089 * 090 * <h4>Layout C — schemas only (no catalogs)</h4> 091 * 092 * <p>The legacy {@code SqlflowSQLEnv} guards Layout C with {@code supportCatalog(vendor)}, 093 * which currently returns {@code true} for every dialect. This branch is therefore dead 094 * in production, but a SqlFlow export pinned to that shape is still tolerated here for 095 * defensive reasons — the model is built under the {@code TSQLEnv.DEFAULT_DB_NAME} 096 * catalog the way the legacy code would.</p> 097 * 098 * <h3>Mapping rules</h3> 099 * 100 * <ul> 101 * <li>{@code tables[]} and {@code views[]} are walked in <strong>insertion order</strong> 102 * (tables first, views second), matching the legacy {@code appendTables} method. 103 * For each entry the {@code type} field is examined: if it contains the substring 104 * {@code "view"} (case-insensitive ASCII) the entry is recorded as a 105 * {@link ViewModel}; otherwise as a {@link TableModel}. Array membership is 106 * informational only — the {@code type} field wins.</li> 107 * <li>{@code procedures[]} → {@link RoutineModel} with {@link CatalogObjectKind#PROCEDURE}.</li> 108 * <li>{@code functions[]} → {@link RoutineModel} with {@link CatalogObjectKind#FUNCTION}.</li> 109 * <li>{@code packages[]} → {@link RoutineModel} with {@link CatalogObjectKind#PACKAGE}. 110 * The package's nested {@code procedures[]} / {@code functions[]} / {@code triggers[]} 111 * have no Phase 1 SPI representation and are dropped (the legacy loader binds them 112 * inside the {@code TSQLEnv}'s package node — that nesting belongs in a follow-up 113 * extension to {@link RoutineModel}). The empty package shell is still present so 114 * callers that resolve {@code SCHEMA.PKG} succeed.</li> 115 * <li>{@code triggers[]} (top-level or package-nested) — there is no Phase 1 116 * {@code RoutineModel.kind} mapping for triggers and the {@code SQLEnvCatalogLoader} 117 * does not synthesize them. They are dropped silently. SELECT-only smoke tests are 118 * unaffected (triggers do not participate in {@code TSQLResolver2} formatter 119 * output for {@code SELECT}/{@code JOIN} queries); future Phase 2 work that needs 120 * trigger metadata should extend the SPI.</li> 121 * <li>The {@code dbVendor} JSON field is informational. The reader honors 122 * {@link CatalogLoadOptions#vendor()} as the single source of truth (defaulting to 123 * {@link EDbVendor#dbvoracle} when the option is unset, matching SqlFlow's historical 124 * Oracle-leaning default). A mismatch between the JSON's {@code dbVendor} and the 125 * option's vendor is not flagged here — {@code CatalogModelValidator} catches the 126 * harmful identifier-bypass cases through its normalize round-trip.</li> 127 * </ul> 128 * 129 * <h3>Dispatch</h3> 130 * 131 * <p>The reader is dispatch-by-{@link CatalogInputKind#SQLFLOW_JSON} only — callers must 132 * tag the source explicitly. There is no auto-claim heuristic on JSON structure (the 133 * SqlFlow shape and the SQLDep shape both top-level objects with a {@code databases} 134 * array would collide); the explicit kind tag is the contract.</p> 135 * 136 * <h3>Dotted-identifier defense</h3> 137 * 138 * <p>Identifier names with embedded dots are wrapped with the vendor's quote characters 139 * before storage. Without the wrap, {@code ModelBackedCatalogProvider} later concatenates 140 * segments with {@code .} into qualified strings (e.g. {@code catalog.schema.A.B}) that 141 * {@code CatalogIdentifierPolicy.parse} would split into four segments instead of three, 142 * and the runtime would never resolve the SQL reference {@code "A.B"}. Mirrors the 143 * {@code SQLDepSQLEnv} defense and the same wrapping policy as {@link SqldepCatalogInputReader}. 144 * The vendor → delimiter-pair table is duplicated from {@code TSQLEnv.delimitedChar} to 145 * keep {@code catalog/**} free of a compile-time dependency on the {@code sqlenv} runtime 146 * layer.</p> 147 */ 148public final class SqlflowCatalogInputReader implements CatalogInputReader { 149 150 public SqlflowCatalogInputReader() { 151 } 152 153 @Override 154 public CatalogInputKind kind() { 155 return CatalogInputKind.SQLFLOW_JSON; 156 } 157 158 @Override 159 public boolean supports(CatalogInputSource source, CatalogLoadOptions options) { 160 if (source == null || source.inMemoryModel() != null) { 161 return false; 162 } 163 return source.declaredKind() == CatalogInputKind.SQLFLOW_JSON; 164 } 165 166 @Override 167 public UnifiedCatalogModel read(CatalogInputSource source, CatalogLoadOptions options) 168 throws CatalogInputException { 169 if (source == null) { 170 throw new CatalogInputException("SqlflowCatalogInputReader: source is required"); 171 } 172 final EDbVendor vendor = (options != null && options.vendor() != null) 173 ? options.vendor() 174 : EDbVendor.dbvoracle; // SqlFlow exports historically default to Oracle. 175 176 long start = System.currentTimeMillis(); 177 String text = readAll(source); 178 Object parsed; 179 try { 180 parsed = JSON.parseObject(text); 181 } catch (RuntimeException ex) { 182 throw new CatalogInputException( 183 "Failed to parse SqlFlow JSON from " + source.name() + ": " + ex.getMessage(), 184 ex); 185 } 186 if (!(parsed instanceof Map)) { 187 throw new CatalogInputException( 188 "SqlFlow root must be an object (got " + typeOf(parsed) + ")"); 189 } 190 @SuppressWarnings("unchecked") 191 Map<Object, Object> root = (Map<Object, Object>) parsed; 192 193 try { 194 // A file may declare its names as catalog-stored (root "nameForm"). Everything 195 // below, and the model it builds, reads names as SQL text, so convert once here; 196 // a misspelt declaration surfaces as CatalogInputException via the catch below. 197 gudusoft.gsqlparser.sqlenv.parser.sqlflow.SqlflowNameForm.toLexicalInPlace(vendor, root); 198 199 UnifiedCatalogModel.Builder mb = UnifiedCatalogModel.builder().vendor(vendor); 200 mb.sourceInfo(buildSourceInfo(source, start)); 201 202 // Track defaults in one place so we can layer (options → JSON server name) 203 // without reading the model builder back. Writes to mb.defaults(...) happen 204 // exactly once at the end. 205 DefaultsConfig.Builder defaultsBuilder = DefaultsConfig.builder(); 206 boolean anyDefault = seedOptionDefaults(defaultsBuilder, options); 207 boolean serverNameSeeded = options != null && options.defaultServer() != null 208 && !options.defaultServer().isEmpty(); 209 210 // The canonical SqlFlow ingester export wraps one or more server objects in 211 // a {createdBy, servers:[…]} envelope (see TJSONSQLEnvParser.getJSONSQLEnv). 212 // Each entry of `servers[]` is exactly the shape SqlflowSQLEnv directly 213 // accepts. When the envelope is present, walk every server and merge its 214 // catalogs into a single UnifiedCatalogModel — the legacy parser produces 215 // one TSQLEnv per server, but the SPI's single-model contract folds them 216 // together. The bare server-object-as-root form (what SqlflowSQLEnv was 217 // historically constructed with directly) is also tolerated so callers that 218 // already unwrapped the envelope themselves keep working. 219 // 220 // Catalog-name dedup (codex P2 round 2): two servers that ship a database 221 // with the same name produce duplicate top-level CatalogModels, which the 222 // CatalogModelValidator flags as an ERROR. Track seen names and reject the 223 // ambiguous case explicitly with a clear message — the legacy parser builds 224 // one TSQLEnv per server so the conflict is naturally avoided; here it must 225 // be surfaced for the single-model contract. 226 java.util.HashSet<String> catalogNames = new java.util.HashSet<String>(); 227 Object servers = root.get("servers"); 228 if (servers != null) { 229 if (!(servers instanceof List)) { 230 throw new CatalogInputException( 231 "SqlFlow field 'servers' must be an array (got " 232 + typeOf(servers) + ")"); 233 } 234 for (Object sobj : (List<?>) servers) { 235 if (!(sobj instanceof Map)) { 236 throw new CatalogInputException( 237 "SqlFlow servers[i] must be an object (got " 238 + typeOf(sobj) + ")"); 239 } 240 @SuppressWarnings("unchecked") 241 Map<Object, Object> server = (Map<Object, Object>) sobj; 242 // Codex P2: preserve the server's `name` as DefaultsConfig.defaultServer 243 // when the caller did not supply one, mirroring the legacy 244 // SqlflowSQLEnv.initSQLEnv() call to setDefaultServerName(server.get("name")). 245 // First server wins to avoid clobbering when multiple servers ship in 246 // one envelope (the legacy code makes one TSQLEnv per server, so the 247 // analogue here is "first server's name represents this model"). 248 if (!serverNameSeeded) { 249 String serverName = asString(server, "name", false); 250 if (serverName != null && !serverName.isEmpty()) { 251 defaultsBuilder.defaultServer(serverName); 252 anyDefault = true; 253 serverNameSeeded = true; 254 } 255 } 256 // Codex P2 round 2: layout decision is per-server. The legacy parser 257 // constructs each SqlflowSQLEnv with the server's own dbVendor; if the 258 // JSON has dbVendor=dbvmysql but options.vendor=dbvoracle, the legacy 259 // walks tables from databases[] directly while our previous single 260 // precomputed layout would look for schemas[] and silently drop tables. 261 // Honor the per-server vendor for supportsSchema only — the model's own 262 // vendor stays options.vendor (the SPI single-model contract precludes 263 // mixed-vendor models). 264 EDbVendor serverVendor = readServerVendor(server, vendor); 265 boolean serverSupportsSchema = supportSchema(serverVendor); 266 parseServerBody(server, vendor, serverSupportsSchema, mb, catalogNames); 267 } 268 } else { 269 // No envelope — root IS the server body (legacy direct-construction shape). 270 // Try the same bare-root server-name capture as the envelope path so a 271 // SqlFlow JSON that was hand-edited down to a single server also preserves 272 // its `name` field. 273 if (!serverNameSeeded) { 274 String serverName = asString(root, "name", false); 275 if (serverName != null && !serverName.isEmpty()) { 276 defaultsBuilder.defaultServer(serverName); 277 anyDefault = true; 278 } 279 } 280 EDbVendor serverVendor = readServerVendor(root, vendor); 281 boolean serverSupportsSchema = supportSchema(serverVendor); 282 parseServerBody(root, vendor, serverSupportsSchema, mb, catalogNames); 283 } 284 285 if (anyDefault) { 286 mb.defaults(defaultsBuilder.build()); 287 } 288 return mb.build(); 289 } catch (IllegalArgumentException ex) { 290 // Model builders enforce structural invariants (non-empty names, etc.) by 291 // throwing IllegalArgumentException. Surface those through the reader's 292 // checked-exception channel so callers using try/catch on 293 // CatalogInputException don't get caught off guard. Same posture as the 294 // SQLDep reader. 295 throw new CatalogInputException( 296 "Malformed SqlFlow export from " + source.name() + ": " + ex.getMessage(), ex); 297 } 298 } 299 300 /** 301 * Walk one SqlFlow server body — the JSON object that {@code SqlflowSQLEnv} would be 302 * constructed with directly. Same shape regardless of whether it came from the outer 303 * {@code servers[]} envelope or from a bare server-as-root JSON. 304 * 305 * <p>{@code seenCatalogNames} is the running set across server walks so a multi-server 306 * envelope can reject the ambiguous case (two servers ship a database with the same 307 * name) with a clear {@link CatalogInputException}, rather than producing a model 308 * that fails the validator's duplicate-catalog ERROR check downstream. Pass an empty 309 * set when called for a single-server body (validator still catches in-server 310 * duplicates).</p> 311 */ 312 private void parseServerBody(Map<Object, Object> body, EDbVendor vendor, 313 boolean supportsSchema, UnifiedCatalogModel.Builder mb, 314 java.util.Set<String> seenCatalogNames) 315 throws CatalogInputException { 316 Object databases = body.get("databases"); 317 if (databases != null) { 318 if (!(databases instanceof List)) { 319 throw new CatalogInputException( 320 "SqlFlow field 'databases' must be an array (got " 321 + typeOf(databases) + ")"); 322 } 323 for (Object dobj : (List<?>) databases) { 324 if (!(dobj instanceof Map)) { 325 throw new CatalogInputException( 326 "SqlFlow databases[i] must be an object (got " 327 + typeOf(dobj) + ")"); 328 } 329 @SuppressWarnings("unchecked") 330 Map<Object, Object> dmap = (Map<Object, Object>) dobj; 331 CatalogModel catalog = parseDatabase(dmap, vendor, supportsSchema); 332 if (catalog != null) { 333 if (!seenCatalogNames.add(catalog.name())) { 334 throw new CatalogInputException( 335 "SqlFlow envelope contains duplicate catalog name '" 336 + catalog.name() + "' across servers; the SPI's single-model " 337 + "contract cannot fold ambiguous catalogs. Split the export " 338 + "into one file per server, or rename the duplicate " 339 + "database before reading."); 340 } 341 mb.addCatalog(catalog); 342 } 343 } 344 return; 345 } 346 // Layout C — schemas-at-top. Defensive only; supportCatalog(vendor) returns 347 // true for every dialect today, so this branch is unreachable in production 348 // but the legacy SqlflowSQLEnv carries it. 349 Object schemas = body.get("schemas"); 350 if (schemas != null) { 351 if (!(schemas instanceof List)) { 352 throw new CatalogInputException( 353 "SqlFlow field 'schemas' must be an array (got " 354 + typeOf(schemas) + ")"); 355 } 356 CatalogModel catalog = parseSchemasAtTop((List<?>) schemas, vendor); 357 if (catalog != null) { 358 if (!seenCatalogNames.add(catalog.name())) { 359 throw new CatalogInputException( 360 "SqlFlow envelope contains duplicate catalog name '" 361 + catalog.name() + "' across servers"); 362 } 363 mb.addCatalog(catalog); 364 } 365 } 366 } 367 368 /** 369 * Resolve the per-server vendor used for the layout decision. The legacy parser 370 * constructs each {@code SqlflowSQLEnv} with the server's own {@code dbVendor} when 371 * present; we honor that for {@link #supportSchema} so a MySQL/Hive server in an 372 * envelope routed under an Oracle/PostgreSQL options.vendor doesn't silently drop 373 * tables that hang directly off {@code databases[i]}. Returns the supplied 374 * {@code defaultVendor} when the JSON omits or supplies an unparsable {@code dbVendor}. 375 */ 376 private static EDbVendor readServerVendor(Map<Object, Object> server, 377 EDbVendor defaultVendor) { 378 Object v = server.get("dbVendor"); 379 if (!(v instanceof String)) { 380 return defaultVendor; 381 } 382 String s = ((String) v).trim(); 383 if (s.isEmpty()) { 384 return defaultVendor; 385 } 386 try { 387 return EDbVendor.valueOf(s); 388 } catch (IllegalArgumentException ex) { 389 // Unrecognized vendor name in the JSON — fall back to the option vendor; 390 // CatalogModelValidator's identifier-bypass check will surface any 391 // identifier-fold mismatch downstream. Don't throw: the legacy parser 392 // would have done the same EDbVendor.valueOf() and let the caller see the 393 // resulting IllegalArgumentException; we choose tolerance over failure. 394 return defaultVendor; 395 } 396 } 397 398 399 // ---------- catalog / schema / table parsing ---------- 400 401 /** 402 * Walk one {@code databases[i]} entry into a {@link CatalogModel}. Two sub-shapes: 403 * 404 * <ul> 405 * <li>{@code supportsSchema}: {@code databases[i].schemas[j]} arrays carry the 406 * per-schema object listings.</li> 407 * <li>{@code !supportsSchema}: object listings (tables / views / etc.) hang off 408 * {@code databases[i]} directly; one synthetic schema named 409 * {@link gudusoft.gsqlparser.sqlenv.TSQLEnv#DEFAULT_SCHEMA_NAME} is produced so 410 * the loader's catalog-tree walk has somewhere to plant them. The legacy 411 * {@code SqlflowSQLEnv} does the same routing in 412 * {@code sqlCatalog.getSchema(TSQLEnv.DEFAULT_SCHEMA_NAME, true)}.</li> 413 * </ul> 414 */ 415 private CatalogModel parseDatabase(Map<Object, Object> dmap, EDbVendor vendor, 416 boolean supportsSchema) 417 throws CatalogInputException { 418 String dbName = wrapDottedName(asString(dmap, "name", true), vendor); 419 CatalogModel.Builder cb = CatalogModel.builder().name(dbName); 420 if (supportsSchema) { 421 Object schemas = dmap.get("schemas"); 422 if (schemas != null) { 423 if (!(schemas instanceof List)) { 424 throw new CatalogInputException( 425 "SqlFlow database '" + dbName + "' field 'schemas' must be an array (got " 426 + typeOf(schemas) + ")"); 427 } 428 for (Object sobj : (List<?>) schemas) { 429 if (!(sobj instanceof Map)) { 430 throw new CatalogInputException( 431 "SqlFlow database '" + dbName + "' schemas[i] must be an object (got " 432 + typeOf(sobj) + ")"); 433 } 434 @SuppressWarnings("unchecked") 435 Map<Object, Object> smap = (Map<Object, Object>) sobj; 436 String schemaName = wrapDottedName(asString(smap, "name", true), vendor); 437 cb.addSchema(parseSchemaBody(smap, schemaName, vendor)); 438 } 439 } 440 } else { 441 // Catalogs-only dialect: synthesize one default-named schema bucket. 442 cb.addSchema(parseSchemaBody(dmap, "", vendor)); 443 } 444 return cb.build(); 445 } 446 447 /** 448 * Defensive Layout-C fallback. Builds a single synthetic catalog whose name is 449 * {@code TSQLEnv.DEFAULT_DB_NAME} ({@code "$_-_$"}) — matching the legacy 450 * {@code sqlCatalog = getSQLCatalog(TSQLEnv.DEFAULT_DB_NAME, true)} call. Each 451 * top-level schema becomes a {@link SchemaModel} under it. 452 */ 453 private CatalogModel parseSchemasAtTop(List<?> schemas, EDbVendor vendor) 454 throws CatalogInputException { 455 CatalogModel.Builder cb = CatalogModel.builder() 456 .name(gudusoft.gsqlparser.sqlenv.TSQLEnv.DEFAULT_DB_NAME); 457 for (Object sobj : schemas) { 458 if (!(sobj instanceof Map)) { 459 throw new CatalogInputException( 460 "SqlFlow top-level schemas[i] must be an object (got " + typeOf(sobj) + ")"); 461 } 462 @SuppressWarnings("unchecked") 463 Map<Object, Object> smap = (Map<Object, Object>) sobj; 464 String schemaName = wrapDottedName(asString(smap, "name", true), vendor); 465 cb.addSchema(parseSchemaBody(smap, schemaName, vendor)); 466 } 467 return cb.build(); 468 } 469 470 /** 471 * Walk a schema-body map (either a {@code schemas[i]} entry, or a {@code databases[i]} 472 * entry in the schema-less case) into a {@link SchemaModel}. Handles tables, views, 473 * top-level packages, top-level procedures/functions, and silently skips triggers and 474 * package-nested children (see class Javadoc for the rationale). 475 * 476 * <p>The {@code schemaName} is wrapped upstream so the dotted-identifier defense covers 477 * the schema layer too.</p> 478 */ 479 private SchemaModel parseSchemaBody(Map<Object, Object> body, String schemaName, 480 EDbVendor vendor) 481 throws CatalogInputException { 482 SchemaModel.Builder sb = SchemaModel.builder().name(schemaName); 483 484 // Tables and views are walked in legacy order: tables first, then views. The 485 // SqlflowSQLEnv code combines them into a single list via addAll(), so the 486 // produced TSQLEnv ordering is tables-then-views — preserve that here so the 487 // parity test sees the same insertion order. 488 Object tables = body.get("tables"); 489 if (tables != null) { 490 if (!(tables instanceof List)) { 491 throw new CatalogInputException( 492 "SqlFlow field 'tables' must be an array (got " + typeOf(tables) + ")"); 493 } 494 for (Object tobj : (List<?>) tables) { 495 if (!(tobj instanceof Map)) { 496 throw new CatalogInputException( 497 "SqlFlow tables[i] must be an object (got " + typeOf(tobj) + ")"); 498 } 499 @SuppressWarnings("unchecked") 500 Map<Object, Object> tmap = (Map<Object, Object>) tobj; 501 addTableOrView(sb, tmap, vendor); 502 } 503 } 504 Object views = body.get("views"); 505 if (views != null) { 506 if (!(views instanceof List)) { 507 throw new CatalogInputException( 508 "SqlFlow field 'views' must be an array (got " + typeOf(views) + ")"); 509 } 510 for (Object vobj : (List<?>) views) { 511 if (!(vobj instanceof Map)) { 512 throw new CatalogInputException( 513 "SqlFlow views[i] must be an object (got " + typeOf(vobj) + ")"); 514 } 515 @SuppressWarnings("unchecked") 516 Map<Object, Object> vmap = (Map<Object, Object>) vobj; 517 addTableOrView(sb, vmap, vendor); 518 } 519 } 520 521 // Top-level routines. 522 Object procedures = body.get("procedures"); 523 if (procedures != null) { 524 for (Map<Object, Object> rmap : asMapList(procedures, "procedures")) { 525 String name = wrapDottedName(asString(rmap, "name", true), vendor); 526 if (rmap.containsKey("parameterBindings")) { 527 // Mantis 4962: the presets are read into TSQLEnv by the SQLFlow metadata reader; the catalog 528 // model has no place for them, so say so instead of dropping them in silence 529 java.util.logging.Logger.getLogger(SqlflowCatalogInputReader.class.getName()).warning( 530 "procedures[].parameterBindings of " + name + " is not read by the catalog input; pass" 531 + " the metadata as a TSQLEnv (SQLFlow metadata JSON) for parameter presets"); 532 } 533 sb.addRoutine(RoutineModel.builder() 534 .name(name) 535 .kind(CatalogObjectKind.PROCEDURE) 536 .build()); 537 } 538 } 539 Object functions = body.get("functions"); 540 if (functions != null) { 541 for (Map<Object, Object> rmap : asMapList(functions, "functions")) { 542 String name = wrapDottedName(asString(rmap, "name", true), vendor); 543 sb.addRoutine(RoutineModel.builder() 544 .name(name) 545 .kind(CatalogObjectKind.FUNCTION) 546 .build()); 547 } 548 } 549 Object packages = body.get("packages"); 550 if (packages != null) { 551 for (Map<Object, Object> pmap : asMapList(packages, "packages")) { 552 // Package-nested procedures / functions / triggers are intentionally 553 // dropped: RoutineModel does not yet model package-nested children, and 554 // the SQLEnvCatalogLoader has no path to bind them. The empty package 555 // shell still resolves SCHEMA.PKG references. 556 String name = wrapDottedName(asString(pmap, "name", true), vendor); 557 sb.addRoutine(RoutineModel.builder() 558 .name(name) 559 .kind(CatalogObjectKind.PACKAGE) 560 .build()); 561 } 562 } 563 564 // Top-level triggers — see class Javadoc; silently dropped because no Phase 1 565 // RoutineModel.kind exists for triggers and the loader does not synthesize them. 566 // Walk the array shape just to fail cleanly on a malformed export. 567 Object triggers = body.get("triggers"); 568 if (triggers != null && !(triggers instanceof List)) { 569 throw new CatalogInputException( 570 "SqlFlow field 'triggers' must be an array (got " + typeOf(triggers) + ")"); 571 } 572 573 return sb.build(); 574 } 575 576 private void addTableOrView(SchemaModel.Builder sb, Map<Object, Object> entry, 577 EDbVendor vendor) 578 throws CatalogInputException { 579 String objName = wrapDottedName(asString(entry, "name", true), vendor); 580 String type = asString(entry, "type", false); 581 boolean isView = typeIndicatesView(type); 582 // Column names are NOT wrapped: ModelBackedCatalogProvider builds the qualified 583 // column form as `<tableQ>.<column>` where <tableQ> already has any required 584 // quoting. A column name with an embedded dot would still be ambiguous downstream 585 // — but the legacy loader has the same gap, so parity is preserved without the 586 // extra wrapping here. (Same posture as SqldepCatalogInputReader.) 587 List<ColumnModel> cols = parseColumns(entry, objName); 588 if (isView) { 589 ViewModel.Builder vb = ViewModel.builder().name(objName); 590 for (ColumnModel c : cols) { 591 vb.addColumn(c); 592 } 593 sb.addView(vb.build()); 594 } else { 595 TableModel.Builder tb = TableModel.builder().name(objName); 596 for (ColumnModel c : cols) { 597 tb.addColumn(c); 598 } 599 sb.addTable(tb.build()); 600 } 601 } 602 603 /** 604 * Case-insensitive ASCII substring check for {@code "view"} in the supplied {@code type} 605 * field — matches the legacy {@code SqlflowSQLEnv.appendTables()}'s 606 * {@code type.toLowerCase().indexOf("view") != -1} test without using 607 * {@link String#toLowerCase()}, which the {@code forbidden-apis} plugin bans inside 608 * {@code catalog/**}. {@link Character#toLowerCase(char)} is locale-insensitive for 609 * ASCII characters and is permitted. 610 */ 611 private static boolean typeIndicatesView(String type) { 612 if (type == null) { 613 return false; 614 } 615 final int n = type.length(); 616 for (int i = 0; i + 4 <= n; i++) { 617 if (Character.toLowerCase(type.charAt(i)) == 'v' 618 && Character.toLowerCase(type.charAt(i + 1)) == 'i' 619 && Character.toLowerCase(type.charAt(i + 2)) == 'e' 620 && Character.toLowerCase(type.charAt(i + 3)) == 'w') { 621 return true; 622 } 623 } 624 return false; 625 } 626 627 private static List<ColumnModel> parseColumns(Map<Object, Object> tmap, String tableName) 628 throws CatalogInputException { 629 Object columns = tmap.get("columns"); 630 java.util.ArrayList<ColumnModel> out = new java.util.ArrayList<ColumnModel>(); 631 if (columns == null) { 632 return out; 633 } 634 if (!(columns instanceof List)) { 635 throw new CatalogInputException( 636 "SqlFlow object '" + tableName + "' field 'columns' must be an array (got " 637 + typeOf(columns) + ")"); 638 } 639 for (Object cobj : (List<?>) columns) { 640 if (!(cobj instanceof Map)) { 641 throw new CatalogInputException( 642 "SqlFlow object '" + tableName + "' columns[i] must be an object (got " 643 + typeOf(cobj) + ")"); 644 } 645 @SuppressWarnings("unchecked") 646 Map<Object, Object> cmap = (Map<Object, Object>) cobj; 647 String colName = asString(cmap, "name", true); 648 ColumnModel.Builder cb = ColumnModel.builder().name(colName); 649 String dt = asString(cmap, "dataType", false); 650 if (dt == null) dt = asString(cmap, "type", false); 651 if (dt != null) cb.dataType(dt); 652 out.add(cb.build()); 653 } 654 return out; 655 } 656 657 /** 658 * Cast and walk a JSON value as a list of object maps. Throws {@link CatalogInputException} 659 * with a labelled error when the structure is wrong. Used for {@code procedures} / 660 * {@code functions} / {@code packages}. 661 */ 662 @SuppressWarnings("unchecked") 663 private static List<Map<Object, Object>> asMapList(Object value, String fieldLabel) 664 throws CatalogInputException { 665 if (!(value instanceof List)) { 666 throw new CatalogInputException( 667 "SqlFlow field '" + fieldLabel + "' must be an array (got " + typeOf(value) + ")"); 668 } 669 List<?> raw = (List<?>) value; 670 java.util.ArrayList<Map<Object, Object>> out = new java.util.ArrayList<Map<Object, Object>>(raw.size()); 671 for (Object o : raw) { 672 if (!(o instanceof Map)) { 673 throw new CatalogInputException( 674 "SqlFlow field '" + fieldLabel + "[i]' must be an object (got " 675 + typeOf(o) + ")"); 676 } 677 out.add((Map<Object, Object>) o); 678 } 679 return out; 680 } 681 682 // ---------- dotted-identifier wrapping ---------- 683 684 /** 685 * Wrap a raw identifier with the vendor's open/close quote chars when it contains a 686 * dot. Same defense as {@link SqldepCatalogInputReader#wrapDottedName} — see that 687 * method for the rationale. Vendor → delimiter pair table is duplicated here to keep 688 * {@code catalog/**} free of a compile-time dependency on {@code sqlenv/**}; if a 689 * dialect adds a new {@code TSQLEnv.delimitedChar} entry, mirror it in both places. 690 */ 691 private static String wrapDottedName(String raw, EDbVendor vendor) { 692 if (raw == null || raw.isEmpty() || raw.indexOf('.') == -1) { 693 return raw; 694 } 695 char open = vendorOpenDelimiter(vendor); 696 char close = vendorCloseDelimiter(vendor); 697 if (raw.length() >= 2 698 && raw.charAt(0) == open 699 && raw.charAt(raw.length() - 1) == close) { 700 return raw; 701 } 702 return open + raw + close; 703 } 704 705 private static char vendorOpenDelimiter(EDbVendor v) { 706 if (v == null) return '"'; 707 switch (v) { 708 case dbvmssql: 709 case dbvazuresql: 710 return '['; 711 case dbvathena: 712 case dbvmysql: 713 case dbvbigquery: 714 case dbvcouchbase: 715 case dbvhive: 716 case dbvimpala: 717 case dbvdatabricks: 718 return '`'; 719 case dbvdax: 720 return '\''; 721 default: 722 return '"'; 723 } 724 } 725 726 private static char vendorCloseDelimiter(EDbVendor v) { 727 if (v == null) return '"'; 728 switch (v) { 729 case dbvmssql: 730 case dbvazuresql: 731 return ']'; 732 default: 733 return vendorOpenDelimiter(v); 734 } 735 } 736 737 // ---------- vendor schema-support ---------- 738 739 /** 740 * Mirror of {@link gudusoft.gsqlparser.sqlenv.TSQLEnv#supportSchema(EDbVendor)}. Inlined 741 * here so {@code catalog/**} does not import from {@code sqlenv/**} on the read path 742 * (the loader path already uses {@code TSQLEnv.supportSchema} for the symmetric 743 * decision). Kept in sync — if a new schema-less dialect lands in the legacy method 744 * (unlikely; the list has been stable for years), reflect it here too. 745 */ 746 private static boolean supportSchema(EDbVendor vendor) { 747 return !(vendor == EDbVendor.dbvmysql 748 || vendor == EDbVendor.dbvteradata 749 || vendor == EDbVendor.dbvhive 750 || vendor == EDbVendor.dbvimpala); 751 } 752 753 // ---------- defaults / source info ---------- 754 755 /** 756 * Seed the supplied {@link DefaultsConfig.Builder} from {@code options} non-empty 757 * fields. Returns {@code true} if at least one default was contributed, so the caller 758 * knows whether to call {@link UnifiedCatalogModel.Builder#defaults} at the end. The 759 * server-name capture from JSON layers on top of this seed in the read path. 760 */ 761 private static boolean seedOptionDefaults(DefaultsConfig.Builder db, 762 CatalogLoadOptions options) { 763 if (options == null) return false; 764 boolean any = false; 765 if (options.defaultCatalog() != null && !options.defaultCatalog().isEmpty()) { 766 db.defaultCatalog(options.defaultCatalog()); 767 any = true; 768 } 769 if (options.defaultSchema() != null && !options.defaultSchema().isEmpty()) { 770 db.defaultSchema(options.defaultSchema()); 771 any = true; 772 } 773 if (options.defaultServer() != null && !options.defaultServer().isEmpty()) { 774 db.defaultServer(options.defaultServer()); 775 any = true; 776 } 777 return any; 778 } 779 780 private static CatalogSourceInfo buildSourceInfo(CatalogInputSource source, long startMillis) { 781 return CatalogSourceInfo.builder() 782 .kind(CatalogInputKind.SQLFLOW_JSON) 783 .name(source.name() != null ? source.name() : "<sqlflow>") 784 .readMillis(System.currentTimeMillis() - startMillis) 785 .build(); 786 } 787 788 // ---------- input → string ---------- 789 790 private static String readAll(CatalogInputSource source) throws CatalogInputException { 791 try { 792 if (source.inMemoryModel() != null) { 793 throw new CatalogInputException( 794 "SqlflowCatalogInputReader cannot read in-memory model sources"); 795 } 796 if (source.path() != null) { 797 byte[] b = Files.readAllBytes(source.path()); 798 return new String(b, StandardCharsets.UTF_8); 799 } 800 byte[] sourceBytes = source.bytes(); 801 if (sourceBytes != null) { 802 return new String(sourceBytes, StandardCharsets.UTF_8); 803 } 804 if (source.url() != null) { 805 try (InputStream in = source.url().openStream(); 806 Reader r = new InputStreamReader(in, StandardCharsets.UTF_8)) { 807 return drain(r); 808 } 809 } 810 if (source.reader() != null) { 811 return drain(source.reader()); 812 } 813 throw new CatalogInputException( 814 "SqlflowCatalogInputReader: source has no readable backing"); 815 } catch (IOException io) { 816 throw new CatalogInputException( 817 "Failed to read SqlFlow export from " + source.name() + ": " + io.getMessage(), 818 io); 819 } 820 } 821 822 private static String drain(Reader r) throws IOException { 823 BufferedReader br = (r instanceof BufferedReader) ? (BufferedReader) r : new BufferedReader(r); 824 StringBuilder sb = new StringBuilder(); 825 char[] buf = new char[4096]; 826 int n; 827 while ((n = br.read(buf)) > 0) { 828 sb.append(buf, 0, n); 829 } 830 return sb.toString(); 831 } 832 833 // ---------- shared field accessors ---------- 834 835 private static String asString(Map<Object, Object> obj, String key, boolean required) 836 throws CatalogInputException { 837 Object v = obj.get(key); 838 if (v == null) { 839 if (required) { 840 throw new CatalogInputException( 841 "SqlFlow entry missing required field '" + key + "'"); 842 } 843 return null; 844 } 845 return v instanceof String ? (String) v : v.toString(); 846 } 847 848 private static String typeOf(Object o) { 849 return o == null ? "null" : o.getClass().getSimpleName(); 850 } 851 852 /** ServiceLoader-discoverable factory. Plan §13.1. */ 853 public static final class Factory implements CatalogInputReaderFactory { 854 855 public Factory() { 856 // Required no-arg constructor for ServiceLoader. 857 } 858 859 @Override 860 public CatalogInputKind kind() { 861 return CatalogInputKind.SQLFLOW_JSON; 862 } 863 864 @Override 865 public CatalogInputReader create() { 866 return new SqlflowCatalogInputReader(); 867 } 868 } 869}