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}