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.List; 027 028/** 029 * Validates a {@link UnifiedCatalogModel} against required-field rules, duplicate-name 030 * checks, identifier-bypass detection, and table↔column referential integrity. 031 * 032 * <p>Plan §7.1 / §9.6. Identifier-bypass detection rejects models whose names disagree 033 * with {@code IdentifierService.normalize(...)} — catching adapters that sneak in a 034 * third-party canonicalization step. Strict-mode escalation is the caller's 035 * responsibility (see {@code CatalogLoadOptions.strict()} and §15).</p> 036 * 037 * <p>Validation is best-effort: it never throws; every problem is reported as a 038 * {@link CatalogDiagnostic} on the returned {@link CatalogValidationResult}. ERROR 039 * diagnostics fail validation; WARN/INFO do not.</p> 040 * 041 * <p>Duplicate detection uses {@link IdentifierService#areEqual} rather than a 042 * normalize-then-{@code Set} short-circuit: in dialects where the compare rule is 043 * case-insensitive but the canonical normalize preserves the input spelling 044 * (e.g., BigQuery columns, MySQL columns under {@code lower_case_table_names=2}, 045 * MSSQL with a case-insensitive collation), the keyset alone misses real 046 * duplicates. The cost is O(n²) per scope, which is acceptable since each scope 047 * is a single schema or table column list.</p> 048 */ 049public final class CatalogModelValidator { 050 051 public CatalogModelValidator() { 052 // No state. 053 } 054 055 public CatalogValidationResult validate(UnifiedCatalogModel model, CatalogLoadOptions options) { 056 if (model == null) { 057 throw new IllegalArgumentException("CatalogModelValidator.validate: model is required"); 058 } 059 List<CatalogDiagnostic> diagnostics = new ArrayList<CatalogDiagnostic>(); 060 EDbVendor vendor = model.vendor(); 061 IdentifierConfig cfg = effectiveConfig(model, options, diagnostics); 062 IdentifierService service = CatalogIdentifierPolicy.identifierServiceFor(cfg, vendor); 063 boolean normalizeOnLoad = options == null || options.normalizeOnLoad(); 064 065 IdentifierBucket catalogBucket = new IdentifierBucket(service); 066 for (CatalogModel c : model.catalogs()) { 067 validateCatalog(c, vendor, service, normalizeOnLoad, catalogBucket, diagnostics); 068 } 069 return CatalogValidationResult.of(diagnostics); 070 } 071 072 // ---------- catalog / schema / table / column traversal ---------- 073 074 private void validateCatalog(CatalogModel c, 075 EDbVendor vendor, 076 IdentifierService service, 077 boolean normalizeOnLoad, 078 IdentifierBucket catalogBucket, 079 List<CatalogDiagnostic> diagnostics) { 080 String name = c.name(); 081 checkRequired("catalog", name, diagnostics); 082 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotCatalog, 083 "catalog '" + name + "'", diagnostics); 084 flagIfDuplicate(catalogBucket, name, ESQLDataObjectType.dotCatalog, CatalogObjectKind.CATALOG, 085 "catalog '" + name + "'", diagnostics); 086 IdentifierBucket schemaBucket = new IdentifierBucket(service); 087 for (SchemaModel s : c.schemas()) { 088 validateSchema(s, vendor, service, normalizeOnLoad, c.name(), schemaBucket, diagnostics); 089 } 090 } 091 092 private void validateSchema(SchemaModel s, 093 EDbVendor vendor, 094 IdentifierService service, 095 boolean normalizeOnLoad, 096 String catalogName, 097 IdentifierBucket schemaBucket, 098 List<CatalogDiagnostic> diagnostics) { 099 String name = s.name(); 100 if (name == null) { 101 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_MISSING_DEFAULT, 102 "schema in catalog '" + catalogName + "' has null name")); 103 return; 104 } 105 if (name.isEmpty()) { 106 // Empty schema names are only legal for dialects without a schema layer 107 // (per TSQLEnv.supportSchema): MySQL, Teradata, Hive, Impala. For every 108 // other dialect — Oracle, PostgreSQL, MSSQL, BigQuery, etc. — an empty 109 // schema name silently re-qualifies child objects as catalog.object, 110 // which is a manifest bug worth surfacing. 111 if (TSQLEnv.supportSchema(vendor)) { 112 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_MISSING_DEFAULT, 113 "schema in catalog '" + catalogName 114 + "' has empty name; vendor " + vendor + " requires a schema layer")); 115 } 116 } else { 117 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotSchema, 118 "schema '" + catalogName + "." + name + "'", diagnostics); 119 } 120 flagIfDuplicate(schemaBucket, name, ESQLDataObjectType.dotSchema, CatalogObjectKind.SCHEMA, 121 "schema '" + catalogName + "." + name + "'", diagnostics); 122 123 // The existing catalog storage (TSQLCatalog) keeps table / view / routine / 124 // synonym / sequence indexes type-specific, so a schema may legitimately hold 125 // a table named 'Foo' and a function named 'Foo' that resolve by requested 126 // type. We honor that by using one bucket per object-type namespace. Tables 127 // and views share a bucket because in every dialect we cover (Oracle, MSSQL, 128 // PostgreSQL, MySQL, BigQuery) a CREATE TABLE and a CREATE VIEW with the same 129 // unqualified name in the same schema is a real collision. 130 IdentifierBucket tableViewBucket = new IdentifierBucket(service); 131 for (TableModel t : s.tables()) { 132 validateTable(t, vendor, service, normalizeOnLoad, catalogName, name, 133 tableViewBucket, diagnostics); 134 } 135 for (ViewModel v : s.views()) { 136 validateView(v, vendor, service, normalizeOnLoad, catalogName, name, 137 tableViewBucket, diagnostics); 138 } 139 IdentifierBucket routineBucket = new IdentifierBucket(service); 140 for (RoutineModel r : s.routines()) { 141 validateRoutine(r, vendor, service, normalizeOnLoad, catalogName, name, 142 routineBucket, diagnostics); 143 } 144 IdentifierBucket synonymBucket = new IdentifierBucket(service); 145 for (SynonymModel sy : s.synonyms()) { 146 validateSynonym(sy, vendor, service, normalizeOnLoad, catalogName, name, 147 synonymBucket, diagnostics); 148 } 149 IdentifierBucket sequenceBucket = new IdentifierBucket(service); 150 for (SequenceModel sq : s.sequences()) { 151 validateSequence(sq, vendor, service, normalizeOnLoad, catalogName, name, 152 sequenceBucket, diagnostics); 153 } 154 } 155 156 private void validateTable(TableModel t, 157 EDbVendor vendor, 158 IdentifierService service, 159 boolean normalizeOnLoad, 160 String catalogName, 161 String schemaName, 162 IdentifierBucket objectBucket, 163 List<CatalogDiagnostic> diagnostics) { 164 String name = t.name(); 165 String location = qualified(catalogName, schemaName, name); 166 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotTable, 167 "table '" + location + "'", diagnostics); 168 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotTable, CatalogObjectKind.TABLE, 169 "table '" + location + "'", diagnostics); 170 171 IdentifierBucket columnBucket = new IdentifierBucket(service); 172 for (ColumnModel c : t.columns()) { 173 validateColumn(c, vendor, service, normalizeOnLoad, location, columnBucket, diagnostics); 174 } 175 176 IdentifierBucket constraintBucket = new IdentifierBucket(service); 177 for (ConstraintModel cs : t.constraints()) { 178 String csName = cs.name(); 179 if (csName != null && !csName.isEmpty()) { 180 flagIfDuplicate(constraintBucket, csName, ESQLDataObjectType.dotUnknown, 181 CatalogObjectKind.CONSTRAINT, 182 "constraint '" + csName + "' on table '" + location + "'", diagnostics); 183 } 184 for (String col : cs.columns()) { 185 if (!columnBucket.contains(col, ESQLDataObjectType.dotColumn)) { 186 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME, 187 "constraint '" + (csName == null ? "<unnamed>" : csName) 188 + "' on table '" + location + "' references unknown column '" + col + "'")); 189 } 190 } 191 } 192 193 IdentifierBucket indexBucket = new IdentifierBucket(service); 194 for (IndexModel ix : t.indexes()) { 195 flagIfDuplicate(indexBucket, ix.name(), ESQLDataObjectType.dotUnknown, 196 CatalogObjectKind.INDEX, 197 "index '" + ix.name() + "' on table '" + location + "'", diagnostics); 198 for (String col : ix.columns()) { 199 if (!columnBucket.contains(col, ESQLDataObjectType.dotColumn)) { 200 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME, 201 "index '" + ix.name() + "' on table '" + location 202 + "' references unknown column '" + col + "'")); 203 } 204 } 205 } 206 } 207 208 private void validateView(ViewModel v, 209 EDbVendor vendor, 210 IdentifierService service, 211 boolean normalizeOnLoad, 212 String catalogName, 213 String schemaName, 214 IdentifierBucket objectBucket, 215 List<CatalogDiagnostic> diagnostics) { 216 String name = v.name(); 217 String location = qualified(catalogName, schemaName, name); 218 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotTable, 219 "view '" + location + "'", diagnostics); 220 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotTable, 221 v.materialized() ? CatalogObjectKind.MATERIALIZED_VIEW : CatalogObjectKind.VIEW, 222 "view '" + location + "'", diagnostics); 223 IdentifierBucket columnBucket = new IdentifierBucket(service); 224 for (ColumnModel c : v.columns()) { 225 validateColumn(c, vendor, service, normalizeOnLoad, location, columnBucket, diagnostics); 226 } 227 } 228 229 private void validateRoutine(RoutineModel r, 230 EDbVendor vendor, 231 IdentifierService service, 232 boolean normalizeOnLoad, 233 String catalogName, 234 String schemaName, 235 IdentifierBucket objectBucket, 236 List<CatalogDiagnostic> diagnostics) { 237 String name = r.name(); 238 String location = qualified(catalogName, schemaName, name); 239 // For diagnostic-message accuracy use the kind-specific leaf type when running 240 // the bypass check; for duplicate detection always compare with dotFunction 241 // because IdentifierProfile only routes dotFunction through ROUTINE_GROUP — 242 // dotProcedure / dotOraclePackage / dotRoutine fall back to NAME_GROUP. Using 243 // dotFunction here means MySQL procedures and packages share the routine 244 // case-insensitivity rule, which matches the actual MySQL behavior (stored 245 // routine names are case-insensitive regardless of routine kind). 246 ESQLDataObjectType bypassType = leafTypeFor(r.kind()); 247 ESQLDataObjectType compareType = ESQLDataObjectType.dotFunction; 248 String label = labelFor(r.kind()); 249 checkBypass(vendor, service, normalizeOnLoad, name, bypassType, 250 label + " '" + location + "'", diagnostics); 251 flagIfDuplicate(objectBucket, name, compareType, r.kind(), 252 label + " '" + location + "'", diagnostics); 253 } 254 255 private void validateSynonym(SynonymModel sy, 256 EDbVendor vendor, 257 IdentifierService service, 258 boolean normalizeOnLoad, 259 String catalogName, 260 String schemaName, 261 IdentifierBucket objectBucket, 262 List<CatalogDiagnostic> diagnostics) { 263 String name = sy.name(); 264 String location = qualified(catalogName, schemaName, name); 265 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotSynonyms, 266 "synonym '" + location + "'", diagnostics); 267 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotSynonyms, CatalogObjectKind.SYNONYM, 268 "synonym '" + location + "'", diagnostics); 269 } 270 271 private void validateSequence(SequenceModel sq, 272 EDbVendor vendor, 273 IdentifierService service, 274 boolean normalizeOnLoad, 275 String catalogName, 276 String schemaName, 277 IdentifierBucket objectBucket, 278 List<CatalogDiagnostic> diagnostics) { 279 String name = sq.name(); 280 String location = qualified(catalogName, schemaName, name); 281 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotUnknown, 282 "sequence '" + location + "'", diagnostics); 283 flagIfDuplicate(objectBucket, name, ESQLDataObjectType.dotUnknown, CatalogObjectKind.SEQUENCE, 284 "sequence '" + location + "'", diagnostics); 285 } 286 287 private void validateColumn(ColumnModel c, 288 EDbVendor vendor, 289 IdentifierService service, 290 boolean normalizeOnLoad, 291 String parentLocation, 292 IdentifierBucket columnBucket, 293 List<CatalogDiagnostic> diagnostics) { 294 String name = c.name(); 295 checkBypass(vendor, service, normalizeOnLoad, name, ESQLDataObjectType.dotColumn, 296 "column '" + parentLocation + "." + name + "'", diagnostics); 297 flagIfDuplicate(columnBucket, name, ESQLDataObjectType.dotColumn, CatalogObjectKind.COLUMN, 298 "column '" + parentLocation + "." + name + "'", diagnostics); 299 } 300 301 // ---------- helpers ---------- 302 303 private void checkRequired(String label, String value, List<CatalogDiagnostic> diagnostics) { 304 if (value == null || value.isEmpty()) { 305 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_MISSING_DEFAULT, 306 label + ".name is required")); 307 } 308 } 309 310 /** 311 * Identifier-bypass detection. When {@code normalizeOnLoad} is true, the raw name 312 * must already equal {@code IdentifierService.normalize(name)}; otherwise an adapter 313 * either pre-normalized through a non-{@code IdentifierService} path or shipped a 314 * raw form that the resolver will fail to match. We emit a WARN, not an ERROR, so 315 * downstream code remains tolerant — strict-mode callers escalate at the load 316 * entry point. 317 * 318 * <p>For COLLATION_BASED dialects (MSSQL/Azure SQL) where {@code normalize} preserves 319 * the input spelling, this check is a no-op: the resolver will route compares 320 * through {@code IdentifierService.areEqual} regardless of stored case.</p> 321 */ 322 private void checkBypass(EDbVendor vendor, 323 IdentifierService service, 324 boolean normalizeOnLoad, 325 String name, 326 ESQLDataObjectType type, 327 String location, 328 List<CatalogDiagnostic> diagnostics) { 329 if (!normalizeOnLoad || name == null || name.isEmpty()) { 330 return; 331 } 332 String normalized = service.normalize(name, type); 333 if (normalized == null || normalized.equals(name)) { 334 return; 335 } 336 // For COLLATION_BASED dialects (MSSQL/Azure SQL with case-insensitive collation, 337 // MySQL lower_case_table_names=2) IdentifierService.normalize preserves the input 338 // spelling, so we never reach this point for them — no false positives. The 339 // condition above (normalized != name) is enough to identify a bypass for the 340 // case-folding dialects (Oracle/DB2 → UPPER, Postgres/Greenplum/Redshift → lower) 341 // we care about. 342 diagnostics.add(CatalogDiagnostic.builder() 343 .severity(CatalogDiagnosticSeverity.WARN) 344 .code(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS) 345 .message(location + ": raw name '" + name + "' is not in canonical form for " 346 + vendor + " (expected '" + normalized + "'). The reader bypassed " 347 + "IdentifierService normalization or normalizeOnLoad must be disabled.") 348 .repairHint("Run input through CatalogIdentifierPolicy.normalize / " 349 + "IdentifierService.normalize before constructing the model, or set " 350 + "CatalogLoadOptions.normalizeOnLoad(false) when the source intentionally " 351 + "preserves a non-canonical form.") 352 .build()); 353 } 354 355 private void flagIfDuplicate(IdentifierBucket bucket, 356 String name, 357 ESQLDataObjectType type, 358 CatalogObjectKind kind, 359 String location, 360 List<CatalogDiagnostic> diagnostics) { 361 if (name == null) return; 362 if (!bucket.add(name, type)) { 363 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME, 364 "duplicate " + labelFor(kind) + ": " + location)); 365 } 366 } 367 368 private static CatalogDiagnostic error(CatalogDiagnosticCode code, String message) { 369 return CatalogDiagnostic.builder() 370 .severity(CatalogDiagnosticSeverity.ERROR) 371 .code(code) 372 .message(message) 373 .build(); 374 } 375 376 private static String qualified(String catalog, String schema, String name) { 377 StringBuilder sb = new StringBuilder(); 378 if (catalog != null && !catalog.isEmpty()) sb.append(catalog).append('.'); 379 if (schema != null && !schema.isEmpty()) sb.append(schema).append('.'); 380 sb.append(name); 381 return sb.toString(); 382 } 383 384 /** 385 * Choose the {@link IdentifierConfig} the validator should compare against. 386 * 387 * <p>Plan §9.2: the model carries its own config (frequently the vendor default, 388 * sometimes adapter-supplied to model MySQL {@code lower_case_table_names} or 389 * MSSQL collation). The caller may pass a {@link CatalogLoadOptions} with an 390 * explicit override — in that case the override wins, after a vendor-consistency 391 * check. When the caller did not pass an explicit override, the model's config is 392 * authoritative.</p> 393 */ 394 private static IdentifierConfig effectiveConfig(UnifiedCatalogModel model, 395 CatalogLoadOptions options, 396 List<CatalogDiagnostic> diagnostics) { 397 IdentifierConfig modelCfg = model.identifierConfig(); 398 // Always check the model's own identifierConfig vendor matches the model vendor — 399 // this is independent of the options path and catches a manifest that declared a 400 // mismatched IdentifierConfig at the top level (rare but possible when readers 401 // forward a config supplied by a different layer of code). 402 if (modelCfg != null && modelCfg.vendor() != model.vendor()) { 403 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS, 404 "UnifiedCatalogModel.identifierConfig.vendor=" + modelCfg.vendor() 405 + " does not match model.vendor=" + model.vendor())); 406 } 407 if (options == null) { 408 return modelCfg; 409 } 410 // Always check options.vendor against model.vendor — a downstream loader / runtime 411 // is going to be driven by options.vendor (CatalogQuery, CatalogLoaders), so a 412 // mismatch is a real configuration bug regardless of whether identifierConfig was 413 // explicit. 414 if (options.vendor() != model.vendor()) { 415 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS, 416 "CatalogLoadOptions.vendor=" + options.vendor() 417 + " does not match model.vendor=" + model.vendor())); 418 } 419 if (!options.hasExplicitIdentifierConfig()) { 420 return modelCfg; 421 } 422 IdentifierConfig optsCfg = options.identifierConfig(); 423 if (optsCfg.vendor() != model.vendor()) { 424 diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS, 425 "CatalogLoadOptions.identifierConfig.vendor=" + optsCfg.vendor() 426 + " does not match model.vendor=" + model.vendor())); 427 return modelCfg; 428 } 429 return optsCfg; 430 } 431 432 private static ESQLDataObjectType leafTypeFor(CatalogObjectKind kind) { 433 switch (kind) { 434 case FUNCTION: return ESQLDataObjectType.dotFunction; 435 case PROCEDURE: return ESQLDataObjectType.dotProcedure; 436 case PACKAGE: return ESQLDataObjectType.dotOraclePackage; 437 case ROUTINE: return ESQLDataObjectType.dotRoutine; 438 default: return ESQLDataObjectType.dotUnknown; 439 } 440 } 441 442 /** 443 * Diagnostic-friendly lower-case label for a kind. Hand-coded to avoid 444 * {@link String#toLowerCase()}, which the forbidden-apis plugin bans inside 445 * {@code catalog/**} (plan §9.5). 446 */ 447 private static String labelFor(CatalogObjectKind kind) { 448 switch (kind) { 449 case CATALOG: return "catalog"; 450 case SCHEMA: return "schema"; 451 case TABLE: return "table"; 452 case VIEW: return "view"; 453 case MATERIALIZED_VIEW: return "materialized view"; 454 case COLUMN: return "column"; 455 case ROUTINE: return "routine"; 456 case FUNCTION: return "function"; 457 case PROCEDURE: return "procedure"; 458 case PACKAGE: return "package"; 459 case SYNONYM: return "synonym"; 460 case SEQUENCE: return "sequence"; 461 case TYPE: return "type"; 462 case TRIGGER: return "trigger"; 463 case INDEX: return "index"; 464 case CONSTRAINT: return "constraint"; 465 default: return "object"; 466 } 467 } 468 469 /** 470 * Per-scope identifier deduplication bucket. Calls {@link IdentifierService#areEqual} 471 * for every membership test so dialects whose compare rule is case-insensitive but 472 * whose {@code normalize} preserves spelling (BigQuery columns, MSSQL with case- 473 * insensitive collation, MySQL {@code lower_case_table_names=2}) still catch 474 * collisions that a {@code Set<String>} of normalize keys would miss. 475 * 476 * <p>The {@link ESQLDataObjectType} used for comparison is supplied per-{@link #add(String, ESQLDataObjectType)} 477 * call rather than fixed at construction. This matters when one logical namespace 478 * mixes kinds that have different per-vendor case-sensitivity rules — e.g., MySQL's 479 * default schema namespace where tables compare case-sensitively 480 * ({@link ESQLDataObjectType#dotTable}) but routines compare case-insensitively 481 * ({@link ESQLDataObjectType#dotRoutine}/{@code dotFunction}/{@code dotProcedure}). 482 * Each entry is checked against existing entries using the type registered on 483 * insert; duplicate detection is symmetric (existing → new and new → existing).</p> 484 */ 485 static final class IdentifierBucket { 486 private final IdentifierService service; 487 private final List<String> seenNames = new ArrayList<String>(); 488 private final List<ESQLDataObjectType> seenTypes = new ArrayList<ESQLDataObjectType>(); 489 490 IdentifierBucket(IdentifierService service) { 491 this.service = service; 492 } 493 494 /** Add a name with its kind-specific compare type. */ 495 boolean add(String raw, ESQLDataObjectType type) { 496 if (raw == null) return true; 497 for (int i = 0; i < seenNames.size(); i++) { 498 String existing = seenNames.get(i); 499 ESQLDataObjectType existingType = seenTypes.get(i); 500 // Cross-kind compare uses the more permissive (incoming) type so the bucket 501 // catches "table Fn vs function fn" collisions even when only one of the 502 // two compare rules is case-insensitive. 503 if (service.areEqual(existing, raw, type) 504 || service.areEqual(existing, raw, existingType)) { 505 return false; 506 } 507 } 508 seenNames.add(raw); 509 seenTypes.add(type); 510 return true; 511 } 512 513 /** 514 * Membership test against entries already added with their per-call type. 515 * Used by constraint/index references against column names — the caller passes 516 * {@link ESQLDataObjectType#dotColumn} (which is what those entries were added 517 * with anyway, but the API stays explicit). 518 */ 519 boolean contains(String raw, ESQLDataObjectType type) { 520 if (raw == null) return false; 521 for (int i = 0; i < seenNames.size(); i++) { 522 if (service.areEqual(seenNames.get(i), raw, type) 523 || service.areEqual(seenNames.get(i), raw, seenTypes.get(i))) { 524 return true; 525 } 526 } 527 return false; 528 } 529 } 530}