001package gudusoft.gsqlparser.catalog.input; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnostic; 005import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnosticCode; 006import gudusoft.gsqlparser.catalog.diagnostic.CatalogDiagnosticSeverity; 007import gudusoft.gsqlparser.catalog.input.model.CatalogModel; 008import gudusoft.gsqlparser.catalog.input.model.ColumnModel; 009import gudusoft.gsqlparser.catalog.input.model.ConstraintModel; 010import gudusoft.gsqlparser.catalog.input.model.IdentifierConfig; 011import gudusoft.gsqlparser.catalog.input.model.IndexModel; 012import gudusoft.gsqlparser.catalog.input.model.RoutineModel; 013import gudusoft.gsqlparser.catalog.input.model.SchemaModel; 014import gudusoft.gsqlparser.catalog.input.model.SequenceModel; 015import gudusoft.gsqlparser.catalog.input.model.SynonymModel; 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.CatalogIdentifierPolicy; 020import gudusoft.gsqlparser.catalog.runtime.CatalogObjectKind; 021import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType; 022import gudusoft.gsqlparser.sqlenv.IdentifierService; 023import gudusoft.gsqlparser.sqlenv.TSQLEnv; 024 025import java.util.ArrayList; 026import java.util.HashMap; 027import java.util.LinkedHashMap; 028import java.util.List; 029import java.util.Map; 030 031/** 032 * Validates a {@link UnifiedCatalogModel} against required-field rules, duplicate-name 033 * checks, identifier-bypass detection, and table↔column referential integrity. 034 * 035 * <p>Plan §7.1 / §9.6. Identifier-bypass detection rejects models whose names disagree 036 * with {@code IdentifierService.normalize(...)} — catching adapters that sneak in a 037 * third-party canonicalization step. Strict-mode escalation is the caller's 038 * responsibility (see {@code CatalogLoadOptions.strict()} and §15).</p> 039 * 040 * <p>Validation is best-effort: it never throws; every problem is reported as a 041 * {@link CatalogDiagnostic} on the returned {@link CatalogValidationResult}. ERROR 042 * diagnostics fail validation; WARN/INFO do not.</p> 043 * 044 * <p>Duplicate detection uses {@link IdentifierService#areEqual} rather than a 045 * normalize-then-{@code Set} short-circuit: in dialects where the compare rule is 046 * case-insensitive but the canonical normalize preserves the input spelling 047 * (e.g., BigQuery columns, MySQL columns under {@code lower_case_table_names=2}, 048 * MSSQL with a case-insensitive collation), the keyset alone misses real 049 * duplicates. The cost is O(n²) per scope, which is acceptable since each scope 050 * is a single schema or table column list.</p> 051 */ 052public final class CatalogModelValidator { 053 054 public CatalogModelValidator() { 055 // No state. 056 } 057 058 public CatalogValidationResult validate(UnifiedCatalogModel model, CatalogLoadOptions options) { 059 if (model == null) { 060 throw new IllegalArgumentException("CatalogModelValidator.validate: model is required"); 061 } 062 List<CatalogDiagnostic> diagnostics = new ArrayList<CatalogDiagnostic>(); 063 EDbVendor vendor = model.vendor(); 064 IdentifierConfig cfg = effectiveConfig(model, options, diagnostics); 065 NameRules service = new NameRules(CatalogIdentifierPolicy.identifierServiceFor(cfg, vendor), model); 066 boolean normalizeOnLoad = options == null || options.normalizeOnLoad(); 067 068 IdentifierBucket catalogBucket = new IdentifierBucket(service); 069 for (CatalogModel c : model.catalogs()) { 070 validateCatalog(c, vendor, service, normalizeOnLoad, catalogBucket, diagnostics); 071 } 072 return CatalogValidationResult.of(diagnostics); 073 } 074 075 // ---------- catalog / schema / table / column traversal ---------- 076 077 private void validateCatalog(CatalogModel c, 078 EDbVendor vendor, 079 NameRules service, 080 boolean normalizeOnLoad, 081 IdentifierBucket catalogBucket, 082 List<CatalogDiagnostic> diagnostics) { 083 String name = c.name(); 084 checkRequired("catalog", name, diagnostics); 085 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotCatalog, 086 "catalog '" + name + "'", diagnostics); 087 flagIfDuplicate(catalogBucket, name, ESQLDataObjectType.dotCatalog, CatalogObjectKind.CATALOG, 088 "catalog '" + name + "'", diagnostics); 089 // Build a catalog-wide table index so FK constraints can look up their target 090 // table across schemas. Keys are normalized via IdentifierService so the lookup 091 // honors per-vendor identifier folding (Oracle uppercase, PG lowercase, etc.). 092 CatalogTableIndex tableIndex = CatalogTableIndex.build(c, service); 093 IdentifierBucket schemaBucket = new IdentifierBucket(service); 094 for (SchemaModel s : c.schemas()) { 095 validateSchema(s, vendor, service, normalizeOnLoad, c.name(), schemaBucket, 096 tableIndex, diagnostics); 097 } 098 } 099 100 private void validateSchema(SchemaModel s, 101 EDbVendor vendor, 102 NameRules service, 103 boolean normalizeOnLoad, 104 String catalogName, 105 IdentifierBucket schemaBucket, 106 CatalogTableIndex tableIndex, 107 List<CatalogDiagnostic> diagnostics) { 108 String name = s.name(); 109 if (name == null) { 110 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_MISSING_DEFAULT, 111 "schema in catalog '" + catalogName + "' has null name")); 112 return; 113 } 114 if (name.isEmpty()) { 115 // Empty schema names are only legal for dialects without a schema layer 116 // (per TSQLEnv.supportSchema): MySQL, Teradata, Hive, Impala. For every 117 // other dialect — Oracle, PostgreSQL, MSSQL, BigQuery, etc. — an empty 118 // schema name silently re-qualifies child objects as catalog.object, 119 // which is a manifest bug worth surfacing. 120 if (TSQLEnv.supportSchema(vendor)) { 121 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_MISSING_DEFAULT, 122 "schema in catalog '" + catalogName 123 + "' has empty name; vendor " + vendor + " requires a schema layer")); 124 } 125 } else { 126 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotSchema, 127 "schema '" + catalogName + "." + name + "'", diagnostics); 128 } 129 flagIfDuplicate(schemaBucket, name, ESQLDataObjectType.dotSchema, CatalogObjectKind.SCHEMA, 130 "schema '" + catalogName + "." + name + "'", diagnostics); 131 132 // The existing catalog storage (TSQLCatalog) keeps table / view / routine / 133 // synonym / sequence indexes type-specific, so a schema may legitimately hold 134 // a table named 'Foo' and a function named 'Foo' that resolve by requested 135 // type. We honor that by using one bucket per object-type namespace. Tables 136 // and views share a bucket because in every dialect we cover (Oracle, MSSQL, 137 // PostgreSQL, MySQL, BigQuery) a CREATE TABLE and a CREATE VIEW with the same 138 // unqualified name in the same schema is a real collision. 139 IdentifierBucket tableViewBucket = new IdentifierBucket(service); 140 for (TableModel t : s.tables()) { 141 validateTable(t, vendor, service, normalizeOnLoad, catalogName, name, 142 tableViewBucket, tableIndex, diagnostics); 143 } 144 for (ViewModel v : s.views()) { 145 validateView(v, vendor, service, normalizeOnLoad, catalogName, name, 146 tableViewBucket, diagnostics); 147 } 148 IdentifierBucket routineBucket = new IdentifierBucket(service); 149 for (RoutineModel r : s.routines()) { 150 validateRoutine(r, vendor, service, normalizeOnLoad, catalogName, name, 151 routineBucket, diagnostics); 152 } 153 IdentifierBucket synonymBucket = new IdentifierBucket(service); 154 for (SynonymModel sy : s.synonyms()) { 155 validateSynonym(sy, vendor, service, normalizeOnLoad, catalogName, name, 156 synonymBucket, diagnostics); 157 } 158 IdentifierBucket sequenceBucket = new IdentifierBucket(service); 159 for (SequenceModel sq : s.sequences()) { 160 validateSequence(sq, vendor, service, normalizeOnLoad, catalogName, name, 161 sequenceBucket, diagnostics); 162 } 163 } 164 165 private void validateTable(TableModel t, 166 EDbVendor vendor, 167 NameRules service, 168 boolean normalizeOnLoad, 169 String catalogName, 170 String schemaName, 171 IdentifierBucket objectBucket, 172 CatalogTableIndex tableIndex, 173 List<CatalogDiagnostic> diagnostics) { 174 String name = t.name(); 175 String location = qualified(catalogName, schemaName, name); 176 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotTable, 177 "table '" + location + "'", diagnostics); 178 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotTable, CatalogObjectKind.TABLE, 179 "table '" + location + "'", diagnostics); 180 181 IdentifierBucket columnBucket = new IdentifierBucket(service); 182 for (ColumnModel c : t.columns()) { 183 validateColumn(c, vendor, service, normalizeOnLoad, location, columnBucket, diagnostics); 184 } 185 186 IdentifierBucket constraintBucket = new IdentifierBucket(service); 187 for (ConstraintModel cs : t.constraints()) { 188 String csName = cs.name(); 189 if (csName != null && !csName.isEmpty()) { 190 flagIfDuplicate(constraintBucket, csName, ESQLDataObjectType.dotUnknown, 191 CatalogObjectKind.CONSTRAINT, 192 "constraint '" + csName + "' on table '" + location + "'", diagnostics); 193 } 194 for (String col : cs.columns()) { 195 if (!columnBucket.contains(col, ESQLDataObjectType.dotColumn)) { 196 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME, 197 "constraint '" + (csName == null ? "<unnamed>" : csName) 198 + "' on table '" + location + "' references unknown column '" + col + "'")); 199 } 200 } 201 if (isForeignKeyType(cs.type())) { 202 validateForeignKey(cs, location, schemaName, vendor, service, tableIndex, diagnostics); 203 } 204 } 205 206 IdentifierBucket indexBucket = new IdentifierBucket(service); 207 for (IndexModel ix : t.indexes()) { 208 flagIfDuplicate(indexBucket, ix.name(), ESQLDataObjectType.dotUnknown, 209 CatalogObjectKind.INDEX, 210 "index '" + ix.name() + "' on table '" + location + "'", diagnostics); 211 for (String col : ix.columns()) { 212 if (!columnBucket.contains(col, ESQLDataObjectType.dotColumn)) { 213 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME, 214 "index '" + ix.name() + "' on table '" + location 215 + "' references unknown column '" + col + "'")); 216 } 217 } 218 } 219 } 220 221 private void validateView(ViewModel v, 222 EDbVendor vendor, 223 NameRules service, 224 boolean normalizeOnLoad, 225 String catalogName, 226 String schemaName, 227 IdentifierBucket objectBucket, 228 List<CatalogDiagnostic> diagnostics) { 229 String name = v.name(); 230 String location = qualified(catalogName, schemaName, name); 231 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotTable, 232 "view '" + location + "'", diagnostics); 233 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotTable, 234 v.materialized() ? CatalogObjectKind.MATERIALIZED_VIEW : CatalogObjectKind.VIEW, 235 "view '" + location + "'", diagnostics); 236 IdentifierBucket columnBucket = new IdentifierBucket(service); 237 for (ColumnModel c : v.columns()) { 238 validateColumn(c, vendor, service, normalizeOnLoad, location, columnBucket, diagnostics); 239 } 240 } 241 242 private void validateRoutine(RoutineModel r, 243 EDbVendor vendor, 244 NameRules service, 245 boolean normalizeOnLoad, 246 String catalogName, 247 String schemaName, 248 IdentifierBucket objectBucket, 249 List<CatalogDiagnostic> diagnostics) { 250 String name = r.name(); 251 String location = qualified(catalogName, schemaName, name); 252 // For diagnostic-message accuracy use the kind-specific leaf type when running 253 // the bypass check; for duplicate detection always compare with dotFunction 254 // because IdentifierProfile only routes dotFunction through ROUTINE_GROUP — 255 // dotProcedure / dotOraclePackage / dotRoutine fall back to NAME_GROUP. Using 256 // dotFunction here means MySQL procedures and packages share the routine 257 // case-insensitivity rule, which matches the actual MySQL behavior (stored 258 // routine names are case-insensitive regardless of routine kind). 259 ESQLDataObjectType bypassType = leafTypeFor(r.kind()); 260 ESQLDataObjectType compareType = ESQLDataObjectType.dotFunction; 261 String label = labelFor(r.kind()); 262 checkBypass(vendor, service, normalizeOnLoad, name, bypassType, 263 label + " '" + location + "'", diagnostics); 264 flagIfDuplicate(objectBucket, name, compareType, r.kind(), 265 label + " '" + location + "'", diagnostics); 266 } 267 268 private void validateSynonym(SynonymModel sy, 269 EDbVendor vendor, 270 NameRules service, 271 boolean normalizeOnLoad, 272 String catalogName, 273 String schemaName, 274 IdentifierBucket objectBucket, 275 List<CatalogDiagnostic> diagnostics) { 276 String name = sy.name(); 277 String location = qualified(catalogName, schemaName, name); 278 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotSynonyms, 279 "synonym '" + location + "'", diagnostics); 280 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotSynonyms, CatalogObjectKind.SYNONYM, 281 "synonym '" + location + "'", diagnostics); 282 } 283 284 private void validateSequence(SequenceModel sq, 285 EDbVendor vendor, 286 NameRules service, 287 boolean normalizeOnLoad, 288 String catalogName, 289 String schemaName, 290 IdentifierBucket objectBucket, 291 List<CatalogDiagnostic> diagnostics) { 292 String name = sq.name(); 293 String location = qualified(catalogName, schemaName, name); 294 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotUnknown, 295 "sequence '" + location + "'", diagnostics); 296 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotUnknown, CatalogObjectKind.SEQUENCE, 297 "sequence '" + location + "'", diagnostics); 298 } 299 300 private void validateColumn(ColumnModel c, 301 EDbVendor vendor, 302 NameRules service, 303 boolean normalizeOnLoad, 304 String parentLocation, 305 IdentifierBucket columnBucket, 306 List<CatalogDiagnostic> diagnostics) { 307 String name = c.name(); 308 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotColumn, 309 "column '" + parentLocation + "." + name + "'", diagnostics); 310 flagIfDuplicate(columnBucket, name, ESQLDataObjectType.dotColumn, CatalogObjectKind.COLUMN, 311 "column '" + parentLocation + "." + name + "'", diagnostics); 312 } 313 314 // ---------- helpers ---------- 315 316 private void checkRequired(String label, String value, List<CatalogDiagnostic> diagnostics) { 317 if (value == null || value.isEmpty()) { 318 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_MISSING_DEFAULT, 319 label + ".name is required")); 320 } 321 } 322 323 /** 324 * Identifier-bypass detection. When {@code normalizeOnLoad} is true, the raw name 325 * must already equal {@code IdentifierService.normalize(name)}; otherwise an adapter 326 * either pre-normalized through a non-{@code IdentifierService} path or shipped a 327 * raw form that the resolver will fail to match. We emit a WARN, not an ERROR, so 328 * downstream code remains tolerant — strict-mode callers escalate at the load 329 * entry point. 330 * 331 * <p>For COLLATION_BASED dialects (MSSQL/Azure SQL) where {@code normalize} preserves 332 * the input spelling, this check is a no-op: the resolver will route compares 333 * through {@code IdentifierService.areEqual} regardless of stored case.</p> 334 */ 335 private void checkBypass(EDbVendor vendor, 336 NameRules service, 337 boolean normalizeOnLoad, 338 String name, 339 ESQLDataObjectType type, 340 String location, 341 List<CatalogDiagnostic> diagnostics) { 342 if (!normalizeOnLoad || name == null || name.isEmpty()) { 343 return; 344 } 345 String normalized = service.normalize(name, type); 346 if (normalized == null || normalized.equals(name)) { 347 return; 348 } 349 if (service.stored()) { 350 // A catalog-stored name is canonical by definition: Oracle "Orders" IS the stored 351 // name, not an un-normalized spelling of ORDERS. Once the input has SAID its names 352 // are stored names there is nothing to report. While it has not, this is exactly 353 // the name the two forms read differently, so say how it was read. 354 if (!service.declared()) { 355 diagnostics.add(CatalogDiagnostic.builder() 356 .severity(CatalogDiagnosticSeverity.WARN) 357 .code(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS) 358 .message(location + ": name '" + name + "' is read as the catalog-stored name, the " 359 + "object " + service.lex(type, name) + " for " + vendor + ". As SQL text it would " 360 + "be '" + normalized + "'. The input does not declare nameForm.") 361 .repairHint("Declare \"nameForm\": \"CATALOG_STORED\" if the names are the ones the " 362 + "database catalog holds, or \"SQL_LEXICAL\" if they are written as SQL.") 363 .build()); 364 } 365 return; 366 } 367 // For COLLATION_BASED dialects (MSSQL/Azure SQL with case-insensitive collation, 368 // MySQL lower_case_table_names=2) IdentifierService.normalize preserves the input 369 // spelling, so we never reach this point for them — no false positives. The 370 // condition above (normalized != name) is enough to identify a bypass for the 371 // case-folding dialects (Oracle/DB2 → UPPER, Postgres/Greenplum/Redshift → lower) 372 // we care about. 373 diagnostics.add(CatalogDiagnostic.builder() 374 .severity(CatalogDiagnosticSeverity.WARN) 375 .code(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS) 376 .message(location + ": raw name '" + name + "' is not in canonical form for " 377 + vendor + " (expected '" + normalized + "'). The reader bypassed " 378 + "IdentifierService normalization or normalizeOnLoad must be disabled.") 379 .repairHint("Run input through CatalogIdentifierPolicy.normalize / " 380 + "IdentifierService.normalize before constructing the model, or set " 381 + "CatalogLoadOptions.normalizeOnLoad(false) when the source intentionally " 382 + "preserves a non-canonical form.") 383 .build()); 384 } 385 386 private void flagIfDuplicate(IdentifierBucket bucket, 387 String name, 388 ESQLDataObjectType type, 389 CatalogObjectKind kind, 390 String location, 391 List<CatalogDiagnostic> diagnostics) { 392 if (name == null) return; 393 if (!bucket.add(name, type)) { 394 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME, 395 "duplicate " + labelFor(kind) + ": " + location)); 396 } 397 } 398 399 private static CatalogDiagnostic error(CatalogDiagnosticCode code, String message) { 400 return CatalogDiagnostic.builder() 401 .severity(CatalogDiagnosticSeverity.ERROR) 402 .code(code) 403 .message(message) 404 .build(); 405 } 406 407 private static String qualified(String catalog, String schema, String name) { 408 StringBuilder sb = new StringBuilder(); 409 if (catalog != null && !catalog.isEmpty()) sb.append(catalog).append('.'); 410 if (schema != null && !schema.isEmpty()) sb.append(schema).append('.'); 411 sb.append(name); 412 return sb.toString(); 413 } 414 415 /** 416 * Choose the {@link IdentifierConfig} the validator should compare against. 417 * 418 * <p>Plan §9.2: the model carries its own config (frequently the vendor default, 419 * sometimes adapter-supplied to model MySQL {@code lower_case_table_names} or 420 * MSSQL collation). The caller may pass a {@link CatalogLoadOptions} with an 421 * explicit override — in that case the override wins, after a vendor-consistency 422 * check. When the caller did not pass an explicit override, the model's config is 423 * authoritative.</p> 424 */ 425 private static IdentifierConfig effectiveConfig(UnifiedCatalogModel model, 426 CatalogLoadOptions options, 427 List<CatalogDiagnostic> diagnostics) { 428 IdentifierConfig modelCfg = model.identifierConfig(); 429 // Always check the model's own identifierConfig vendor matches the model vendor — 430 // this is independent of the options path and catches a manifest that declared a 431 // mismatched IdentifierConfig at the top level (rare but possible when readers 432 // forward a config supplied by a different layer of code). 433 if (modelCfg != null && modelCfg.vendor() != model.vendor()) { 434 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS, 435 "UnifiedCatalogModel.identifierConfig.vendor=" + modelCfg.vendor() 436 + " does not match model.vendor=" + model.vendor())); 437 } 438 if (options == null) { 439 return modelCfg; 440 } 441 // Always check options.vendor against model.vendor — a downstream loader / runtime 442 // is going to be driven by options.vendor (CatalogQuery, CatalogLoaders), so a 443 // mismatch is a real configuration bug regardless of whether identifierConfig was 444 // explicit. 445 if (options.vendor() != model.vendor()) { 446 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS, 447 "CatalogLoadOptions.vendor=" + options.vendor() 448 + " does not match model.vendor=" + model.vendor())); 449 } 450 if (!options.hasExplicitIdentifierConfig()) { 451 return modelCfg; 452 } 453 IdentifierConfig optsCfg = options.identifierConfig(); 454 if (optsCfg.vendor() != model.vendor()) { 455 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS, 456 "CatalogLoadOptions.identifierConfig.vendor=" + optsCfg.vendor() 457 + " does not match model.vendor=" + model.vendor())); 458 return modelCfg; 459 } 460 return optsCfg; 461 } 462 463 private static ESQLDataObjectType leafTypeFor(CatalogObjectKind kind) { 464 switch (kind) { 465 case FUNCTION: return ESQLDataObjectType.dotFunction; 466 case PROCEDURE: return ESQLDataObjectType.dotProcedure; 467 case PACKAGE: return ESQLDataObjectType.dotOraclePackage; 468 case ROUTINE: return ESQLDataObjectType.dotRoutine; 469 default: return ESQLDataObjectType.dotUnknown; 470 } 471 } 472 473 /** 474 * Diagnostic-friendly lower-case label for a kind. Hand-coded to avoid 475 * {@link String#toLowerCase()}, which the forbidden-apis plugin bans inside 476 * {@code catalog/**} (plan §9.5). 477 */ 478 private static String labelFor(CatalogObjectKind kind) { 479 switch (kind) { 480 case CATALOG: return "catalog"; 481 case SCHEMA: return "schema"; 482 case TABLE: return "table"; 483 case VIEW: return "view"; 484 case MATERIALIZED_VIEW: return "materialized view"; 485 case COLUMN: return "column"; 486 case ROUTINE: return "routine"; 487 case FUNCTION: return "function"; 488 case PROCEDURE: return "procedure"; 489 case PACKAGE: return "package"; 490 case SYNONYM: return "synonym"; 491 case SEQUENCE: return "sequence"; 492 case TYPE: return "type"; 493 case TRIGGER: return "trigger"; 494 case INDEX: return "index"; 495 case CONSTRAINT: return "constraint"; 496 default: return "object"; 497 } 498 } 499 500 /** 501 * Per-scope identifier deduplication bucket. Calls {@link IdentifierService#areEqual} 502 * for every membership test so dialects whose compare rule is case-insensitive but 503 * whose {@code normalize} preserves spelling (BigQuery columns, MSSQL with case- 504 * insensitive collation, MySQL {@code lower_case_table_names=2}) still catch 505 * collisions that a {@code Set<String>} of normalize keys would miss. 506 * 507 * <p>The {@link ESQLDataObjectType} used for comparison is supplied per-{@link #add(String, ESQLDataObjectType)} 508 * call rather than fixed at construction. This matters when one logical namespace 509 * mixes kinds that have different per-vendor case-sensitivity rules — e.g., MySQL's 510 * default schema namespace where tables compare case-sensitively 511 * ({@link ESQLDataObjectType#dotTable}) but routines compare case-insensitively 512 * ({@link ESQLDataObjectType#dotRoutine}/{@code dotFunction}/{@code dotProcedure}). 513 * Each entry is checked against existing entries using the type registered on 514 * insert; duplicate detection is symmetric (existing → new and new → existing).</p> 515 */ 516 /** 517 * The vendor's comparison service plus the model's name form. Everything the validator 518 * compares is first put into SQL spelling ({@link UnifiedCatalogModel#lexical}), so a 519 * catalog-stored {@code Orders} and {@code ORDERS} stay the two Oracle tables they are 520 * instead of being reported as one duplicated name. 521 */ 522 static final class NameRules { 523 private final IdentifierService service; 524 private final UnifiedCatalogModel model; 525 526 NameRules(IdentifierService service, UnifiedCatalogModel model) { 527 this.service = service; 528 this.model = model; 529 } 530 531 String lex(ESQLDataObjectType type, String raw) { 532 return model.lexical(type, raw); 533 } 534 535 boolean declared() { 536 return model.nameFormDeclared(); 537 } 538 539 boolean stored() { 540 return model.nameForm() == gudusoft.gsqlparser.sqlenv.IdentifierInputForm.CATALOG_STORED; 541 } 542 543 String normalize(String sqlText, ESQLDataObjectType type) { 544 return service.normalize(sqlText, type); 545 } 546 547 boolean areEqual(String a, String b, ESQLDataObjectType type) { 548 return service.areEqual(a, b, type); 549 } 550 } 551 552 static final class IdentifierBucket { 553 private final NameRules service; 554 private final List<String> seenNames = new ArrayList<String>(); 555 private final List<ESQLDataObjectType> seenTypes = new ArrayList<ESQLDataObjectType>(); 556 557 IdentifierBucket(NameRules service) { 558 this.service = service; 559 } 560 561 /** Add a name with its kind-specific compare type. */ 562 boolean add(String raw, ESQLDataObjectType type) { 563 if (raw == null) return true; 564 raw = service.lex(type, raw); 565 for (int i = 0; i < seenNames.size(); i++) { 566 String existing = seenNames.get(i); 567 ESQLDataObjectType existingType = seenTypes.get(i); 568 // Cross-kind compare uses the more permissive (incoming) type so the bucket 569 // catches "table Fn vs function fn" collisions even when only one of the 570 // two compare rules is case-insensitive. 571 if (service.areEqual(existing, raw, type) 572 || service.areEqual(existing, raw, existingType)) { 573 return false; 574 } 575 } 576 seenNames.add(raw); 577 seenTypes.add(type); 578 return true; 579 } 580 581 /** 582 * Membership test against entries already added with their per-call type. 583 * Used by constraint/index references against column names — the caller passes 584 * {@link ESQLDataObjectType#dotColumn} (which is what those entries were added 585 * with anyway, but the API stays explicit). 586 */ 587 boolean contains(String raw, ESQLDataObjectType type) { 588 if (raw == null) return false; 589 raw = service.lex(type, raw); 590 for (int i = 0; i < seenNames.size(); i++) { 591 if (service.areEqual(seenNames.get(i), raw, type) 592 || service.areEqual(seenNames.get(i), raw, seenTypes.get(i))) { 593 return true; 594 } 595 } 596 return false; 597 } 598 } 599 600 // ---------- FK validation ---------- 601 602 /** 603 * Lenient FK type recognition. Matches {@code FK}, {@code FOREIGN KEY}, 604 * {@code FOREIGN_KEY} case-insensitively against the constraint's 605 * {@link ConstraintModel#type()} string. Hand-coded to avoid 606 * {@link String#equalsIgnoreCase} which the forbidden-apis plugin bans 607 * inside {@code catalog/**}. 608 */ 609 private static boolean isForeignKeyType(String type) { 610 if (type == null) return false; 611 return asciiEqualsIgnoreCase(type, "FK") 612 || asciiEqualsIgnoreCase(type, "FOREIGN KEY") 613 || asciiEqualsIgnoreCase(type, "FOREIGN_KEY"); 614 } 615 616 private static boolean asciiEqualsIgnoreCase(String a, String b) { 617 if (a == null || b == null) return a == b; 618 int len = a.length(); 619 if (b.length() != len) return false; 620 for (int i = 0; i < len; i++) { 621 char ca = a.charAt(i); 622 char cb = b.charAt(i); 623 if (ca == cb) continue; 624 if (ca >= 'A' && ca <= 'Z') ca = (char) (ca + 32); 625 if (cb >= 'A' && cb <= 'Z') cb = (char) (cb + 32); 626 if (ca != cb) return false; 627 } 628 return true; 629 } 630 631 private void validateForeignKey(ConstraintModel cs, 632 String fkLocation, 633 String currentSchema, 634 EDbVendor vendor, 635 NameRules service, 636 CatalogTableIndex tableIndex, 637 List<CatalogDiagnostic> diagnostics) { 638 String csLabel = "constraint '" + (cs.name() == null ? "<unnamed>" : cs.name()) 639 + "' on table '" + fkLocation + "'"; 640 String referencedTable = cs.referencedTable(); 641 if (referencedTable == null || referencedTable.isEmpty()) { 642 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE, 643 csLabel + " is a FOREIGN KEY but has no referencedTable")); 644 return; 645 } 646 List<String> refCols = cs.referencedColumns(); 647 if (refCols.size() != cs.columns().size()) { 648 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE, 649 csLabel + " has " + cs.columns().size() + " column(s) but " 650 + refCols.size() + " referencedColumn(s); FK column counts must match")); 651 // Continue to surface other issues (target table resolution) — don't bail. 652 } 653 654 CatalogTableIndex.Lookup resolved = tableIndex.resolve(referencedTable, currentSchema); 655 switch (resolved.kind) { 656 case NOT_FOUND: 657 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE, 658 csLabel + " references unknown table '" + referencedTable + "'")); 659 return; 660 case AMBIGUOUS: 661 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE, 662 csLabel + " references ambiguous bare table name '" + referencedTable 663 + "'; qualify with schema")); 664 return; 665 case FOUND: 666 default: 667 break; 668 } 669 670 // Verify each referencedColumn exists on the target table. 671 TableModel target = resolved.table; 672 for (String col : refCols) { 673 if (!columnExists(target, col, service)) { 674 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE, 675 csLabel + " references unknown column '" + col 676 + "' on referenced table '" + referencedTable + "'")); 677 } 678 } 679 } 680 681 private static boolean columnExists(TableModel t, String columnName, NameRules service) { 682 if (columnName == null) return false; 683 for (ColumnModel c : t.columns()) { 684 if (service.areEqual(service.lex(ESQLDataObjectType.dotColumn, c.name()), 685 service.lex(ESQLDataObjectType.dotColumn, columnName), ESQLDataObjectType.dotColumn)) { 686 return true; 687 } 688 } 689 return false; 690 } 691 692 /** 693 * Per-catalog table lookup index, built once at {@link #validateCatalog} entry and 694 * threaded down so each FK constraint can resolve its {@code referencedTable} 695 * against the whole catalog (not just the current schema). 696 * 697 * <p>Keys are normalized via {@link IdentifierService#normalize} so the lookup 698 * honors the vendor's identifier-folding rule (Oracle uppercase, PG lowercase, 699 * etc.). Three key shapes are stored:</p> 700 * <ul> 701 * <li><b>Catalog-qualified</b> ({@code "catalog.schema.table"}) — populated 702 * for every table. Lets an FK target an explicit 703 * {@code ConstraintModel#referencedTable} of the form 704 * {@code "ORCL.HR.DEPT"} (the form the Javadoc documents).</li> 705 * <li><b>Schema-qualified</b> ({@code "schema.table"}) — populated for every 706 * table. The primary lookup for FK references of the form 707 * {@code "schema.table"}.</li> 708 * <li><b>Bare</b> ({@code "table"}) — fallback when the FK's 709 * {@code referencedTable} has no dot. If a bare name appears in 2+ 710 * schemas, it is marked ambiguous and resolution then requires the 711 * caller to qualify.</li> 712 * </ul> 713 * 714 * <p><b>Resolution order for bare {@code referencedTable}:</b></p> 715 * <ol> 716 * <li>First try {@code currentSchema + "." + ref} — current-schema-first 717 * precedence, matching CREATE TABLE / FK semantics in real databases. 718 * A local same-named table wins over a same-named table in another 719 * schema even when the bare name is ambiguous catalog-wide.</li> 720 * <li>Otherwise fall back to a catalog-wide bare lookup; ambiguity is only 721 * reported when the bare name doesn't resolve in the current schema.</li> 722 * </ol> 723 */ 724 private static final class CatalogTableIndex { 725 726 enum Kind { FOUND, NOT_FOUND, AMBIGUOUS } 727 728 static final class Lookup { 729 final Kind kind; 730 final TableModel table; 731 Lookup(Kind k, TableModel t) { this.kind = k; this.table = t; } 732 } 733 734 private final NameRules service; 735 // Catalog-qualified ("catalog.schema.table") and schema-qualified ("schema.table") keys 736 // share this map. Both forms are pre-normalized once at build time. 737 private final Map<String, TableModel> byQualifiedKey; 738 private final Map<String, TableModel> byBareKey; 739 private final Map<String, Boolean> bareNameAmbiguous; 740 741 private CatalogTableIndex(NameRules service) { 742 this.service = service; 743 this.byQualifiedKey = new LinkedHashMap<String, TableModel>(); 744 this.byBareKey = new HashMap<String, TableModel>(); 745 this.bareNameAmbiguous = new HashMap<String, Boolean>(); 746 } 747 748 static CatalogTableIndex build(CatalogModel c, NameRules service) { 749 CatalogTableIndex idx = new CatalogTableIndex(service); 750 String catalogName = service.lex(ESQLDataObjectType.dotCatalog, c.name()); 751 for (SchemaModel s : c.schemas()) { 752 String schemaName = service.lex(ESQLDataObjectType.dotSchema, s.name()); 753 for (TableModel t : s.tables()) { 754 String bare = service.lex(ESQLDataObjectType.dotTable, t.name()); 755 if (bare == null || bare.isEmpty()) continue; 756 if (schemaName != null && !schemaName.isEmpty()) { 757 String schemaQual = service.normalize( 758 schemaName + "." + bare, ESQLDataObjectType.dotTable); 759 idx.byQualifiedKey.put(schemaQual, t); 760 if (catalogName != null && !catalogName.isEmpty()) { 761 String fullyQual = service.normalize( 762 catalogName + "." + schemaName + "." + bare, 763 ESQLDataObjectType.dotTable); 764 idx.byQualifiedKey.put(fullyQual, t); 765 } 766 } 767 String bareKey = service.normalize(bare, ESQLDataObjectType.dotTable); 768 if (idx.byBareKey.containsKey(bareKey)) { 769 idx.bareNameAmbiguous.put(bareKey, Boolean.TRUE); 770 } else { 771 idx.byBareKey.put(bareKey, t); 772 } 773 } 774 } 775 return idx; 776 } 777 778 Lookup resolve(String referencedTable, String currentSchema) { 779 if (referencedTable == null || referencedTable.isEmpty()) { 780 return new Lookup(Kind.NOT_FOUND, null); 781 } 782 // Case 1: ref has a dot — treat as qualified. Tries both shapes the index 783 // stores (catalog.schema.table and schema.table) without further parsing. 784 if (referencedTable.indexOf('.') >= 0) { 785 String qual = service.normalize(referencedTable, ESQLDataObjectType.dotTable); 786 TableModel hit = byQualifiedKey.get(qual); 787 if (hit != null) return new Lookup(Kind.FOUND, hit); 788 return new Lookup(Kind.NOT_FOUND, null); 789 } 790 // Case 2: ref is bare. Try current schema first (current-schema-first 791 // precedence — matches FK / CREATE TABLE semantics in real databases). 792 if (currentSchema != null && !currentSchema.isEmpty()) { 793 // currentSchema is a model name and follows its form; referencedTable is a 794 // reference and is SQL text (see UnifiedCatalogModel.lexical). 795 String qual = service.normalize( 796 service.lex(ESQLDataObjectType.dotSchema, currentSchema) + "." + referencedTable, 797 ESQLDataObjectType.dotTable); 798 TableModel hit = byQualifiedKey.get(qual); 799 if (hit != null) return new Lookup(Kind.FOUND, hit); 800 } 801 // Case 3: ref is bare and not in current schema. Fall back to catalog-wide 802 // bare lookup; ambiguity gate fires only when the local-schema shortcut missed. 803 String bareKey = service.normalize(referencedTable, ESQLDataObjectType.dotTable); 804 if (Boolean.TRUE.equals(bareNameAmbiguous.get(bareKey))) { 805 return new Lookup(Kind.AMBIGUOUS, null); 806 } 807 TableModel hit = byBareKey.get(bareKey); 808 if (hit != null) return new Lookup(Kind.FOUND, hit); 809 return new Lookup(Kind.NOT_FOUND, null); 810 } 811 } 812}