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//! Each finding carries a [`Severity`]: error-severity drift (a relation that
9//! does not exist, a required field's key absent from every sampled row) fails
10//! `compile --database` unless `--allow-drift` is passed (#384); warn-severity
11//! findings are always advisory.
12
13use std::{
14    collections::{HashMap, HashSet},
15    fmt,
16};
17
18use fraiseql_core::{
19    db::{
20        DatabaseType,
21        introspector::{DatabaseIntrospector, RelationInfo},
22    },
23    schema::{
24        CompiledSchema, FieldType, PaginationOrder, SourceKind, SourceProbe, sql_source_probes,
25    },
26};
27
28use super::mutation_contract::Severity;
29
30/// Report containing all database validation warnings and discovered metadata.
31pub struct DatabaseValidationReport {
32    /// All warnings emitted during validation.
33    pub warnings:          Vec<DatabaseWarning>,
34    /// Native columns discovered per query during L2 validation.
35    ///
36    /// Key: query name. Value: map of argument name → `PostgreSQL` type string
37    /// (e.g. `"uuid"`, `"integer"`, `"text"`).
38    ///
39    /// Only contains entries for queries that have at least one direct argument
40    /// with a matching native column on their `sql_source`.
41    pub native_columns:    HashMap<String, HashMap<String, String>>,
42    /// Cheaper page orderings discovered per query during L2 validation (#1303).
43    ///
44    /// Key: query name. Value: the ordering to replace the compiler's offline
45    /// answer with.
46    ///
47    /// Only contains entries for queries whose `pagination_order` the compiler
48    /// *derived* — an authored one is never overridden, and a query that declared
49    /// `none` is never given one back. The offline derivation is already correct;
50    /// this makes it cheap, which is why introspection may sharpen the answer and
51    /// may never be required to produce one.
52    pub pagination_orders: HashMap<String, PaginationOrder>,
53}
54
55/// A single database validation warning.
56#[derive(Debug)]
57pub enum DatabaseWarning {
58    /// L1: `sql_source` relation does not exist.
59    MissingRelation {
60        /// Name of the query or mutation.
61        query_name: String,
62        /// The `sql_source` value that was not found.
63        sql_source: String,
64    },
65    /// L1: a mutation's `sql_source` **function** does not exist.
66    ///
67    /// Distinct from [`MissingRelation`](Self::MissingRelation): a mutation's
68    /// `sql_source` names a SQL *function* (the runtime calls
69    /// `SELECT * FROM fn(...)`), not a view/table, so it is probed via `pg_proc`
70    /// rather than the relation catalog.
71    MissingFunction {
72        /// Name of the mutation.
73        mutation_name: String,
74        /// The `sql_source` function that was not found.
75        sql_source:    String,
76    },
77    /// L1: a `@fraiseql.type`'s declared `sql_source` view does not exist (#653).
78    ///
79    /// Most often an embedded value object the SDK gave a synthesized `v_<name>` source by
80    /// naming convention (a view that was never authored). Advisory only — it never fails
81    /// the compile; the hard `FRAISEQL_VALIDATE_SQL_SOURCES` gate deliberately does **not**
82    /// probe type sources (it would drown in synthesized-source noise), so this warn-grade
83    /// path under `--database` is the only place phantom type sources surface.
84    MissingTypeSource {
85        /// Name of the type.
86        type_name:  String,
87        /// The `sql_source` view that was not found.
88        sql_source: String,
89    },
90    /// L1: `additional_view` does not exist.
91    MissingAdditionalView {
92        /// Name of the query.
93        query_name: String,
94        /// The view name that was not found.
95        view_name:  String,
96    },
97    /// L2: `jsonb_column` does not exist on the relation.
98    MissingJsonColumn {
99        /// Name of the query.
100        query_name:  String,
101        /// The `sql_source` relation.
102        sql_source:  String,
103        /// The missing column name.
104        column_name: String,
105    },
106    /// L2: `jsonb_column` exists but is not a JSON/JSONB type.
107    WrongJsonColumnType {
108        /// Name of the query.
109        query_name:  String,
110        /// The `sql_source` relation.
111        sql_source:  String,
112        /// The column name.
113        column_name: String,
114        /// The actual SQL data type.
115        actual_type: String,
116    },
117    /// L2: an authored `pagination_order` column does not exist on the relation (#1303).
118    ///
119    /// The same shape as [`MissingCursorColumn`](Self::MissingCursorColumn) and
120    /// error-grade for the same reason: the column is rendered into `ORDER BY`, so
121    /// a name the relation does not carry is a query that fails at request time,
122    /// on a route that compiled cleanly.
123    MissingPaginationColumn {
124        /// Name of the query.
125        query_name:  String,
126        /// The `sql_source` relation.
127        sql_source:  String,
128        /// The missing column name.
129        column_name: String,
130    },
131    /// L2: no cheaper unique column was found, so pages stay ordered by
132    /// `data->>'id'` (#1303).
133    ///
134    /// Advisory: the ordering is *correct*, and correct is the point — `id` is an
135    /// enforced invariant (ADR-0017), so the extraction orders every type. It is
136    /// simply the most expensive of the three known spellings, and the relation is
137    /// the only place a cheaper one could have come from.
138    PaginationIdentityFallback {
139        /// Name of the query.
140        query_name: String,
141        /// The `sql_source` relation.
142        sql_source: String,
143        /// The `pk_<type>` column that would have been used had it existed.
144        pk_column:  String,
145    },
146    /// L2: the view carries its own `ORDER BY` and the query did not declare
147    /// `pagination_order = "none"` (#1303).
148    ///
149    /// The one case where ordering pages by the entity identity is a *regression*:
150    /// a self-ordering view is the compiler's own documented remedy for
151    /// `order_by = false`, and a default ordering replaces the author's order
152    /// rather than adding to it. Advisory rather than error-grade because
153    /// `pg_get_viewdef` cannot tell a top-level `ORDER BY` from one inside a
154    /// subquery or window frame, where imposing a page order is entirely correct —
155    /// so this reports a suspicion, and the author resolves it by declaring either
156    /// way.
157    SelfOrderingView {
158        /// Name of the query.
159        query_name: String,
160        /// The `sql_source` view.
161        sql_source: String,
162    },
163    /// L2: `relay_cursor_column` does not exist on the relation.
164    MissingCursorColumn {
165        /// Name of the query.
166        query_name:  String,
167        /// The `sql_source` relation.
168        sql_source:  String,
169        /// The missing cursor column name.
170        column_name: String,
171    },
172    /// L3: a JSON key path is declared but not found in sampled data.
173    MissingJsonKey {
174        /// Name of the query.
175        query_name:     String,
176        /// The GraphQL type that declares the field (where to fix it).
177        type_name:      String,
178        /// The `sql_source` relation.
179        sql_source:     String,
180        /// The JSON column being sampled.
181        json_column:    String,
182        /// The GraphQL field name.
183        field_name:     String,
184        /// The snake_case key looked up in the JSON.
185        json_key:       String,
186        /// Whether the field is declared non-nullable. A required field's key
187        /// cannot legitimately be absent from every sampled row, so this drives
188        /// the finding's [`Severity`]: required → error, nullable → warning.
189        field_required: bool,
190    },
191    /// L2: a direct query argument has no matching native column — will fall back to JSONB
192    /// extraction.
193    ///
194    /// For best performance, consider adding a native column with the same name
195    /// and an index on the `sql_source` table/view.
196    NativeColumnFallback {
197        /// Name of the query.
198        query_name: String,
199        /// The `sql_source` relation.
200        sql_source: String,
201        /// The argument name that has no matching native column.
202        arg_name:   String,
203    },
204    /// L2: a direct query argument resolves to a native column whose SQL type cannot
205    /// cleanly drive the predicate for the argument's GraphQL scalar type (e.g. an
206    /// `Int` argument filtering a `uuid` column — `WHERE col = $N` errors or never
207    /// matches).
208    TypeConvertibility {
209        /// Name of the query.
210        query_name:   String,
211        /// The `sql_source` relation.
212        sql_source:   String,
213        /// The argument name.
214        arg_name:     String,
215        /// The argument's declared GraphQL type (e.g. `Int`).
216        graphql_type: String,
217        /// The native column's SQL type (e.g. `uuid`).
218        column_type:  String,
219    },
220}
221
222impl DatabaseWarning {
223    /// The severity of this finding under `compile --database` (#384).
224    ///
225    /// `Error`-severity drift fails the compile (unless `--allow-drift`):
226    /// the declared schema names database objects that do not exist or cannot
227    /// serve the declared shape, so the first query/mutation against them fails
228    /// at runtime. `Warn`-severity findings are advisory:
229    ///
230    /// - [`MissingTypeSource`](Self::MissingTypeSource) is warn-grade by design (#653): synthesized
231    ///   value-object sources would drown a hard gate.
232    /// - [`MissingJsonKey`](Self::MissingJsonKey) is sampling-based; a *nullable* field may
233    ///   legitimately be unset in every sampled row, so only a **required** field's absent key is
234    ///   an error.
235    /// - [`NativeColumnFallback`](Self::NativeColumnFallback) is a performance advisory — the JSONB
236    ///   path is correct, just slower.
237    #[must_use]
238    pub const fn severity(&self) -> Severity {
239        match self {
240            Self::MissingRelation { .. }
241            | Self::MissingFunction { .. }
242            | Self::MissingAdditionalView { .. }
243            | Self::MissingJsonColumn { .. }
244            | Self::WrongJsonColumnType { .. }
245            | Self::MissingCursorColumn { .. }
246            | Self::MissingPaginationColumn { .. }
247            | Self::TypeConvertibility { .. } => Severity::Error,
248            Self::MissingJsonKey { field_required, .. } => {
249                if *field_required {
250                    Severity::Error
251                } else {
252                    Severity::Warn
253                }
254            },
255            Self::MissingTypeSource { .. }
256            | Self::NativeColumnFallback { .. }
257            | Self::PaginationIdentityFallback { .. }
258            | Self::SelfOrderingView { .. } => Severity::Warn,
259        }
260    }
261}
262
263impl fmt::Display for DatabaseWarning {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            Self::MissingRelation {
267                query_name,
268                sql_source,
269            } => {
270                write!(
271                    f,
272                    "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
273                )
274            },
275            Self::MissingFunction {
276                mutation_name,
277                sql_source,
278            } => {
279                write!(
280                    f,
281                    "mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
282                )
283            },
284            Self::MissingTypeSource {
285                type_name,
286                sql_source,
287            } => {
288                write!(
289                    f,
290                    "type `{type_name}`: sql_source `{sql_source}` does not exist in the \
291                     database — if `{type_name}` is an embedded value object it should declare \
292                     no source (a value object has no independent identity; it is fetched via \
293                     its parent)"
294                )
295            },
296            Self::MissingAdditionalView {
297                query_name,
298                view_name,
299            } => {
300                write!(
301                    f,
302                    "query `{query_name}`: additional_view `{view_name}` does not exist in database"
303                )
304            },
305            Self::MissingJsonColumn {
306                query_name,
307                sql_source,
308                column_name,
309            } => {
310                write!(
311                    f,
312                    "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
313                )
314            },
315            Self::WrongJsonColumnType {
316                query_name,
317                sql_source,
318                column_name,
319                actual_type,
320            } => {
321                write!(
322                    f,
323                    "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
324                )
325            },
326            Self::MissingCursorColumn {
327                query_name,
328                sql_source,
329                column_name,
330            } => {
331                write!(
332                    f,
333                    "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
334                )
335            },
336            Self::MissingPaginationColumn {
337                query_name,
338                sql_source,
339                column_name,
340            } => {
341                write!(
342                    f,
343                    "query `{query_name}`: pagination_order column `{column_name}` not found on \
344                     `{sql_source}` — it is rendered into ORDER BY, so this query fails at \
345                     request time"
346                )
347            },
348            Self::PaginationIdentityFallback {
349                query_name,
350                sql_source,
351                pk_column,
352            } => {
353                write!(
354                    f,
355                    "query `{query_name}`: offset pages are ordered by `data->>'id'` because \
356                     `{sql_source}` exposes neither `{pk_column}` nor a native `id` column. \
357                     Correct, but the most expensive of the three orderings (measured 7.7x the \
358                     unordered plan at 200k rows and a deep offset, against 1.7x for \
359                     `{pk_column}`). Expose one of them, or declare pagination_order"
360                )
361            },
362            Self::SelfOrderingView {
363                query_name,
364                sql_source,
365            } => {
366                write!(
367                    f,
368                    "query `{query_name}`: view `{sql_source}` carries its own ORDER BY, and \
369                     this query does not declare pagination_order. An offset page will now be \
370                     ordered by the entity identity instead, replacing the view's order. \
371                     Declare pagination_order = \"none\" to keep it, or a column to confirm \
372                     the new one"
373                )
374            },
375            Self::MissingJsonKey {
376                query_name,
377                type_name,
378                sql_source,
379                json_column,
380                field_name,
381                json_key,
382                field_required,
383            } => {
384                let requiredness = if *field_required {
385                    "required"
386                } else {
387                    "nullable"
388                };
389                write!(
390                    f,
391                    "query `{query_name}`: {requiredness} field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
392                )
393            },
394            Self::NativeColumnFallback {
395                query_name,
396                sql_source,
397                arg_name,
398            } => {
399                write!(
400                    f,
401                    "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
402                     (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
403                     `{sql_source}`. Add a native column with an index for O(log n) lookup."
404                )
405            },
406            Self::TypeConvertibility {
407                query_name,
408                sql_source,
409                arg_name,
410                graphql_type,
411                column_type,
412            } => {
413                write!(
414                    f,
415                    "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
416                     `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
417                     Align the argument type with the column, or store the field in `data` for JSONB extraction."
418                )
419            },
420        }
421    }
422}
423
424/// Check if a SQL data type represents a JSON column for the given database.
425pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
426    let lower = data_type.to_lowercase();
427    match db_type {
428        DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
429    }
430}
431
432/// Coarse SQL type families, used to flag argument↔column type mismatches.
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434enum SqlFamily {
435    /// Integer/decimal/float numeric types.
436    Numeric,
437    /// Boolean.
438    Boolean,
439    /// UUID / `uniqueidentifier`.
440    Uuid,
441    /// Character/string types.
442    Text,
443    /// Date/time/interval types.
444    Temporal,
445    /// `json` / `jsonb`.
446    Json,
447    /// Anything else (custom domains, enums, arrays, geometry, …) — never flagged.
448    Other,
449}
450
451/// Classify a (any-dialect) SQL type string into a coarse [`SqlFamily`].
452///
453/// Strips length/precision and multi-word suffixes (`character varying`,
454/// `timestamp with time zone`, `double precision`) down to the leading base word.
455fn sql_type_family(sql_type: &str) -> SqlFamily {
456    let lower = sql_type.trim().to_lowercase();
457    let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
458    match base {
459        "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
460        | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
461        | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
462        "boolean" | "bool" | "bit" => SqlFamily::Boolean,
463        "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
464        "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
465        | "character" | "clob" | "string" => SqlFamily::Text,
466        "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
467        | "interval" | "smalldatetime" => SqlFamily::Temporal,
468        "json" | "jsonb" => SqlFamily::Json,
469        _ => SqlFamily::Other,
470    }
471}
472
473/// Whether a query argument's GraphQL type can cleanly drive an equality predicate
474/// against a native column of `sql_type`.
475///
476/// Conservative by design: only the strongly-typed scalars (`Int`, `Float`,
477/// `Decimal`, `Boolean`, `UUID`) can flag a mismatch, and only against a *known*
478/// incompatible column family. Permissive scalars (`String`, `ID`, date/time, `JSON`,
479/// custom scalars) and non-scalar references never warn — the runtime binds them as
480/// text-coercible parameters, and `ID` intentionally spans uuid / integer / text keys.
481fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
482    // A list filter (`[ID!]`) drives the predicate with its element type.
483    let base = match field_type {
484        FieldType::List(inner) => inner.as_ref(),
485        other => other,
486    };
487    let family = sql_type_family(sql_type);
488    // Unknown column family → don't second-guess it.
489    if family == SqlFamily::Other {
490        return true;
491    }
492    match base {
493        FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
494        FieldType::Boolean => family == SqlFamily::Boolean,
495        FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
496        _ => true,
497    }
498}
499
500/// Split a potentially schema-qualified name into (optional_schema, name).
501fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
502    match sql_source.split_once('.') {
503        Some((schema, table)) => (Some(schema), table),
504        None => (None, sql_source),
505    }
506}
507
508/// Check if a relation exists in the relation lookup maps.
509fn relation_exists(
510    schema_qualified: &HashMap<(String, String), RelationInfo>,
511    unqualified: &HashMap<String, Vec<String>>,
512    sql_source: &str,
513) -> bool {
514    let (schema, name) = split_schema_qualified(sql_source);
515    if let Some(s) = schema {
516        schema_qualified.contains_key(&(s.to_string(), name.to_string()))
517    } else {
518        unqualified.contains_key(name)
519    }
520}
521
522/// L1 relation existence with runtime-faithful resolution.
523///
524/// For a **schema-qualified** source, probes via `to_regclass` on the
525/// case-sensitively-quoted identifier (search_path-independent — exactly how the
526/// runtime resolves it), so a mixed-case relation in an off-`search_path` schema
527/// is not falsely flagged. Falls back to the relation map for **bare** names
528/// (search_path-scoped, also matching the runtime) or when the connector cannot
529/// probe directly (non-Postgres).
530async fn relation_source_exists(
531    introspector: &impl DatabaseIntrospector,
532    schema_qualified: &HashMap<(String, String), RelationInfo>,
533    unqualified: &HashMap<String, Vec<String>>,
534    sql_source: &str,
535) -> fraiseql_core::Result<bool> {
536    let (schema, name) = split_schema_qualified(sql_source);
537    if let Some(s) = schema {
538        if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
539            return Ok(exists);
540        }
541    }
542    // Bare name, or a connector that can't probe → relation-map lookup.
543    Ok(relation_exists(schema_qualified, unqualified, sql_source))
544}
545
546/// L1 function existence for a mutation's `sql_source`.
547///
548/// A mutation's `sql_source` names a SQL *function*, so it is probed via `pg_proc`
549/// (not the relation catalog). When the connector cannot probe functions
550/// (non-Postgres), returns `true` — the check is skipped rather than false-failing.
551async fn function_source_exists(
552    introspector: &impl DatabaseIntrospector,
553    sql_source: &str,
554) -> fraiseql_core::Result<bool> {
555    let (schema, name) = split_schema_qualified(sql_source);
556    Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
557}
558
559/// Validate a compiled schema against a live database.
560///
561/// Performs three levels of validation:
562/// - **L1**: Checks that `sql_source` relations exist
563/// - **L2**: Checks column existence and JSON column types
564/// - **L3**: Checks JSONB key existence via sampling
565///
566/// All diagnostics are warnings — the report never causes compilation to fail.
567///
568/// # Errors
569///
570/// Returns `FraiseQLError` if database introspection queries fail.
571pub async fn validate_schema_against_database(
572    schema: &CompiledSchema,
573    introspector: &impl DatabaseIntrospector,
574) -> fraiseql_core::Result<DatabaseValidationReport> {
575    // Auto-wired argument names excluded from direct-arg native column detection.
576    // Must stay in sync with AUTO_PARAM_NAMES in fraiseql-core/runtime/executor/query.rs.
577    const AUTO_PARAM_NAMES: &[&str] = &[
578        "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
579    ];
580
581    let mut warnings = Vec::new();
582    let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
583    let mut pagination_orders: HashMap<String, PaginationOrder> = HashMap::new();
584    let db_type = introspector.database_type();
585
586    // L1: Build relation lookup maps
587    let relations = introspector.list_relations().await?;
588    let (schema_qualified, unqualified) = build_relation_maps(&relations);
589
590    // Validate queries
591    for query in &schema.queries {
592        if let Some(ref source) = query.sql_source {
593            // L1: Check relation exists (qualified → to_regclass verbatim, bare → map).
594            if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
595                .await?
596            {
597                warnings.push(DatabaseWarning::MissingRelation {
598                    query_name: query.name.clone(),
599                    sql_source: source.clone(),
600                });
601                continue; // Skip L2/L3 if relation doesn't exist
602            }
603
604            // L2: Get columns for the relation.
605            // Pass the full source (possibly schema-qualified like "benchmark.tv_post") so
606            // the introspector can use the explicit schema when present.
607            let columns = introspector.get_columns(source).await?;
608            let column_map: HashMap<String, String> =
609                columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
610
611            // L2: Check jsonb_column
612            let jsonb_col = &query.jsonb_column;
613            if !jsonb_col.is_empty() {
614                if let Some(actual_type) = column_map.get(jsonb_col) {
615                    if !is_json_type(actual_type, db_type) {
616                        warnings.push(DatabaseWarning::WrongJsonColumnType {
617                            query_name:  query.name.clone(),
618                            sql_source:  source.clone(),
619                            column_name: jsonb_col.clone(),
620                            actual_type: actual_type.clone(),
621                        });
622                    }
623                } else {
624                    warnings.push(DatabaseWarning::MissingJsonColumn {
625                        query_name:  query.name.clone(),
626                        sql_source:  source.clone(),
627                        column_name: jsonb_col.clone(),
628                    });
629                }
630            }
631
632            // L2: Check relay_cursor_column
633            if query.relay {
634                if let Some(ref cursor_col) = query.relay_cursor_column {
635                    if !column_map.contains_key(cursor_col) {
636                        warnings.push(DatabaseWarning::MissingCursorColumn {
637                            query_name:  query.name.clone(),
638                            sql_source:  source.clone(),
639                            column_name: cursor_col.clone(),
640                        });
641                    }
642                }
643            }
644
645            // L2: pagination_order (#1303). Three questions, in the order the
646            // compiled value answers them: a declared column must exist; a derived
647            // JSONB identity may be sharpened to a cheaper native one; and either
648            // way, a view that orders itself is about to have that order replaced.
649            match &query.pagination_order {
650                Some(PaginationOrder::Column(col)) if !column_map.contains_key(col) => {
651                    warnings.push(DatabaseWarning::MissingPaginationColumn {
652                        query_name:  query.name.clone(),
653                        sql_source:  source.clone(),
654                        column_name: col.clone(),
655                    });
656                },
657                Some(PaginationOrder::Column(_)) => {},
658                Some(PaginationOrder::JsonIdentity) => {
659                    let pk = pk_column_name(&query.return_type);
660                    if let Some(col) = cheapest_identity_column(&pk, &column_map) {
661                        pagination_orders.insert(query.name.clone(), PaginationOrder::Column(col));
662                    } else {
663                        warnings.push(DatabaseWarning::PaginationIdentityFallback {
664                            query_name: query.name.clone(),
665                            sql_source: source.clone(),
666                            pk_column:  pk,
667                        });
668                    }
669                },
670                None => {},
671            }
672
673            // A view that orders itself is the one shape a default page ordering
674            // makes worse, and the author is the only one who can adjudicate it —
675            // so it is reported wherever a default is about to apply, whether the
676            // ordering came from the author or from the compiler. A query that
677            // declared `none` has already answered and is silent.
678            if query.pagination_order.is_some()
679                && introspector
680                    .get_view_definition(source)
681                    .await?
682                    .is_some_and(|body| orders_itself(&body))
683            {
684                warnings.push(DatabaseWarning::SelfOrderingView {
685                    query_name: query.name.clone(),
686                    sql_source: source.clone(),
687                });
688            }
689
690            // L3: Sample JSON keys if jsonb_column is valid JSON type
691            if !jsonb_col.is_empty() {
692                let json_type_ok =
693                    column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
694
695                if json_type_ok {
696                    validate_json_keys(
697                        schema,
698                        query,
699                        source,
700                        jsonb_col,
701                        introspector,
702                        source, // pass full schema-qualified source for sample queries
703                        &mut warnings,
704                    )
705                    .await?;
706                }
707            }
708
709            // L2: Detect native columns for direct (non-auto-param) arguments AND
710            // inject params. Both are filtered against the view at runtime, so a name
711            // that matches a real column must use the native-column path
712            // (`WHERE col = $N`) rather than the JSONB fallback (`data->>'name'`).
713            let direct_args: Vec<&str> = query
714                .arguments
715                .iter()
716                .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
717                .map(|a| a.name.as_str())
718                .collect();
719
720            let (query_native, arg_fallbacks) = detect_query_native_columns(
721                &direct_args,
722                query.inject_params.keys().map(String::as_str),
723                &column_map,
724            );
725            for arg_name in arg_fallbacks {
726                warnings.push(DatabaseWarning::NativeColumnFallback {
727                    query_name: query.name.clone(),
728                    sql_source: source.clone(),
729                    arg_name,
730                });
731            }
732
733            // L2 (type-convertibility): a direct argument that resolved to a native
734            // column whose SQL type cannot cleanly drive the predicate is a likely
735            // authoring bug (e.g. an `Int` argument filtering a `uuid` column).
736            for arg in &query.arguments {
737                if let Some(col_type) = query_native.get(&arg.name) {
738                    if !arg_type_convertible(&arg.arg_type, col_type) {
739                        warnings.push(DatabaseWarning::TypeConvertibility {
740                            query_name:   query.name.clone(),
741                            sql_source:   source.clone(),
742                            arg_name:     arg.name.clone(),
743                            graphql_type: arg.arg_type.to_graphql_string(),
744                            column_type:  col_type.clone(),
745                        });
746                    }
747                }
748            }
749
750            if !query_native.is_empty() {
751                native_columns.insert(query.name.clone(), query_native);
752            }
753
754            // L1: Check additional_views (relations, same resolution as sql_source).
755            for view in &query.additional_views {
756                if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
757                    .await?
758                {
759                    warnings.push(DatabaseWarning::MissingAdditionalView {
760                        query_name: query.name.clone(),
761                        view_name:  view.clone(),
762                    });
763                }
764            }
765        }
766    }
767
768    // Validate mutations (L1 only). A mutation's `sql_source` is a FUNCTION, not a
769    // relation — probe `pg_proc`, not the relation catalog (#485). Mirrors the
770    // shared `fraiseql_core::schema::sql_source_probes` Relation/Function split.
771    for mutation in &schema.mutations {
772        if let Some(ref source) = mutation.sql_source {
773            if !function_source_exists(introspector, source).await? {
774                warnings.push(DatabaseWarning::MissingFunction {
775                    mutation_name: mutation.name.clone(),
776                    sql_source:    source.clone(),
777                });
778            }
779        }
780    }
781
782    // Validate type sources (L1 only, warn-grade). A `@fraiseql.type`'s `sql_source` is a
783    // view/table; a declared source that does not exist is usually an embedded value object
784    // the SDK synthesized a source for (#653). Surface it at its origin — never failing —
785    // instead of letting it resurface later as an unrelated cascade `id` error. Unlike
786    // queries/mutations, type sources are not on the shared `sql_source_probes` work-list,
787    // so the hard `FRAISEQL_VALIDATE_SQL_SOURCES` gate does not flag them; this is the only
788    // check that does. Synthetic types (empty source) and error types are skipped.
789    //
790    // This set INTENTIONALLY DIVERGES from `is_queryable_entity` (the cascade classifier).
791    // After #665 that predicate ALSO excludes framework-`internal` projections, but this
792    // existence check must NOT: a missing `v_entity_change_log` / `v_transport_checkpoint`
793    // is exactly #569's failure mode ("no install path; every mutation fails on a fresh
794    // stack"), so validating the change-log views is precisely the point. Do NOT DRY the
795    // two predicates together — the `internal` exemption belongs to cascade classification
796    // only. The `validator_checks_internal_projection_view_existence` tripwire test pins
797    // this divergence so a future "unification" cannot silently delete the #569 signal.
798    for ty in &schema.types {
799        let source = ty.sql_source.as_str();
800        if ty.is_error || source.is_empty() {
801            continue;
802        }
803        if !relation_source_exists(introspector, &schema_qualified, &unqualified, source).await? {
804            warnings.push(DatabaseWarning::MissingTypeSource {
805                type_name:  ty.name.to_string(),
806                sql_source: source.to_string(),
807            });
808        }
809    }
810
811    Ok(DatabaseValidationReport {
812        warnings,
813        native_columns,
814        pagination_orders,
815    })
816}
817
818/// The Trinity primary-key column a type's relation is expected to expose.
819///
820/// The same derivation `relay_cursor_column` uses (`User` → `pk_user`), and
821/// deliberately so: when a relation carries it, both pagination families cut
822/// their pages on the same column, which is ADR-0017's "one identity, consumed
823/// uniformly, not re-derived per subsystem".
824fn pk_column_name(return_type: &str) -> String {
825    format!("pk_{}", fraiseql_core::utils::to_snake_case(return_type))
826}
827
828/// The cheapest native column that totally orders this relation, if it has one.
829///
830/// `pk_<type>` before `id`, which is the opposite of the order the two are
831/// usually reached in and is what the measurements say: against PostgreSQL 16
832/// over 200 000 rows at a deep offset, the `pk` BIGINT index scan costs 1.7x the
833/// unordered plan and the `id` UUID one 4.2x. Both beat the JSONB extraction's
834/// 7.7x, so either is worth taking.
835///
836/// Only the *name* is checked, never the type: a column that orders the rows is
837/// all a total order needs, and a `pk_`/`id` column that is not unique is a
838/// broken view rather than a case to code around.
839fn cheapest_identity_column(pk: &str, columns: &HashMap<String, String>) -> Option<String> {
840    if columns.contains_key(pk) {
841        return Some(pk.to_string());
842    }
843    columns.contains_key("id").then(|| "id".to_string())
844}
845
846/// Does this view body carry its own `ORDER BY`?
847///
848/// Deliberately lexical, and deliberately not exact. `pg_get_viewdef` returns
849/// normalised SQL, so `ORDER BY` appears upper-cased and space-separated when it
850/// appears at all — but it appears for a window frame and a subquery too, where a
851/// page ordering on the outer read is entirely correct. The finding this drives is
852/// therefore advisory and says what it saw; a stricter parser would trade a
853/// question the author can answer in a second for a dependency on parsing SQL.
854fn orders_itself(view_body: &str) -> bool {
855    view_body.contains("ORDER BY")
856}
857
858/// Build lookup maps from the list of relations.
859fn build_relation_maps(
860    relations: &[RelationInfo],
861) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
862    let mut schema_qualified = HashMap::new();
863    let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
864
865    for rel in relations {
866        schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
867        unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
868    }
869
870    (schema_qualified, unqualified)
871}
872
873/// Find every declared `sql_source` that is **not backed** by a live database
874/// object, over the shared [`sql_source_probes`] work-list (#487).
875///
876/// Resolution mirrors the runtime exactly: a query relation by `to_regclass`
877/// (qualified, verbatim) or the search_path-scoped relation map (bare); a mutation
878/// function via `pg_proc`. Returns the unbacked probes in declaration order — an
879/// empty vec means every source is backed. This is the existence gate the
880/// `validate --against-db` CLI and the opt-in server boot check share, so the two
881/// agree on "backed" by construction.
882///
883/// When the connector cannot probe a kind (non-Postgres), that source is treated
884/// as backed (skipped) rather than false-failed.
885///
886/// # Errors
887///
888/// Returns an error if database introspection fails.
889pub async fn find_unbacked_sources(
890    schema: &CompiledSchema,
891    introspector: &impl DatabaseIntrospector,
892) -> fraiseql_core::Result<Vec<SourceProbe>> {
893    let relations = introspector.list_relations().await?;
894    let (schema_qualified, unqualified) = build_relation_maps(&relations);
895
896    let mut unbacked = Vec::new();
897    for probe in sql_source_probes(schema) {
898        let exists = match probe.kind {
899            SourceKind::Function => introspector
900                .function_exists(probe.schema.as_deref(), &probe.name)
901                .await?
902                .unwrap_or(true),
903            SourceKind::Relation => {
904                if let Some(s) = &probe.schema {
905                    match introspector.qualified_relation_exists(s, &probe.name).await? {
906                        Some(exists) => exists,
907                        None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
908                    }
909                } else {
910                    unqualified.contains_key(&probe.name)
911                }
912            },
913        };
914        if !exists {
915            unbacked.push(probe);
916        }
917    }
918    Ok(unbacked)
919}
920
921/// Validate JSON keys in sampled data for L3 checking.
922async fn validate_json_keys(
923    schema: &CompiledSchema,
924    query: &fraiseql_core::schema::QueryDefinition,
925    source: &str,
926    jsonb_col: &str,
927    introspector: &impl DatabaseIntrospector,
928    table_name: &str,
929    warnings: &mut Vec<DatabaseWarning>,
930) -> fraiseql_core::Result<()> {
931    let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
932
933    if samples.is_empty() {
934        return Ok(());
935    }
936
937    // Merge all top-level keys from sampled rows
938    let mut all_keys = HashSet::new();
939    for sample in &samples {
940        if let serde_json::Value::Object(map) = sample {
941            for key in map.keys() {
942                all_keys.insert(key.clone());
943            }
944        }
945    }
946
947    if all_keys.is_empty() {
948        return Ok(());
949    }
950
951    // Find the type definition for this query's return type
952    let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
953
954    if let Some(type_def) = type_def {
955        for field in &type_def.fields {
956            let field_str = field.name.as_str();
957            // Use the canonical acronym/digit-aware caser the runtime queries with
958            // (`httpResponse` → `http_response`, not `h_t_t_p_response`), so the
959            // L3 JSON-key check never cries wolf on acronym/digit fields (#485).
960            let json_key = fraiseql_core::utils::to_snake_case(field_str);
961            // Skip fields that are top-level columns (not from JSONB)
962            // Convention: fields like "id", "pk_*", "fk_*" are columns, not JSON keys
963            if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
964                continue;
965            }
966            if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
967                warnings.push(DatabaseWarning::MissingJsonKey {
968                    query_name: query.name.clone(),
969                    type_name: type_def.name.as_str().to_string(),
970                    sql_source: source.to_string(),
971                    json_column: jsonb_col.to_string(),
972                    field_name: field_str.to_string(),
973                    json_key,
974                    field_required: !field.nullable,
975                });
976            }
977        }
978    }
979
980    Ok(())
981}
982
983/// Enum dispatch for database introspectors.
984///
985/// Uses enum dispatch instead of `Box<dyn DatabaseIntrospector>` because the
986/// trait uses `async_fn_in_trait` and cannot be object-safe.
987pub enum AnyIntrospector {
988    /// `PostgreSQL` introspector.
989    Postgres(fraiseql_core::db::PostgresIntrospector),
990}
991
992impl DatabaseIntrospector for AnyIntrospector {
993    async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
994        match self {
995            Self::Postgres(i) => i.list_fact_tables().await,
996        }
997    }
998
999    async fn get_columns(
1000        &self,
1001        table_name: &str,
1002    ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
1003        match self {
1004            Self::Postgres(i) => i.get_columns(table_name).await,
1005        }
1006    }
1007
1008    async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
1009        match self {
1010            Self::Postgres(i) => i.get_indexed_columns(table_name).await,
1011        }
1012    }
1013
1014    fn database_type(&self) -> DatabaseType {
1015        match self {
1016            Self::Postgres(i) => i.database_type(),
1017        }
1018    }
1019
1020    async fn get_sample_jsonb(
1021        &self,
1022        table_name: &str,
1023        column_name: &str,
1024    ) -> fraiseql_core::Result<Option<serde_json::Value>> {
1025        match self {
1026            Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
1027        }
1028    }
1029
1030    async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
1031        match self {
1032            Self::Postgres(i) => i.list_relations().await,
1033        }
1034    }
1035
1036    async fn get_sample_json_rows(
1037        &self,
1038        table_name: &str,
1039        column_name: &str,
1040        limit: usize,
1041    ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
1042        match self {
1043            Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
1044        }
1045    }
1046
1047    async fn function_exists(
1048        &self,
1049        schema: Option<&str>,
1050        name: &str,
1051    ) -> fraiseql_core::Result<Option<bool>> {
1052        // Must delegate (not inherit the trait default) so the Postgres impl is
1053        // actually used — otherwise every variant would return `None`.
1054        match self {
1055            Self::Postgres(i) => i.function_exists(schema, name).await,
1056        }
1057    }
1058
1059    async fn qualified_relation_exists(
1060        &self,
1061        schema: &str,
1062        name: &str,
1063    ) -> fraiseql_core::Result<Option<bool>> {
1064        match self {
1065            Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
1066        }
1067    }
1068
1069    async fn get_view_definition(&self, relation: &str) -> fraiseql_core::Result<Option<String>> {
1070        match self {
1071            Self::Postgres(i) => i.get_view_definition(relation).await,
1072        }
1073    }
1074}
1075
1076/// Create an introspector from a database URL.
1077///
1078/// Detects the database type from the URL scheme and creates the appropriate
1079/// introspector with a connection pool.
1080///
1081/// # Errors
1082///
1083/// Returns error if the URL scheme is unrecognized or the connection pool
1084/// cannot be created.
1085#[allow(clippy::unused_async)] // Reason: callers always .await this; feature-gated branches do use await
1086pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
1087    if db_url.starts_with("postgres") {
1088        use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
1089        use tokio_postgres::NoTls;
1090
1091        let mut cfg = Config::new();
1092        cfg.url = Some(db_url.to_string());
1093        cfg.manager = Some(ManagerConfig {
1094            recycling_method: RecyclingMethod::Fast,
1095        });
1096        cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
1097
1098        let pool = cfg
1099            .create_pool(Some(Runtime::Tokio1), NoTls)
1100            .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
1101
1102        Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
1103    } else if db_url.starts_with("mysql")
1104        || db_url.starts_with("mariadb")
1105        || db_url.starts_with("sqlite")
1106        || db_url.starts_with("mssql")
1107        || db_url.starts_with("server=")
1108        || std::path::Path::new(db_url)
1109            .extension()
1110            .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
1111    {
1112        anyhow::bail!(
1113            "This database URL targets an engine whose support was removed: FraiseQL is \
1114             PostgreSQL-only (G2 decision on #374; the non-PostgreSQL dialects were never \
1115             production-correct, see #721/#799). Use a postgresql:// URL."
1116        )
1117    } else {
1118        anyhow::bail!("Unrecognized database URL scheme: {db_url}")
1119    }
1120}
1121
1122/// Build a query's native-column map from its explicit (non-auto-param) arguments
1123/// and its inject-param names, consulting the introspected `column_map`.
1124///
1125/// A name that matches a real view column is rendered as a native-column predicate
1126/// (`WHERE col = $N`) at runtime; a name with no matching column falls back to the
1127/// JSONB path (`data->>'name'`). inject params were previously omitted here, so an
1128/// inject-scoped list query against a view that keeps e.g. `tenant_id` as a real
1129/// column (and not inside `data`) rendered `data->>'tenant_id'` and matched no rows.
1130///
1131/// Returns the native-column map plus the explicit-arg names that did NOT resolve to
1132/// a column — the caller emits a `NativeColumnFallback` warning for each. Inject-param
1133/// misses are intentionally silent: a claim may legitimately live in the `data` JSONB.
1134fn detect_query_native_columns<'a>(
1135    direct_arg_names: &[&str],
1136    inject_param_names: impl Iterator<Item = &'a str>,
1137    column_map: &HashMap<String, String>,
1138) -> (HashMap<String, String>, Vec<String>) {
1139    let mut native: HashMap<String, String> = HashMap::new();
1140    let mut arg_fallbacks: Vec<String> = Vec::new();
1141
1142    for arg in direct_arg_names {
1143        if let Some(col_type) = column_map.get(*arg) {
1144            native.insert((*arg).to_string(), col_type.clone());
1145        } else {
1146            arg_fallbacks.push((*arg).to_string());
1147        }
1148    }
1149
1150    for name in inject_param_names {
1151        if let Some(col_type) = column_map.get(name) {
1152            native.insert(name.to_string(), col_type.clone());
1153        }
1154    }
1155
1156    (native, arg_fallbacks)
1157}
1158
1159#[cfg(test)]
1160#[path = "database_validator_tests.rs"]
1161mod database_validator_tests;