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        IdentifierService service = CatalogIdentifierPolicy.identifierServiceFor(cfg, vendor);
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                                 IdentifierService 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                                IdentifierService 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                               IdentifierService 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                              IdentifierService 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                                 IdentifierService 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                                 IdentifierService 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                                  IdentifierService 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                                IdentifierService 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                             IdentifierService 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        // For COLLATION_BASED dialects (MSSQL/Azure SQL with case-insensitive collation,
350        // MySQL lower_case_table_names=2) IdentifierService.normalize preserves the input
351        // spelling, so we never reach this point for them — no false positives. The
352        // condition above (normalized != name) is enough to identify a bypass for the
353        // case-folding dialects (Oracle/DB2 → UPPER, Postgres/Greenplum/Redshift → lower)
354        // we care about.
355        diagnostics.add(CatalogDiagnostic.builder()
356            .severity(CatalogDiagnosticSeverity.WARN)
357            .code(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS)
358            .message(location + ": raw name '" + name + "' is not in canonical form for "
359                + vendor + " (expected '" + normalized + "'). The reader bypassed "
360                + "IdentifierService normalization or normalizeOnLoad must be disabled.")
361            .repairHint("Run input through CatalogIdentifierPolicy.normalize / "
362                + "IdentifierService.normalize before constructing the model, or set "
363                + "CatalogLoadOptions.normalizeOnLoad(false) when the source intentionally "
364                + "preserves a non-canonical form.")
365            .build());
366    }
367
368    private void flagIfDuplicate(IdentifierBucket bucket,
369                                 String name,
370                                 ESQLDataObjectType type,
371                                 CatalogObjectKind kind,
372                                 String location,
373                                 List<CatalogDiagnostic> diagnostics) {
374        if (name == null) return;
375        if (!bucket.add(name, type)) {
376            diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_DUPLICATE_NAME,
377                "duplicate " + labelFor(kind) + ": " + location));
378        }
379    }
380
381    private static CatalogDiagnostic error(CatalogDiagnosticCode code, String message) {
382        return CatalogDiagnostic.builder()
383            .severity(CatalogDiagnosticSeverity.ERROR)
384            .code(code)
385            .message(message)
386            .build();
387    }
388
389    private static String qualified(String catalog, String schema, String name) {
390        StringBuilder sb = new StringBuilder();
391        if (catalog != null && !catalog.isEmpty()) sb.append(catalog).append('.');
392        if (schema != null && !schema.isEmpty()) sb.append(schema).append('.');
393        sb.append(name);
394        return sb.toString();
395    }
396
397    /**
398     * Choose the {@link IdentifierConfig} the validator should compare against.
399     *
400     * <p>Plan §9.2: the model carries its own config (frequently the vendor default,
401     * sometimes adapter-supplied to model MySQL {@code lower_case_table_names} or
402     * MSSQL collation). The caller may pass a {@link CatalogLoadOptions} with an
403     * explicit override — in that case the override wins, after a vendor-consistency
404     * check. When the caller did not pass an explicit override, the model's config is
405     * authoritative.</p>
406     */
407    private static IdentifierConfig effectiveConfig(UnifiedCatalogModel model,
408                                                    CatalogLoadOptions options,
409                                                    List<CatalogDiagnostic> diagnostics) {
410        IdentifierConfig modelCfg = model.identifierConfig();
411        // Always check the model's own identifierConfig vendor matches the model vendor —
412        // this is independent of the options path and catches a manifest that declared a
413        // mismatched IdentifierConfig at the top level (rare but possible when readers
414        // forward a config supplied by a different layer of code).
415        if (modelCfg != null && modelCfg.vendor() != model.vendor()) {
416            diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS,
417                "UnifiedCatalogModel.identifierConfig.vendor=" + modelCfg.vendor()
418                    + " does not match model.vendor=" + model.vendor()));
419        }
420        if (options == null) {
421            return modelCfg;
422        }
423        // Always check options.vendor against model.vendor — a downstream loader / runtime
424        // is going to be driven by options.vendor (CatalogQuery, CatalogLoaders), so a
425        // mismatch is a real configuration bug regardless of whether identifierConfig was
426        // explicit.
427        if (options.vendor() != model.vendor()) {
428            diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS,
429                "CatalogLoadOptions.vendor=" + options.vendor()
430                    + " does not match model.vendor=" + model.vendor()));
431        }
432        if (!options.hasExplicitIdentifierConfig()) {
433            return modelCfg;
434        }
435        IdentifierConfig optsCfg = options.identifierConfig();
436        if (optsCfg.vendor() != model.vendor()) {
437            diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_IDENTIFIER_BYPASS,
438                "CatalogLoadOptions.identifierConfig.vendor=" + optsCfg.vendor()
439                    + " does not match model.vendor=" + model.vendor()));
440            return modelCfg;
441        }
442        return optsCfg;
443    }
444
445    private static ESQLDataObjectType leafTypeFor(CatalogObjectKind kind) {
446        switch (kind) {
447            case FUNCTION:  return ESQLDataObjectType.dotFunction;
448            case PROCEDURE: return ESQLDataObjectType.dotProcedure;
449            case PACKAGE:   return ESQLDataObjectType.dotOraclePackage;
450            case ROUTINE:   return ESQLDataObjectType.dotRoutine;
451            default:        return ESQLDataObjectType.dotUnknown;
452        }
453    }
454
455    /**
456     * Diagnostic-friendly lower-case label for a kind. Hand-coded to avoid
457     * {@link String#toLowerCase()}, which the forbidden-apis plugin bans inside
458     * {@code catalog/**} (plan §9.5).
459     */
460    private static String labelFor(CatalogObjectKind kind) {
461        switch (kind) {
462            case CATALOG:           return "catalog";
463            case SCHEMA:            return "schema";
464            case TABLE:             return "table";
465            case VIEW:              return "view";
466            case MATERIALIZED_VIEW: return "materialized view";
467            case COLUMN:            return "column";
468            case ROUTINE:           return "routine";
469            case FUNCTION:          return "function";
470            case PROCEDURE:         return "procedure";
471            case PACKAGE:           return "package";
472            case SYNONYM:           return "synonym";
473            case SEQUENCE:          return "sequence";
474            case TYPE:              return "type";
475            case TRIGGER:           return "trigger";
476            case INDEX:             return "index";
477            case CONSTRAINT:        return "constraint";
478            default:                return "object";
479        }
480    }
481
482    /**
483     * Per-scope identifier deduplication bucket. Calls {@link IdentifierService#areEqual}
484     * for every membership test so dialects whose compare rule is case-insensitive but
485     * whose {@code normalize} preserves spelling (BigQuery columns, MSSQL with case-
486     * insensitive collation, MySQL {@code lower_case_table_names=2}) still catch
487     * collisions that a {@code Set<String>} of normalize keys would miss.
488     *
489     * <p>The {@link ESQLDataObjectType} used for comparison is supplied per-{@link #add(String, ESQLDataObjectType)}
490     * call rather than fixed at construction. This matters when one logical namespace
491     * mixes kinds that have different per-vendor case-sensitivity rules — e.g., MySQL's
492     * default schema namespace where tables compare case-sensitively
493     * ({@link ESQLDataObjectType#dotTable}) but routines compare case-insensitively
494     * ({@link ESQLDataObjectType#dotRoutine}/{@code dotFunction}/{@code dotProcedure}).
495     * Each entry is checked against existing entries using the type registered on
496     * insert; duplicate detection is symmetric (existing → new and new → existing).</p>
497     */
498    static final class IdentifierBucket {
499        private final IdentifierService service;
500        private final List<String> seenNames = new ArrayList<String>();
501        private final List<ESQLDataObjectType> seenTypes = new ArrayList<ESQLDataObjectType>();
502
503        IdentifierBucket(IdentifierService service) {
504            this.service = service;
505        }
506
507        /** Add a name with its kind-specific compare type. */
508        boolean add(String raw, ESQLDataObjectType type) {
509            if (raw == null) return true;
510            for (int i = 0; i < seenNames.size(); i++) {
511                String existing = seenNames.get(i);
512                ESQLDataObjectType existingType = seenTypes.get(i);
513                // Cross-kind compare uses the more permissive (incoming) type so the bucket
514                // catches "table Fn vs function fn" collisions even when only one of the
515                // two compare rules is case-insensitive.
516                if (service.areEqual(existing, raw, type)
517                    || service.areEqual(existing, raw, existingType)) {
518                    return false;
519                }
520            }
521            seenNames.add(raw);
522            seenTypes.add(type);
523            return true;
524        }
525
526        /**
527         * Membership test against entries already added with their per-call type.
528         * Used by constraint/index references against column names — the caller passes
529         * {@link ESQLDataObjectType#dotColumn} (which is what those entries were added
530         * with anyway, but the API stays explicit).
531         */
532        boolean contains(String raw, ESQLDataObjectType type) {
533            if (raw == null) return false;
534            for (int i = 0; i < seenNames.size(); i++) {
535                if (service.areEqual(seenNames.get(i), raw, type)
536                    || service.areEqual(seenNames.get(i), raw, seenTypes.get(i))) {
537                    return true;
538                }
539            }
540            return false;
541        }
542    }
543
544    // ---------- FK validation ----------
545
546    /**
547     * Lenient FK type recognition. Matches {@code FK}, {@code FOREIGN KEY},
548     * {@code FOREIGN_KEY} case-insensitively against the constraint's
549     * {@link ConstraintModel#type()} string. Hand-coded to avoid
550     * {@link String#equalsIgnoreCase} which the forbidden-apis plugin bans
551     * inside {@code catalog/**}.
552     */
553    private static boolean isForeignKeyType(String type) {
554        if (type == null) return false;
555        return asciiEqualsIgnoreCase(type, "FK")
556            || asciiEqualsIgnoreCase(type, "FOREIGN KEY")
557            || asciiEqualsIgnoreCase(type, "FOREIGN_KEY");
558    }
559
560    private static boolean asciiEqualsIgnoreCase(String a, String b) {
561        if (a == null || b == null) return a == b;
562        int len = a.length();
563        if (b.length() != len) return false;
564        for (int i = 0; i < len; i++) {
565            char ca = a.charAt(i);
566            char cb = b.charAt(i);
567            if (ca == cb) continue;
568            if (ca >= 'A' && ca <= 'Z') ca = (char) (ca + 32);
569            if (cb >= 'A' && cb <= 'Z') cb = (char) (cb + 32);
570            if (ca != cb) return false;
571        }
572        return true;
573    }
574
575    private void validateForeignKey(ConstraintModel cs,
576                                    String fkLocation,
577                                    String currentSchema,
578                                    EDbVendor vendor,
579                                    IdentifierService service,
580                                    CatalogTableIndex tableIndex,
581                                    List<CatalogDiagnostic> diagnostics) {
582        String csLabel = "constraint '" + (cs.name() == null ? "<unnamed>" : cs.name())
583            + "' on table '" + fkLocation + "'";
584        String referencedTable = cs.referencedTable();
585        if (referencedTable == null || referencedTable.isEmpty()) {
586            diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE,
587                csLabel + " is a FOREIGN KEY but has no referencedTable"));
588            return;
589        }
590        List<String> refCols = cs.referencedColumns();
591        if (refCols.size() != cs.columns().size()) {
592            diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE,
593                csLabel + " has " + cs.columns().size() + " column(s) but "
594                    + refCols.size() + " referencedColumn(s); FK column counts must match"));
595            // Continue to surface other issues (target table resolution) — don't bail.
596        }
597
598        CatalogTableIndex.Lookup resolved = tableIndex.resolve(referencedTable, currentSchema);
599        switch (resolved.kind) {
600            case NOT_FOUND:
601                diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE,
602                    csLabel + " references unknown table '" + referencedTable + "'"));
603                return;
604            case AMBIGUOUS:
605                diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE,
606                    csLabel + " references ambiguous bare table name '" + referencedTable
607                        + "'; qualify with schema"));
608                return;
609            case FOUND:
610            default:
611                break;
612        }
613
614        // Verify each referencedColumn exists on the target table.
615        TableModel target = resolved.table;
616        for (String col : refCols) {
617            if (!columnExists(target, col, service)) {
618                diagnostics.add(error(CatalogDiagnosticCode.CATALOG_VALIDATION_INVALID_REFERENCE,
619                    csLabel + " references unknown column '" + col
620                        + "' on referenced table '" + referencedTable + "'"));
621            }
622        }
623    }
624
625    private static boolean columnExists(TableModel t, String columnName, IdentifierService service) {
626        if (columnName == null) return false;
627        for (ColumnModel c : t.columns()) {
628            if (service.areEqual(c.name(), columnName, ESQLDataObjectType.dotColumn)) {
629                return true;
630            }
631        }
632        return false;
633    }
634
635    /**
636     * Per-catalog table lookup index, built once at {@link #validateCatalog} entry and
637     * threaded down so each FK constraint can resolve its {@code referencedTable}
638     * against the whole catalog (not just the current schema).
639     *
640     * <p>Keys are normalized via {@link IdentifierService#normalize} so the lookup
641     * honors the vendor's identifier-folding rule (Oracle uppercase, PG lowercase,
642     * etc.). Three key shapes are stored:</p>
643     * <ul>
644     *   <li><b>Catalog-qualified</b> ({@code "catalog.schema.table"}) — populated
645     *       for every table. Lets an FK target an explicit
646     *       {@code ConstraintModel#referencedTable} of the form
647     *       {@code "ORCL.HR.DEPT"} (the form the Javadoc documents).</li>
648     *   <li><b>Schema-qualified</b> ({@code "schema.table"}) — populated for every
649     *       table. The primary lookup for FK references of the form
650     *       {@code "schema.table"}.</li>
651     *   <li><b>Bare</b> ({@code "table"}) — fallback when the FK's
652     *       {@code referencedTable} has no dot. If a bare name appears in 2+
653     *       schemas, it is marked ambiguous and resolution then requires the
654     *       caller to qualify.</li>
655     * </ul>
656     *
657     * <p><b>Resolution order for bare {@code referencedTable}:</b></p>
658     * <ol>
659     *   <li>First try {@code currentSchema + "." + ref} — current-schema-first
660     *       precedence, matching CREATE TABLE / FK semantics in real databases.
661     *       A local same-named table wins over a same-named table in another
662     *       schema even when the bare name is ambiguous catalog-wide.</li>
663     *   <li>Otherwise fall back to a catalog-wide bare lookup; ambiguity is only
664     *       reported when the bare name doesn't resolve in the current schema.</li>
665     * </ol>
666     */
667    private static final class CatalogTableIndex {
668
669        enum Kind { FOUND, NOT_FOUND, AMBIGUOUS }
670
671        static final class Lookup {
672            final Kind kind;
673            final TableModel table;
674            Lookup(Kind k, TableModel t) { this.kind = k; this.table = t; }
675        }
676
677        private final IdentifierService service;
678        // Catalog-qualified ("catalog.schema.table") and schema-qualified ("schema.table") keys
679        // share this map. Both forms are pre-normalized once at build time.
680        private final Map<String, TableModel> byQualifiedKey;
681        private final Map<String, TableModel> byBareKey;
682        private final Map<String, Boolean> bareNameAmbiguous;
683
684        private CatalogTableIndex(IdentifierService service) {
685            this.service = service;
686            this.byQualifiedKey = new LinkedHashMap<String, TableModel>();
687            this.byBareKey = new HashMap<String, TableModel>();
688            this.bareNameAmbiguous = new HashMap<String, Boolean>();
689        }
690
691        static CatalogTableIndex build(CatalogModel c, IdentifierService service) {
692            CatalogTableIndex idx = new CatalogTableIndex(service);
693            String catalogName = c.name();
694            for (SchemaModel s : c.schemas()) {
695                String schemaName = s.name();
696                for (TableModel t : s.tables()) {
697                    String bare = t.name();
698                    if (bare == null || bare.isEmpty()) continue;
699                    if (schemaName != null && !schemaName.isEmpty()) {
700                        String schemaQual = service.normalize(
701                            schemaName + "." + bare, ESQLDataObjectType.dotTable);
702                        idx.byQualifiedKey.put(schemaQual, t);
703                        if (catalogName != null && !catalogName.isEmpty()) {
704                            String fullyQual = service.normalize(
705                                catalogName + "." + schemaName + "." + bare,
706                                ESQLDataObjectType.dotTable);
707                            idx.byQualifiedKey.put(fullyQual, t);
708                        }
709                    }
710                    String bareKey = service.normalize(bare, ESQLDataObjectType.dotTable);
711                    if (idx.byBareKey.containsKey(bareKey)) {
712                        idx.bareNameAmbiguous.put(bareKey, Boolean.TRUE);
713                    } else {
714                        idx.byBareKey.put(bareKey, t);
715                    }
716                }
717            }
718            return idx;
719        }
720
721        Lookup resolve(String referencedTable, String currentSchema) {
722            if (referencedTable == null || referencedTable.isEmpty()) {
723                return new Lookup(Kind.NOT_FOUND, null);
724            }
725            // Case 1: ref has a dot — treat as qualified. Tries both shapes the index
726            // stores (catalog.schema.table and schema.table) without further parsing.
727            if (referencedTable.indexOf('.') >= 0) {
728                String qual = service.normalize(referencedTable, ESQLDataObjectType.dotTable);
729                TableModel hit = byQualifiedKey.get(qual);
730                if (hit != null) return new Lookup(Kind.FOUND, hit);
731                return new Lookup(Kind.NOT_FOUND, null);
732            }
733            // Case 2: ref is bare. Try current schema first (current-schema-first
734            // precedence — matches FK / CREATE TABLE semantics in real databases).
735            if (currentSchema != null && !currentSchema.isEmpty()) {
736                String qual = service.normalize(
737                    currentSchema + "." + referencedTable, ESQLDataObjectType.dotTable);
738                TableModel hit = byQualifiedKey.get(qual);
739                if (hit != null) return new Lookup(Kind.FOUND, hit);
740            }
741            // Case 3: ref is bare and not in current schema. Fall back to catalog-wide
742            // bare lookup; ambiguity gate fires only when the local-schema shortcut missed.
743            String bareKey = service.normalize(referencedTable, ESQLDataObjectType.dotTable);
744            if (Boolean.TRUE.equals(bareNameAmbiguous.get(bareKey))) {
745                return new Lookup(Kind.AMBIGUOUS, null);
746            }
747            TableModel hit = byBareKey.get(bareKey);
748            if (hit != null) return new Lookup(Kind.FOUND, hit);
749            return new Lookup(Kind.NOT_FOUND, null);
750        }
751    }
752}