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