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