Skip to main content

fraiseql_cli/schema/
database_validator.rs

1//! Compile-time database validation for schema definitions.
2//!
3//! Validates a compiled schema against a live database at three levels:
4//! - **L1**: `sql_source` relation exists in the database
5//! - **L2**: Columns and JSON column types match
6//! - **L3**: JSONB keys exist in sampled rows (best-effort)
7//!
8//! All diagnostics are warnings — compilation never fails due to validation.
9
10use std::{
11    collections::{HashMap, HashSet},
12    fmt,
13};
14
15use fraiseql_core::{
16    db::{
17        DatabaseType,
18        introspector::{DatabaseIntrospector, RelationInfo},
19    },
20    schema::{CompiledSchema, FieldType, SourceKind, SourceProbe, sql_source_probes},
21};
22
23/// Report containing all database validation warnings and discovered metadata.
24pub struct DatabaseValidationReport {
25    /// All warnings emitted during validation.
26    pub warnings:       Vec<DatabaseWarning>,
27    /// Native columns discovered per query during L2 validation.
28    ///
29    /// Key: query name. Value: map of argument name → PostgreSQL type string
30    /// (e.g. `"uuid"`, `"integer"`, `"text"`).
31    ///
32    /// Only contains entries for queries that have at least one direct argument
33    /// with a matching native column on their `sql_source`.
34    pub native_columns: HashMap<String, HashMap<String, String>>,
35}
36
37/// A single database validation warning.
38#[derive(Debug)]
39pub enum DatabaseWarning {
40    /// L1: `sql_source` relation does not exist.
41    MissingRelation {
42        /// Name of the query or mutation.
43        query_name: String,
44        /// The `sql_source` value that was not found.
45        sql_source: String,
46    },
47    /// L1: a mutation's `sql_source` **function** does not exist.
48    ///
49    /// Distinct from [`MissingRelation`](Self::MissingRelation): a mutation's
50    /// `sql_source` names a SQL *function* (the runtime calls
51    /// `SELECT * FROM fn(...)`), not a view/table, so it is probed via `pg_proc`
52    /// rather than the relation catalog.
53    MissingFunction {
54        /// Name of the mutation.
55        mutation_name: String,
56        /// The `sql_source` function that was not found.
57        sql_source:    String,
58    },
59    /// L1: `additional_view` does not exist.
60    MissingAdditionalView {
61        /// Name of the query.
62        query_name: String,
63        /// The view name that was not found.
64        view_name:  String,
65    },
66    /// L2: `jsonb_column` does not exist on the relation.
67    MissingJsonColumn {
68        /// Name of the query.
69        query_name:  String,
70        /// The `sql_source` relation.
71        sql_source:  String,
72        /// The missing column name.
73        column_name: String,
74    },
75    /// L2: `jsonb_column` exists but is not a JSON/JSONB type.
76    WrongJsonColumnType {
77        /// Name of the query.
78        query_name:  String,
79        /// The `sql_source` relation.
80        sql_source:  String,
81        /// The column name.
82        column_name: String,
83        /// The actual SQL data type.
84        actual_type: String,
85    },
86    /// L2: `relay_cursor_column` does not exist on the relation.
87    MissingCursorColumn {
88        /// Name of the query.
89        query_name:  String,
90        /// The `sql_source` relation.
91        sql_source:  String,
92        /// The missing cursor column name.
93        column_name: String,
94    },
95    /// L3: a JSON key path is declared but not found in sampled data.
96    MissingJsonKey {
97        /// Name of the query.
98        query_name:  String,
99        /// The GraphQL type that declares the field (where to fix it).
100        type_name:   String,
101        /// The `sql_source` relation.
102        sql_source:  String,
103        /// The JSON column being sampled.
104        json_column: String,
105        /// The GraphQL field name.
106        field_name:  String,
107        /// The snake_case key looked up in the JSON.
108        json_key:    String,
109    },
110    /// L2: a direct query argument has no matching native column — will fall back to JSONB
111    /// extraction.
112    ///
113    /// For best performance, consider adding a native column with the same name
114    /// and an index on the `sql_source` table/view.
115    NativeColumnFallback {
116        /// Name of the query.
117        query_name: String,
118        /// The `sql_source` relation.
119        sql_source: String,
120        /// The argument name that has no matching native column.
121        arg_name:   String,
122    },
123    /// L2: a direct query argument resolves to a native column whose SQL type cannot
124    /// cleanly drive the predicate for the argument's GraphQL scalar type (e.g. an
125    /// `Int` argument filtering a `uuid` column — `WHERE col = $N` errors or never
126    /// matches).
127    TypeConvertibility {
128        /// Name of the query.
129        query_name:   String,
130        /// The `sql_source` relation.
131        sql_source:   String,
132        /// The argument name.
133        arg_name:     String,
134        /// The argument's declared GraphQL type (e.g. `Int`).
135        graphql_type: String,
136        /// The native column's SQL type (e.g. `uuid`).
137        column_type:  String,
138    },
139}
140
141impl fmt::Display for DatabaseWarning {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::MissingRelation {
145                query_name,
146                sql_source,
147            } => {
148                write!(
149                    f,
150                    "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
151                )
152            },
153            Self::MissingFunction {
154                mutation_name,
155                sql_source,
156            } => {
157                write!(
158                    f,
159                    "mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
160                )
161            },
162            Self::MissingAdditionalView {
163                query_name,
164                view_name,
165            } => {
166                write!(
167                    f,
168                    "query `{query_name}`: additional_view `{view_name}` does not exist in database"
169                )
170            },
171            Self::MissingJsonColumn {
172                query_name,
173                sql_source,
174                column_name,
175            } => {
176                write!(
177                    f,
178                    "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
179                )
180            },
181            Self::WrongJsonColumnType {
182                query_name,
183                sql_source,
184                column_name,
185                actual_type,
186            } => {
187                write!(
188                    f,
189                    "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
190                )
191            },
192            Self::MissingCursorColumn {
193                query_name,
194                sql_source,
195                column_name,
196            } => {
197                write!(
198                    f,
199                    "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
200                )
201            },
202            Self::MissingJsonKey {
203                query_name,
204                type_name,
205                sql_source,
206                json_column,
207                field_name,
208                json_key,
209            } => {
210                write!(
211                    f,
212                    "query `{query_name}`: field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
213                )
214            },
215            Self::NativeColumnFallback {
216                query_name,
217                sql_source,
218                arg_name,
219            } => {
220                write!(
221                    f,
222                    "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
223                     (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
224                     `{sql_source}`. Add a native column with an index for O(log n) lookup."
225                )
226            },
227            Self::TypeConvertibility {
228                query_name,
229                sql_source,
230                arg_name,
231                graphql_type,
232                column_type,
233            } => {
234                write!(
235                    f,
236                    "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
237                     `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
238                     Align the argument type with the column, or store the field in `data` for JSONB extraction."
239                )
240            },
241        }
242    }
243}
244
245/// Check if a SQL data type represents a JSON column for the given database.
246pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
247    let lower = data_type.to_lowercase();
248    match db_type {
249        DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
250        DatabaseType::MySQL => lower == "json",
251        DatabaseType::SQLite => lower.contains("json"),
252        // SQL Server has no native JSON type — always attempt JSON
253        // validation for the configured jsonb_column
254        DatabaseType::SQLServer => true,
255    }
256}
257
258/// Coarse SQL type families, used to flag argument↔column type mismatches.
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260enum SqlFamily {
261    /// Integer/decimal/float numeric types.
262    Numeric,
263    /// Boolean.
264    Boolean,
265    /// UUID / `uniqueidentifier`.
266    Uuid,
267    /// Character/string types.
268    Text,
269    /// Date/time/interval types.
270    Temporal,
271    /// `json` / `jsonb`.
272    Json,
273    /// Anything else (custom domains, enums, arrays, geometry, …) — never flagged.
274    Other,
275}
276
277/// Classify a (any-dialect) SQL type string into a coarse [`SqlFamily`].
278///
279/// Strips length/precision and multi-word suffixes (`character varying`,
280/// `timestamp with time zone`, `double precision`) down to the leading base word.
281fn sql_type_family(sql_type: &str) -> SqlFamily {
282    let lower = sql_type.trim().to_lowercase();
283    let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
284    match base {
285        "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
286        | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
287        | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
288        "boolean" | "bool" | "bit" => SqlFamily::Boolean,
289        "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
290        "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
291        | "character" | "clob" | "string" => SqlFamily::Text,
292        "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
293        | "interval" | "smalldatetime" => SqlFamily::Temporal,
294        "json" | "jsonb" => SqlFamily::Json,
295        _ => SqlFamily::Other,
296    }
297}
298
299/// Whether a query argument's GraphQL type can cleanly drive an equality predicate
300/// against a native column of `sql_type`.
301///
302/// Conservative by design: only the strongly-typed scalars (`Int`, `Float`,
303/// `Decimal`, `Boolean`, `UUID`) can flag a mismatch, and only against a *known*
304/// incompatible column family. Permissive scalars (`String`, `ID`, date/time, `JSON`,
305/// custom scalars) and non-scalar references never warn — the runtime binds them as
306/// text-coercible parameters, and `ID` intentionally spans uuid / integer / text keys.
307fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
308    // A list filter (`[ID!]`) drives the predicate with its element type.
309    let base = match field_type {
310        FieldType::List(inner) => inner.as_ref(),
311        other => other,
312    };
313    let family = sql_type_family(sql_type);
314    // Unknown column family → don't second-guess it.
315    if family == SqlFamily::Other {
316        return true;
317    }
318    match base {
319        FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
320        FieldType::Boolean => family == SqlFamily::Boolean,
321        FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
322        _ => true,
323    }
324}
325
326/// Split a potentially schema-qualified name into (optional_schema, name).
327fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
328    match sql_source.split_once('.') {
329        Some((schema, table)) => (Some(schema), table),
330        None => (None, sql_source),
331    }
332}
333
334/// Check if a relation exists in the relation lookup maps.
335fn relation_exists(
336    schema_qualified: &HashMap<(String, String), RelationInfo>,
337    unqualified: &HashMap<String, Vec<String>>,
338    sql_source: &str,
339) -> bool {
340    let (schema, name) = split_schema_qualified(sql_source);
341    if let Some(s) = schema {
342        schema_qualified.contains_key(&(s.to_string(), name.to_string()))
343    } else {
344        unqualified.contains_key(name)
345    }
346}
347
348/// L1 relation existence with runtime-faithful resolution.
349///
350/// For a **schema-qualified** source, probes via `to_regclass` on the
351/// case-sensitively-quoted identifier (search_path-independent — exactly how the
352/// runtime resolves it), so a mixed-case relation in an off-`search_path` schema
353/// is not falsely flagged. Falls back to the relation map for **bare** names
354/// (search_path-scoped, also matching the runtime) or when the connector cannot
355/// probe directly (non-Postgres).
356async fn relation_source_exists(
357    introspector: &impl DatabaseIntrospector,
358    schema_qualified: &HashMap<(String, String), RelationInfo>,
359    unqualified: &HashMap<String, Vec<String>>,
360    sql_source: &str,
361) -> fraiseql_core::Result<bool> {
362    let (schema, name) = split_schema_qualified(sql_source);
363    if let Some(s) = schema {
364        if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
365            return Ok(exists);
366        }
367    }
368    // Bare name, or a connector that can't probe → relation-map lookup.
369    Ok(relation_exists(schema_qualified, unqualified, sql_source))
370}
371
372/// L1 function existence for a mutation's `sql_source`.
373///
374/// A mutation's `sql_source` names a SQL *function*, so it is probed via `pg_proc`
375/// (not the relation catalog). When the connector cannot probe functions
376/// (non-Postgres), returns `true` — the check is skipped rather than false-failing.
377async fn function_source_exists(
378    introspector: &impl DatabaseIntrospector,
379    sql_source: &str,
380) -> fraiseql_core::Result<bool> {
381    let (schema, name) = split_schema_qualified(sql_source);
382    Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
383}
384
385/// Validate a compiled schema against a live database.
386///
387/// Performs three levels of validation:
388/// - **L1**: Checks that `sql_source` relations exist
389/// - **L2**: Checks column existence and JSON column types
390/// - **L3**: Checks JSONB key existence via sampling
391///
392/// All diagnostics are warnings — the report never causes compilation to fail.
393///
394/// # Errors
395///
396/// Returns `FraiseQLError` if database introspection queries fail.
397pub async fn validate_schema_against_database(
398    schema: &CompiledSchema,
399    introspector: &impl DatabaseIntrospector,
400) -> fraiseql_core::Result<DatabaseValidationReport> {
401    // Auto-wired argument names excluded from direct-arg native column detection.
402    // Must stay in sync with AUTO_PARAM_NAMES in fraiseql-core/runtime/executor/query.rs.
403    const AUTO_PARAM_NAMES: &[&str] = &[
404        "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
405    ];
406
407    let mut warnings = Vec::new();
408    let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
409    let db_type = introspector.database_type();
410
411    // L1: Build relation lookup maps
412    let relations = introspector.list_relations().await?;
413    let (schema_qualified, unqualified) = build_relation_maps(&relations);
414
415    // Validate queries
416    for query in &schema.queries {
417        if let Some(ref source) = query.sql_source {
418            // L1: Check relation exists (qualified → to_regclass verbatim, bare → map).
419            if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
420                .await?
421            {
422                warnings.push(DatabaseWarning::MissingRelation {
423                    query_name: query.name.clone(),
424                    sql_source: source.clone(),
425                });
426                continue; // Skip L2/L3 if relation doesn't exist
427            }
428
429            // L2: Get columns for the relation.
430            // Pass the full source (possibly schema-qualified like "benchmark.tv_post") so
431            // the introspector can use the explicit schema when present.
432            let columns = introspector.get_columns(source).await?;
433            let column_map: HashMap<String, String> =
434                columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
435
436            // L2: Check jsonb_column
437            let jsonb_col = &query.jsonb_column;
438            if !jsonb_col.is_empty() {
439                if let Some(actual_type) = column_map.get(jsonb_col) {
440                    if !is_json_type(actual_type, db_type) {
441                        warnings.push(DatabaseWarning::WrongJsonColumnType {
442                            query_name:  query.name.clone(),
443                            sql_source:  source.clone(),
444                            column_name: jsonb_col.clone(),
445                            actual_type: actual_type.clone(),
446                        });
447                    }
448                } else {
449                    warnings.push(DatabaseWarning::MissingJsonColumn {
450                        query_name:  query.name.clone(),
451                        sql_source:  source.clone(),
452                        column_name: jsonb_col.clone(),
453                    });
454                }
455            }
456
457            // L2: Check relay_cursor_column
458            if query.relay {
459                if let Some(ref cursor_col) = query.relay_cursor_column {
460                    if !column_map.contains_key(cursor_col) {
461                        warnings.push(DatabaseWarning::MissingCursorColumn {
462                            query_name:  query.name.clone(),
463                            sql_source:  source.clone(),
464                            column_name: cursor_col.clone(),
465                        });
466                    }
467                }
468            }
469
470            // L3: Sample JSON keys if jsonb_column is valid JSON type
471            if !jsonb_col.is_empty() {
472                let json_type_ok =
473                    column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
474
475                if json_type_ok {
476                    validate_json_keys(
477                        schema,
478                        query,
479                        source,
480                        jsonb_col,
481                        introspector,
482                        source, // pass full schema-qualified source for sample queries
483                        &mut warnings,
484                    )
485                    .await?;
486                }
487            }
488
489            // L2: Detect native columns for direct (non-auto-param) arguments AND
490            // inject params. Both are filtered against the view at runtime, so a name
491            // that matches a real column must use the native-column path
492            // (`WHERE col = $N`) rather than the JSONB fallback (`data->>'name'`).
493            let direct_args: Vec<&str> = query
494                .arguments
495                .iter()
496                .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
497                .map(|a| a.name.as_str())
498                .collect();
499
500            let (query_native, arg_fallbacks) = detect_query_native_columns(
501                &direct_args,
502                query.inject_params.keys().map(String::as_str),
503                &column_map,
504            );
505            for arg_name in arg_fallbacks {
506                warnings.push(DatabaseWarning::NativeColumnFallback {
507                    query_name: query.name.clone(),
508                    sql_source: source.clone(),
509                    arg_name,
510                });
511            }
512
513            // L2 (type-convertibility): a direct argument that resolved to a native
514            // column whose SQL type cannot cleanly drive the predicate is a likely
515            // authoring bug (e.g. an `Int` argument filtering a `uuid` column).
516            for arg in &query.arguments {
517                if let Some(col_type) = query_native.get(&arg.name) {
518                    if !arg_type_convertible(&arg.arg_type, col_type) {
519                        warnings.push(DatabaseWarning::TypeConvertibility {
520                            query_name:   query.name.clone(),
521                            sql_source:   source.clone(),
522                            arg_name:     arg.name.clone(),
523                            graphql_type: arg.arg_type.to_graphql_string(),
524                            column_type:  col_type.clone(),
525                        });
526                    }
527                }
528            }
529
530            if !query_native.is_empty() {
531                native_columns.insert(query.name.clone(), query_native);
532            }
533
534            // L1: Check additional_views (relations, same resolution as sql_source).
535            for view in &query.additional_views {
536                if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
537                    .await?
538                {
539                    warnings.push(DatabaseWarning::MissingAdditionalView {
540                        query_name: query.name.clone(),
541                        view_name:  view.clone(),
542                    });
543                }
544            }
545        }
546    }
547
548    // Validate mutations (L1 only). A mutation's `sql_source` is a FUNCTION, not a
549    // relation — probe `pg_proc`, not the relation catalog (#485). Mirrors the
550    // shared `fraiseql_core::schema::sql_source_probes` Relation/Function split.
551    for mutation in &schema.mutations {
552        if let Some(ref source) = mutation.sql_source {
553            if !function_source_exists(introspector, source).await? {
554                warnings.push(DatabaseWarning::MissingFunction {
555                    mutation_name: mutation.name.clone(),
556                    sql_source:    source.clone(),
557                });
558            }
559        }
560    }
561
562    Ok(DatabaseValidationReport {
563        warnings,
564        native_columns,
565    })
566}
567
568/// Build lookup maps from the list of relations.
569fn build_relation_maps(
570    relations: &[RelationInfo],
571) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
572    let mut schema_qualified = HashMap::new();
573    let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
574
575    for rel in relations {
576        schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
577        unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
578    }
579
580    (schema_qualified, unqualified)
581}
582
583/// Find every declared `sql_source` that is **not backed** by a live database
584/// object, over the shared [`sql_source_probes`] work-list (#487).
585///
586/// Resolution mirrors the runtime exactly: a query relation by `to_regclass`
587/// (qualified, verbatim) or the search_path-scoped relation map (bare); a mutation
588/// function via `pg_proc`. Returns the unbacked probes in declaration order — an
589/// empty vec means every source is backed. This is the existence gate the
590/// `validate --against-db` CLI and the opt-in server boot check share, so the two
591/// agree on "backed" by construction.
592///
593/// When the connector cannot probe a kind (non-Postgres), that source is treated
594/// as backed (skipped) rather than false-failed.
595///
596/// # Errors
597///
598/// Returns an error if database introspection fails.
599pub async fn find_unbacked_sources(
600    schema: &CompiledSchema,
601    introspector: &impl DatabaseIntrospector,
602) -> fraiseql_core::Result<Vec<SourceProbe>> {
603    let relations = introspector.list_relations().await?;
604    let (schema_qualified, unqualified) = build_relation_maps(&relations);
605
606    let mut unbacked = Vec::new();
607    for probe in sql_source_probes(schema) {
608        let exists = match probe.kind {
609            SourceKind::Function => introspector
610                .function_exists(probe.schema.as_deref(), &probe.name)
611                .await?
612                .unwrap_or(true),
613            SourceKind::Relation => {
614                if let Some(s) = &probe.schema {
615                    match introspector.qualified_relation_exists(s, &probe.name).await? {
616                        Some(exists) => exists,
617                        None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
618                    }
619                } else {
620                    unqualified.contains_key(&probe.name)
621                }
622            },
623        };
624        if !exists {
625            unbacked.push(probe);
626        }
627    }
628    Ok(unbacked)
629}
630
631/// Validate JSON keys in sampled data for L3 checking.
632async fn validate_json_keys(
633    schema: &CompiledSchema,
634    query: &fraiseql_core::schema::QueryDefinition,
635    source: &str,
636    jsonb_col: &str,
637    introspector: &impl DatabaseIntrospector,
638    table_name: &str,
639    warnings: &mut Vec<DatabaseWarning>,
640) -> fraiseql_core::Result<()> {
641    let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
642
643    if samples.is_empty() {
644        return Ok(());
645    }
646
647    // Merge all top-level keys from sampled rows
648    let mut all_keys = HashSet::new();
649    for sample in &samples {
650        if let serde_json::Value::Object(map) = sample {
651            for key in map.keys() {
652                all_keys.insert(key.clone());
653            }
654        }
655    }
656
657    if all_keys.is_empty() {
658        return Ok(());
659    }
660
661    // Find the type definition for this query's return type
662    let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
663
664    if let Some(type_def) = type_def {
665        for field in &type_def.fields {
666            let field_str = field.name.as_str();
667            // Use the canonical acronym/digit-aware caser the runtime queries with
668            // (`httpResponse` → `http_response`, not `h_t_t_p_response`), so the
669            // L3 JSON-key check never cries wolf on acronym/digit fields (#485).
670            let json_key = fraiseql_core::utils::to_snake_case(field_str);
671            // Skip fields that are top-level columns (not from JSONB)
672            // Convention: fields like "id", "pk_*", "fk_*" are columns, not JSON keys
673            if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
674                continue;
675            }
676            if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
677                warnings.push(DatabaseWarning::MissingJsonKey {
678                    query_name: query.name.clone(),
679                    type_name: type_def.name.as_str().to_string(),
680                    sql_source: source.to_string(),
681                    json_column: jsonb_col.to_string(),
682                    field_name: field_str.to_string(),
683                    json_key,
684                });
685            }
686        }
687    }
688
689    Ok(())
690}
691
692/// Enum dispatch for database introspectors.
693///
694/// Uses enum dispatch instead of `Box<dyn DatabaseIntrospector>` because the
695/// trait uses `async_fn_in_trait` and cannot be object-safe.
696pub enum AnyIntrospector {
697    /// PostgreSQL introspector.
698    Postgres(fraiseql_core::db::PostgresIntrospector),
699    #[cfg(feature = "mysql")]
700    /// MySQL introspector.
701    MySql(fraiseql_core::db::MySqlIntrospector),
702    #[cfg(feature = "sqlite")]
703    /// SQLite introspector.
704    Sqlite(fraiseql_core::db::SqliteIntrospector),
705    #[cfg(feature = "sqlserver")]
706    /// SQL Server introspector.
707    SqlServer(fraiseql_core::db::SqlServerIntrospector),
708}
709
710impl DatabaseIntrospector for AnyIntrospector {
711    async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
712        match self {
713            Self::Postgres(i) => i.list_fact_tables().await,
714            #[cfg(feature = "mysql")]
715            Self::MySql(i) => i.list_fact_tables().await,
716            #[cfg(feature = "sqlite")]
717            Self::Sqlite(i) => i.list_fact_tables().await,
718            #[cfg(feature = "sqlserver")]
719            Self::SqlServer(i) => i.list_fact_tables().await,
720        }
721    }
722
723    async fn get_columns(
724        &self,
725        table_name: &str,
726    ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
727        match self {
728            Self::Postgres(i) => i.get_columns(table_name).await,
729            #[cfg(feature = "mysql")]
730            Self::MySql(i) => i.get_columns(table_name).await,
731            #[cfg(feature = "sqlite")]
732            Self::Sqlite(i) => i.get_columns(table_name).await,
733            #[cfg(feature = "sqlserver")]
734            Self::SqlServer(i) => i.get_columns(table_name).await,
735        }
736    }
737
738    async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
739        match self {
740            Self::Postgres(i) => i.get_indexed_columns(table_name).await,
741            #[cfg(feature = "mysql")]
742            Self::MySql(i) => i.get_indexed_columns(table_name).await,
743            #[cfg(feature = "sqlite")]
744            Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
745            #[cfg(feature = "sqlserver")]
746            Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
747        }
748    }
749
750    fn database_type(&self) -> DatabaseType {
751        match self {
752            Self::Postgres(i) => i.database_type(),
753            #[cfg(feature = "mysql")]
754            Self::MySql(i) => i.database_type(),
755            #[cfg(feature = "sqlite")]
756            Self::Sqlite(i) => i.database_type(),
757            #[cfg(feature = "sqlserver")]
758            Self::SqlServer(i) => i.database_type(),
759        }
760    }
761
762    async fn get_sample_jsonb(
763        &self,
764        table_name: &str,
765        column_name: &str,
766    ) -> fraiseql_core::Result<Option<serde_json::Value>> {
767        match self {
768            Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
769            #[cfg(feature = "mysql")]
770            Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
771            #[cfg(feature = "sqlite")]
772            Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
773            #[cfg(feature = "sqlserver")]
774            Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
775        }
776    }
777
778    async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
779        match self {
780            Self::Postgres(i) => i.list_relations().await,
781            #[cfg(feature = "mysql")]
782            Self::MySql(i) => i.list_relations().await,
783            #[cfg(feature = "sqlite")]
784            Self::Sqlite(i) => i.list_relations().await,
785            #[cfg(feature = "sqlserver")]
786            Self::SqlServer(i) => i.list_relations().await,
787        }
788    }
789
790    async fn get_sample_json_rows(
791        &self,
792        table_name: &str,
793        column_name: &str,
794        limit: usize,
795    ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
796        match self {
797            Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
798            #[cfg(feature = "mysql")]
799            Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
800            #[cfg(feature = "sqlite")]
801            Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
802            #[cfg(feature = "sqlserver")]
803            Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
804        }
805    }
806
807    async fn function_exists(
808        &self,
809        schema: Option<&str>,
810        name: &str,
811    ) -> fraiseql_core::Result<Option<bool>> {
812        // Must delegate (not inherit the trait default) so the Postgres impl is
813        // actually used — otherwise every variant would return `None`.
814        match self {
815            Self::Postgres(i) => i.function_exists(schema, name).await,
816            #[cfg(feature = "mysql")]
817            Self::MySql(i) => i.function_exists(schema, name).await,
818            #[cfg(feature = "sqlite")]
819            Self::Sqlite(i) => i.function_exists(schema, name).await,
820            #[cfg(feature = "sqlserver")]
821            Self::SqlServer(i) => i.function_exists(schema, name).await,
822        }
823    }
824
825    async fn qualified_relation_exists(
826        &self,
827        schema: &str,
828        name: &str,
829    ) -> fraiseql_core::Result<Option<bool>> {
830        match self {
831            Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
832            #[cfg(feature = "mysql")]
833            Self::MySql(i) => i.qualified_relation_exists(schema, name).await,
834            #[cfg(feature = "sqlite")]
835            Self::Sqlite(i) => i.qualified_relation_exists(schema, name).await,
836            #[cfg(feature = "sqlserver")]
837            Self::SqlServer(i) => i.qualified_relation_exists(schema, name).await,
838        }
839    }
840}
841
842/// Create an introspector from a database URL.
843///
844/// Detects the database type from the URL scheme and creates the appropriate
845/// introspector with a connection pool.
846///
847/// # Errors
848///
849/// Returns error if the URL scheme is unrecognized or the connection pool
850/// cannot be created.
851#[allow(clippy::unused_async)] // Reason: callers always .await this; feature-gated branches do use await
852pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
853    if db_url.starts_with("postgres") {
854        use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
855        use tokio_postgres::NoTls;
856
857        let mut cfg = Config::new();
858        cfg.url = Some(db_url.to_string());
859        cfg.manager = Some(ManagerConfig {
860            recycling_method: RecyclingMethod::Fast,
861        });
862        cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
863
864        let pool = cfg
865            .create_pool(Some(Runtime::Tokio1), NoTls)
866            .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
867
868        Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
869    } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
870        #[cfg(feature = "mysql")]
871        {
872            use sqlx::mysql::MySqlPool;
873
874            let pool = MySqlPool::connect(db_url)
875                .await
876                .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
877
878            Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
879        }
880        #[cfg(not(feature = "mysql"))]
881        {
882            anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
883        }
884    } else if db_url.starts_with("sqlite")
885        || std::path::Path::new(db_url)
886            .extension()
887            .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
888    {
889        #[cfg(feature = "sqlite")]
890        {
891            use sqlx::sqlite::SqlitePool;
892
893            let pool = SqlitePool::connect(db_url)
894                .await
895                .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
896
897            Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
898        }
899        #[cfg(not(feature = "sqlite"))]
900        {
901            anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
902        }
903    } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
904        #[cfg(feature = "sqlserver")]
905        {
906            use bb8::Pool;
907            use bb8_tiberius::ConnectionManager;
908            use tiberius::Config;
909
910            let config = Config::from_ado_string(db_url).map_err(|e| {
911                anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
912            })?;
913            let mgr = ConnectionManager::build(config).map_err(|e| {
914                anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
915            })?;
916            let pool = Pool::builder()
917                .max_size(2)
918                .build(mgr)
919                .await
920                .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
921
922            Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
923        }
924        #[cfg(not(feature = "sqlserver"))]
925        {
926            anyhow::bail!(
927                "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
928            )
929        }
930    } else {
931        anyhow::bail!("Unrecognized database URL scheme: {db_url}")
932    }
933}
934
935/// Build a query's native-column map from its explicit (non-auto-param) arguments
936/// and its inject-param names, consulting the introspected `column_map`.
937///
938/// A name that matches a real view column is rendered as a native-column predicate
939/// (`WHERE col = $N`) at runtime; a name with no matching column falls back to the
940/// JSONB path (`data->>'name'`). inject params were previously omitted here, so an
941/// inject-scoped list query against a view that keeps e.g. `tenant_id` as a real
942/// column (and not inside `data`) rendered `data->>'tenant_id'` and matched no rows.
943///
944/// Returns the native-column map plus the explicit-arg names that did NOT resolve to
945/// a column — the caller emits a `NativeColumnFallback` warning for each. Inject-param
946/// misses are intentionally silent: a claim may legitimately live in the `data` JSONB.
947fn detect_query_native_columns<'a>(
948    direct_arg_names: &[&str],
949    inject_param_names: impl Iterator<Item = &'a str>,
950    column_map: &HashMap<String, String>,
951) -> (HashMap<String, String>, Vec<String>) {
952    let mut native: HashMap<String, String> = HashMap::new();
953    let mut arg_fallbacks: Vec<String> = Vec::new();
954
955    for arg in direct_arg_names {
956        if let Some(col_type) = column_map.get(*arg) {
957            native.insert((*arg).to_string(), col_type.clone());
958        } else {
959            arg_fallbacks.push((*arg).to_string());
960        }
961    }
962
963    for name in inject_param_names {
964        if let Some(col_type) = column_map.get(name) {
965            native.insert(name.to_string(), col_type.clone());
966        }
967    }
968
969    (native, arg_fallbacks)
970}
971
972#[cfg(test)]
973#[path = "database_validator_tests.rs"]
974mod database_validator_tests;