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};
27use fraiseql_db::postgres::PostgresTlsConfig;
28
29use super::mutation_contract::Severity;
30
31/// Report containing all database validation warnings and discovered metadata.
32pub struct DatabaseValidationReport {
33    /// All warnings emitted during validation.
34    pub warnings:          Vec<DatabaseWarning>,
35    /// Native columns discovered per query during L2 validation.
36    ///
37    /// Key: query name. Value: map of argument name → `PostgreSQL` type string
38    /// (e.g. `"uuid"`, `"integer"`, `"text"`).
39    ///
40    /// Only contains entries for queries that have at least one direct argument
41    /// with a matching native column on their `sql_source`.
42    pub native_columns:    HashMap<String, HashMap<String, fraiseql_core::schema::NativeColumn>>,
43    /// Cheaper page orderings discovered per query during L2 validation (#1303).
44    ///
45    /// Key: query name. Value: the ordering to replace the compiler's offline
46    /// answer with.
47    ///
48    /// Only contains entries for queries whose `pagination_order` the compiler
49    /// *derived* — an authored one is never overridden, and a query that declared
50    /// `none` is never given one back. The offline derivation is already correct;
51    /// this makes it cheap, which is why introspection may sharpen the answer and
52    /// may never be required to produce one.
53    pub pagination_orders: HashMap<String, PaginationOrder>,
54}
55
56/// A single database validation warning.
57#[derive(Debug)]
58pub enum DatabaseWarning {
59    /// L1: `sql_source` relation does not exist.
60    MissingRelation {
61        /// Name of the query or mutation.
62        query_name: String,
63        /// The `sql_source` value that was not found.
64        sql_source: String,
65    },
66    /// L1: a mutation's `sql_source` **function** does not exist.
67    ///
68    /// Distinct from [`MissingRelation`](Self::MissingRelation): a mutation's
69    /// `sql_source` names a SQL *function* (the runtime calls
70    /// `SELECT * FROM fn(...)`), not a view/table, so it is probed via `pg_proc`
71    /// rather than the relation catalog.
72    MissingFunction {
73        /// Name of the mutation.
74        mutation_name: String,
75        /// The `sql_source` function that was not found.
76        sql_source:    String,
77    },
78    /// L1: a `@fraiseql.type`'s declared `sql_source` view does not exist (#653).
79    ///
80    /// Most often an embedded value object the SDK gave a synthesized `v_<name>` source by
81    /// naming convention (a view that was never authored). Advisory only — it never fails
82    /// the compile; the hard `FRAISEQL_VALIDATE_SQL_SOURCES` gate deliberately does **not**
83    /// probe type sources (it would drown in synthesized-source noise), so this warn-grade
84    /// path under `--database` is the only place phantom type sources surface.
85    MissingTypeSource {
86        /// Name of the type.
87        type_name:  String,
88        /// The `sql_source` view that was not found.
89        sql_source: String,
90    },
91    /// L1: `additional_view` does not exist.
92    MissingAdditionalView {
93        /// Name of the query.
94        query_name: String,
95        /// The view name that was not found.
96        view_name:  String,
97    },
98    /// L2: `jsonb_column` does not exist on the relation.
99    MissingJsonColumn {
100        /// Name of the query.
101        query_name:  String,
102        /// The `sql_source` relation.
103        sql_source:  String,
104        /// The missing column name.
105        column_name: String,
106    },
107    /// L2: `jsonb_column` exists but is not a JSON/JSONB type.
108    WrongJsonColumnType {
109        /// Name of the query.
110        query_name:  String,
111        /// The `sql_source` relation.
112        sql_source:  String,
113        /// The column name.
114        column_name: String,
115        /// The actual SQL data type.
116        actual_type: String,
117    },
118    /// L2: an authored `pagination_order` column does not exist on the relation (#1303).
119    ///
120    /// The same shape as [`MissingCursorColumn`](Self::MissingCursorColumn) and
121    /// error-grade for the same reason: the column is rendered into `ORDER BY`, so
122    /// a name the relation does not carry is a query that fails at request time,
123    /// on a route that compiled cleanly.
124    MissingPaginationColumn {
125        /// Name of the query.
126        query_name:  String,
127        /// The `sql_source` relation.
128        sql_source:  String,
129        /// The missing column name.
130        column_name: String,
131    },
132    /// L2: the query's pages are ordered by the type's identity, and its relation
133    /// exposes neither `pk_<type>` nor an `id` column to order them by (#1569).
134    ///
135    /// An error: ADR-0017 makes `id` a base column of every read table and view, and
136    /// the identity is never read out of the document, so the compiled `ORDER BY`
137    /// would fail at the first paged read.
138    MissingIdentityColumn {
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    /// A filter the query's relation cannot provide — an `inject_params` key (#1382) or a
205    /// direct argument (#1394): no column of that name, and no sampled `data` row carrying
206    /// it — so the predicate is false for every row and every caller gets an empty,
207    /// successful answer.
208    UnresolvableFilter {
209        /// Name of the query.
210        query_name: String,
211        /// The `sql_source` relation.
212        sql_source: String,
213        /// What filters the relation.
214        filter:     QueryFilter,
215        /// The column / JSON key the runtime reads for it.
216        key:        String,
217        /// Why the relation cannot provide it.
218        reason:     InjectMiss,
219    },
220    /// L2: a direct query argument resolves to a native column whose SQL type cannot
221    /// cleanly drive the predicate for the argument's GraphQL scalar type (e.g. an
222    /// `Int` argument filtering a `uuid` column — `WHERE col = $N` errors or never
223    /// matches).
224    TypeConvertibility {
225        /// Name of the query.
226        query_name:   String,
227        /// The `sql_source` relation.
228        sql_source:   String,
229        /// The argument name.
230        arg_name:     String,
231        /// The argument's declared GraphQL type (e.g. `Int`).
232        graphql_type: String,
233        /// The native column's SQL type (e.g. `uuid`).
234        column_type:  String,
235    },
236    /// L2: a query sorts or filters a `DateTime`, `Date` or `Time` field it reads out of its
237    /// relation's document (#1571). The runtime compares `(data->>'key')::timestamptz`,
238    /// a cast PostgreSQL will not index (it depends on the session's `TimeZone`), so every sort
239    /// or filter on the key reads every row. A native column of the type, mapped by an argument
240    /// of the field's name, is read instead, and an index on it serves both.
241    TemporalKeyInDocument {
242        /// Name of the query.
243        query_name: String,
244        /// The `sql_source` relation.
245        sql_source: String,
246        /// The field, as the schema names it.
247        field:      String,
248        /// The column the runtime would read for it.
249        column:     String,
250        /// What would let an index serve the field.
251        remedy:     TemporalKeyRemedy,
252    },
253    /// L2: a query filters a TVIEW's document on keys no index of the TVIEW serves (#1571):
254    /// its inject parameters and arguments that read `data->>'key'`, on every call. `pg_tviews`
255    /// keeps an index declared with the TVIEW (its `indexes` option) over its rebuilds.
256    TviewIndexMissing {
257        /// Name of the query.
258        query_name: String,
259        /// The TVIEW's table, schema-qualified.
260        tview:      String,
261        /// The document keys it filters on, in the index's order.
262        keys:       Vec<String>,
263        /// The TVIEW's options with the index added: the whole declaration a replace must
264        /// pass, since one without `indexes` drops them.
265        options:    serde_json::Value,
266    },
267    /// L3 (#1579): a query is scoped by an `inject_params` column that is a native column of
268    /// its relation, its type publishes the key as a field, and sampled rows hold a
269    /// different value in the column than in the document. The root read filters on the
270    /// column and an embedded document of the type on the key: they must agree.
271    NativeKeyDisagreement {
272        /// Name of the query.
273        query_name:  String,
274        /// The `sql_source` relation.
275        sql_source:  String,
276        /// The native column, and the document key of the same name.
277        column:      String,
278        /// The JSON column holding the document.
279        json_column: String,
280        /// Rows sampled.
281        sampled:     u64,
282        /// Rows whose column and key differ.
283        disagreeing: u64,
284    },
285}
286
287/// What would let an index serve a temporal field read out of a document
288/// ([`DatabaseWarning::TemporalKeyInDocument`]).
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum TemporalKeyRemedy {
291    /// The relation has the column: only the query's mapping is missing.
292    MapColumn,
293    /// A TVIEW without the column: its definition can project one.
294    AddToTview,
295    /// A table without the column: it can have one.
296    AddToTable,
297    /// A view without the column: it can expose its base table's, where the index lives.
298    ExposeFromView,
299}
300
301impl DatabaseWarning {
302    /// For [`TviewIndexMissing`](Self::TviewIndexMissing): the statement that declares the
303    /// index with the TVIEW, keeping its definition and its other options. `None` for any
304    /// other warning.
305    #[must_use]
306    pub fn statement(&self) -> Option<String> {
307        let Self::TviewIndexMissing { tview, options, .. } = self else {
308            return None;
309        };
310        let literal = |text: &str| text.replace('\'', "''");
311        Some(format!(
312            "SELECT tviews.pg_tviews_create_or_replace(format('%I.%I', schema, name), query, \
313             '{}'::jsonb) FROM tviews.registry WHERE format('%I.%I', schema, name) = '{}';",
314            literal(&options.to_string()),
315            literal(tview)
316        ))
317    }
318}
319
320/// A query filter the runtime lowers onto the relation: what the author declared.
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub enum QueryFilter {
323    /// `inject_params['<name>']`.
324    Inject(String),
325    /// A direct GraphQL argument (its GraphQL name).
326    Argument(String),
327}
328
329impl fmt::Display for QueryFilter {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        match self {
332            Self::Inject(name) => write!(f, "inject_params['{name}']"),
333            Self::Argument(name) => write!(f, "argument `{name}`"),
334        }
335    }
336}
337
338/// Why a relation cannot provide a query filter (#1382, #1394).
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum InjectMiss {
341    /// No column of that name, and no JSON column to fall back on.
342    NoJsonColumn,
343    /// No column of that name, and none of the sampled rows' JSON carries the key.
344    NotInSampledRows {
345        /// The JSON column sampled.
346        json_column: String,
347    },
348    /// No column of that name, and the relation is empty, so the JSON key cannot be checked.
349    EmptyRelation {
350        /// The JSON column that would have been sampled.
351        json_column: String,
352    },
353}
354
355impl DatabaseWarning {
356    /// The severity of this finding under `compile --database` (#384).
357    ///
358    /// `Error`-severity drift fails the compile (unless `--allow-drift`):
359    /// the declared schema names database objects that do not exist or cannot
360    /// serve the declared shape, so the first query/mutation against them fails
361    /// at runtime. `Warn`-severity findings are advisory:
362    ///
363    /// - [`MissingTypeSource`](Self::MissingTypeSource) is warn-grade by design (#653): synthesized
364    ///   value-object sources would drown a hard gate.
365    /// - [`MissingJsonKey`](Self::MissingJsonKey) is sampling-based; a *nullable* field may
366    ///   legitimately be unset in every sampled row, so only a **required** field's absent key is
367    ///   an error.
368    /// - [`NativeColumnFallback`](Self::NativeColumnFallback) is a performance advisory — the JSONB
369    ///   path is correct, just slower.
370    #[must_use]
371    pub const fn severity(&self) -> Severity {
372        match self {
373            Self::MissingRelation { .. }
374            | Self::MissingFunction { .. }
375            | Self::MissingAdditionalView { .. }
376            | Self::MissingJsonColumn { .. }
377            | Self::WrongJsonColumnType { .. }
378            | Self::MissingCursorColumn { .. }
379            | Self::MissingPaginationColumn { .. }
380            | Self::MissingIdentityColumn { .. }
381            | Self::TypeConvertibility { .. }
382            | Self::NativeKeyDisagreement { .. } => Severity::Error,
383            // Evidence of absence fails the compile; an empty relation proves nothing.
384            Self::UnresolvableFilter { reason, .. } => match reason {
385                InjectMiss::NoJsonColumn | InjectMiss::NotInSampledRows { .. } => Severity::Error,
386                InjectMiss::EmptyRelation { .. } => Severity::Warn,
387            },
388            Self::MissingJsonKey { field_required, .. } => {
389                if *field_required {
390                    Severity::Error
391                } else {
392                    Severity::Warn
393                }
394            },
395            Self::MissingTypeSource { .. }
396            | Self::NativeColumnFallback { .. }
397            | Self::SelfOrderingView { .. }
398            | Self::TemporalKeyInDocument { .. }
399            | Self::TviewIndexMissing { .. } => Severity::Warn,
400        }
401    }
402}
403
404impl fmt::Display for DatabaseWarning {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        match self {
407            Self::MissingRelation {
408                query_name,
409                sql_source,
410            } => {
411                write!(
412                    f,
413                    "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
414                )
415            },
416            Self::MissingFunction {
417                mutation_name,
418                sql_source,
419            } => {
420                write!(
421                    f,
422                    "mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
423                )
424            },
425            Self::MissingTypeSource {
426                type_name,
427                sql_source,
428            } => {
429                write!(
430                    f,
431                    "type `{type_name}`: sql_source `{sql_source}` does not exist in the \
432                     database — if `{type_name}` is an embedded value object it should declare \
433                     no source (a value object has no independent identity; it is fetched via \
434                     its parent)"
435                )
436            },
437            Self::MissingAdditionalView {
438                query_name,
439                view_name,
440            } => {
441                write!(
442                    f,
443                    "query `{query_name}`: additional_view `{view_name}` does not exist in database"
444                )
445            },
446            Self::MissingJsonColumn {
447                query_name,
448                sql_source,
449                column_name,
450            } => {
451                write!(
452                    f,
453                    "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
454                )
455            },
456            Self::WrongJsonColumnType {
457                query_name,
458                sql_source,
459                column_name,
460                actual_type,
461            } => {
462                write!(
463                    f,
464                    "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
465                )
466            },
467            Self::MissingCursorColumn {
468                query_name,
469                sql_source,
470                column_name,
471            } => {
472                write!(
473                    f,
474                    "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
475                )
476            },
477            Self::MissingPaginationColumn {
478                query_name,
479                sql_source,
480                column_name,
481            } => {
482                write!(
483                    f,
484                    "query `{query_name}`: pagination_order column `{column_name}` not found on \
485                     `{sql_source}` — it is rendered into ORDER BY, so this query fails at \
486                     request time"
487                )
488            },
489            Self::MissingIdentityColumn {
490                query_name,
491                sql_source,
492                pk_column,
493            } => {
494                write!(
495                    f,
496                    "query `{query_name}`: offset pages are ordered by the type's identity, and \
497                     `{sql_source}` exposes neither `{pk_column}` nor an `id` column (ADR-0017 \
498                     makes `id` a base column of every read relation). Expose one of them, or \
499                     declare pagination_order"
500                )
501            },
502            Self::SelfOrderingView {
503                query_name,
504                sql_source,
505            } => {
506                write!(
507                    f,
508                    "query `{query_name}`: view `{sql_source}` carries its own ORDER BY, and \
509                     this query does not declare pagination_order. An offset page will now be \
510                     ordered by the entity identity instead, replacing the view's order. \
511                     Declare pagination_order = \"none\" to keep it, or a column to confirm \
512                     the new one"
513                )
514            },
515            Self::MissingJsonKey {
516                query_name,
517                type_name,
518                sql_source,
519                json_column,
520                field_name,
521                json_key,
522                field_required,
523            } => {
524                let requiredness = if *field_required {
525                    "required"
526                } else {
527                    "nullable"
528                };
529                write!(
530                    f,
531                    "query `{query_name}`: {requiredness} field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
532                )
533            },
534            Self::NativeColumnFallback {
535                query_name,
536                sql_source,
537                arg_name,
538            } => {
539                let key = fraiseql_core::utils::to_snake_case(arg_name);
540                write!(
541                    f,
542                    "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
543                     (`{sql_source}.data->>'{key}'`) — no native column `{key}` found on \
544                     `{sql_source}`. Add a native column with an index for O(log n) lookup."
545                )
546            },
547            Self::UnresolvableFilter {
548                query_name,
549                sql_source,
550                filter,
551                key,
552                reason,
553            } => {
554                let why = match reason {
555                    InjectMiss::NoJsonColumn => "has no JSON column to fall back on".to_string(),
556                    InjectMiss::NotInSampledRows { json_column } => {
557                        format!("and none of the sampled `{json_column}` rows carries the key")
558                    },
559                    InjectMiss::EmptyRelation { json_column } => format!(
560                        "and is empty, so whether `{json_column}` carries the key cannot be checked"
561                    ),
562                };
563                let way_out = match filter {
564                    QueryFilter::Inject(_) => {
565                        "Add the column to the relation, or, if this data is not scoped by it, drop \
566                         the parameter from this query (`exclude_inject_defaults` when it comes from \
567                         [inject_defaults])"
568                    },
569                    QueryFilter::Argument(_) => {
570                        "Add the column to the relation (or the key to its `data`), or rename the \
571                         argument after the column it filters"
572                    },
573                };
574                write!(
575                    f,
576                    "query `{query_name}`: {filter} filters `{sql_source}` on `{key}`, which has no \
577                     `{key}` column {why} — the predicate would match no row and every caller would \
578                     get an empty answer. {way_out}"
579                )
580            },
581            Self::TypeConvertibility {
582                query_name,
583                sql_source,
584                arg_name,
585                graphql_type,
586                column_type,
587            } => {
588                write!(
589                    f,
590                    "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
591                     `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
592                     Align the argument type with the column, or store the field in `data` for JSONB extraction."
593                )
594            },
595            Self::TemporalKeyInDocument {
596                query_name,
597                sql_source,
598                field,
599                column,
600                remedy,
601            } => {
602                let step = match remedy {
603                    TemporalKeyRemedy::MapColumn => format!(
604                        "`{sql_source}` already has `{column}`: give the query an argument named \
605                         `{field}` so this compile maps it to the column"
606                    ),
607                    TemporalKeyRemedy::AddToTview => format!(
608                        "Add `{column}` to the TVIEW's definition (with its own type, e.g. \
609                         `timestamptz`, and an index), then give the query an argument named \
610                         `{field}` so this compile maps it to the column"
611                    ),
612                    TemporalKeyRemedy::AddToTable => format!(
613                        "Add a `{column}` column to `{sql_source}` (with its own type, e.g. \
614                         `timestamptz`, and an index), then give the query an argument named \
615                         `{field}` so this compile maps it to the column"
616                    ),
617                    TemporalKeyRemedy::ExposeFromView => format!(
618                        "Expose `{column}` from the view's base table as a column of its own \
619                         type (e.g. `timestamptz`), index it on the base table with the page's \
620                         tie-breaker (`({column} DESC, id)`), then give the query an argument \
621                         named `{field}` so this compile maps it to the column"
622                    ),
623                };
624                write!(
625                    f,
626                    "query `{query_name}`: `{field}` is read out of `{sql_source}`'s document, so \
627                     every sort or filter on it casts each row's value and no index can serve it. \
628                     {step} (docs/performance.md, \"Use a Native Column\")."
629                )
630            },
631            Self::NativeKeyDisagreement {
632                query_name,
633                sql_source,
634                column,
635                json_column,
636                sampled,
637                disagreeing,
638            } => {
639                write!(
640                    f,
641                    "query `{query_name}`: is scoped by `{column}`, read from the native column \
642                     at the root and from the `{json_column}` key `{column}` wherever the \
643                     document is embedded, and {disagreeing} of {sampled} sampled \
644                     `{sql_source}` rows hold a different value in each — an embedded document \
645                     would be filtered on another value than its row. Build the key from the \
646                     column (`jsonb_build_object('{column}', {column}, …)`)."
647                )
648            },
649            Self::TviewIndexMissing {
650                query_name,
651                tview,
652                keys,
653                ..
654            } => {
655                write!(
656                    f,
657                    "query `{query_name}`: filters `{tview}`'s document on `{}` on every call, \
658                     and no index of the TVIEW serves it. Declare one with the TVIEW, keeping its \
659                     other options (a replace's options are its whole declaration: one without \
660                     `indexes` drops them): {}",
661                    keys.join("`, `"),
662                    self.statement().unwrap_or_default()
663                )
664            },
665        }
666    }
667}
668
669/// Check if a SQL data type represents a JSON column for the given database.
670pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
671    let lower = data_type.to_lowercase();
672    match db_type {
673        DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
674    }
675}
676
677/// Coarse SQL type families, used to flag argument↔column type mismatches.
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum SqlFamily {
680    /// Integer/decimal/float numeric types.
681    Numeric,
682    /// Boolean.
683    Boolean,
684    /// UUID / `uniqueidentifier`.
685    Uuid,
686    /// Character/string types.
687    Text,
688    /// Date/time/interval types.
689    Temporal,
690    /// `json` / `jsonb`.
691    Json,
692    /// Anything else (custom domains, enums, arrays, geometry, …) — never flagged.
693    Other,
694}
695
696/// Classify a (any-dialect) SQL type string into a coarse [`SqlFamily`].
697///
698/// Strips length/precision and multi-word suffixes (`character varying`,
699/// `timestamp with time zone`, `double precision`) down to the leading base word.
700fn sql_type_family(sql_type: &str) -> SqlFamily {
701    let lower = sql_type.trim().to_lowercase();
702    let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
703    match base {
704        "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
705        | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
706        | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
707        "boolean" | "bool" | "bit" => SqlFamily::Boolean,
708        "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
709        "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
710        | "character" | "clob" | "string" => SqlFamily::Text,
711        "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
712        | "interval" | "smalldatetime" => SqlFamily::Temporal,
713        "json" | "jsonb" => SqlFamily::Json,
714        _ => SqlFamily::Other,
715    }
716}
717
718/// Whether a query argument's GraphQL type can cleanly drive an equality predicate
719/// against a native column of `sql_type`.
720///
721/// Conservative by design: only the strongly-typed scalars (`Int`, `Float`,
722/// `Decimal`, `Boolean`, `UUID`) can flag a mismatch, and only against a *known*
723/// incompatible column family. Permissive scalars (`String`, `ID`, date/time, `JSON`,
724/// custom scalars) and non-scalar references never warn — the runtime binds them as
725/// text-coercible parameters, and `ID` intentionally spans uuid / integer / text keys.
726fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
727    // A list filter (`[ID!]`) drives the predicate with its element type.
728    let base = match field_type {
729        FieldType::List { item: inner, .. } => inner.as_ref(),
730        other => other,
731    };
732    let family = sql_type_family(sql_type);
733    // Unknown column family → don't second-guess it.
734    if family == SqlFamily::Other {
735        return true;
736    }
737    match base {
738        FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
739        FieldType::Boolean => family == SqlFamily::Boolean,
740        FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
741        _ => true,
742    }
743}
744
745/// Split a potentially schema-qualified name into (optional_schema, name).
746fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
747    match sql_source.split_once('.') {
748        Some((schema, table)) => (Some(schema), table),
749        None => (None, sql_source),
750    }
751}
752
753/// Check if a relation exists in the relation lookup maps.
754fn relation_exists(
755    schema_qualified: &HashMap<(String, String), RelationInfo>,
756    unqualified: &HashMap<String, Vec<String>>,
757    sql_source: &str,
758) -> bool {
759    let (schema, name) = split_schema_qualified(sql_source);
760    if let Some(s) = schema {
761        schema_qualified.contains_key(&(s.to_string(), name.to_string()))
762    } else {
763        unqualified.contains_key(name)
764    }
765}
766
767/// L1 relation existence with runtime-faithful resolution.
768///
769/// For a **schema-qualified** source, probes via `to_regclass` on the
770/// case-sensitively-quoted identifier (search_path-independent — exactly how the
771/// runtime resolves it), so a mixed-case relation in an off-`search_path` schema
772/// is not falsely flagged. Falls back to the relation map for **bare** names
773/// (search_path-scoped, also matching the runtime) or when the connector cannot
774/// probe directly (non-Postgres).
775async fn relation_source_exists(
776    introspector: &impl DatabaseIntrospector,
777    schema_qualified: &HashMap<(String, String), RelationInfo>,
778    unqualified: &HashMap<String, Vec<String>>,
779    sql_source: &str,
780) -> fraiseql_core::Result<bool> {
781    let (schema, name) = split_schema_qualified(sql_source);
782    if let Some(s) = schema {
783        if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
784            return Ok(exists);
785        }
786    }
787    // Bare name, or a connector that can't probe → relation-map lookup.
788    Ok(relation_exists(schema_qualified, unqualified, sql_source))
789}
790
791/// L1 function existence for a mutation's `sql_source`.
792///
793/// A mutation's `sql_source` names a SQL *function*, so it is probed via `pg_proc`
794/// (not the relation catalog). When the connector cannot probe functions
795/// (non-Postgres), returns `true` — the check is skipped rather than false-failing.
796async fn function_source_exists(
797    introspector: &impl DatabaseIntrospector,
798    sql_source: &str,
799) -> fraiseql_core::Result<bool> {
800    let (schema, name) = split_schema_qualified(sql_source);
801    Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
802}
803
804/// Validate a compiled schema against a live database.
805///
806/// Performs three levels of validation:
807/// - **L1**: Checks that `sql_source` relations exist
808/// - **L2**: Checks column existence and JSON column types
809/// - **L3**: Checks JSONB key existence via sampling
810///
811/// All diagnostics are warnings — the report never causes compilation to fail.
812///
813/// # Errors
814///
815/// Returns `FraiseQLError` if database introspection queries fail.
816pub async fn validate_schema_against_database(
817    schema: &CompiledSchema,
818    introspector: &impl DatabaseIntrospector,
819) -> fraiseql_core::Result<DatabaseValidationReport> {
820    // Auto-wired argument names excluded from direct-arg native column detection.
821    // Must stay in sync with AUTO_PARAM_NAMES in fraiseql-core/runtime/executor/query.rs.
822    const AUTO_PARAM_NAMES: &[&str] = &[
823        "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
824    ];
825
826    let mut warnings = Vec::new();
827    let mut native_columns: HashMap<String, HashMap<String, fraiseql_core::schema::NativeColumn>> =
828        HashMap::new();
829    let mut pagination_orders: HashMap<String, PaginationOrder> = HashMap::new();
830    let db_type = introspector.database_type();
831
832    // L1: Build relation lookup maps
833    let relations = introspector.list_relations().await?;
834    let (schema_qualified, unqualified) = build_relation_maps(&relations);
835
836    // Validate queries. A function root reads no relation: its function is checked
837    // by `function_root_contract`.
838    for query in schema.queries.iter().filter(|q| q.sql_source_kind.is_relation()) {
839        if let Some(ref source) = query.sql_source {
840            // L1: Check relation exists (qualified → to_regclass verbatim, bare → map).
841            if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
842                .await?
843            {
844                warnings.push(DatabaseWarning::MissingRelation {
845                    query_name: query.name.clone(),
846                    sql_source: source.clone(),
847                });
848                continue; // Skip L2/L3 if relation doesn't exist
849            }
850
851            // L2: Get columns for the relation.
852            // Pass the full source (possibly schema-qualified like "benchmark.tv_post") so
853            // the introspector can use the explicit schema when present.
854            let columns = introspector.get_columns(source).await?;
855            // The columns PostgreSQL proves `NOT NULL` (#1533). A view's are none: the
856            // catalog reports every view column nullable, so only a base relation proves it.
857            let not_null: std::collections::HashSet<String> = columns
858                .iter()
859                .filter(|(_, _, nullable)| !nullable)
860                .map(|(name, _, _)| name.clone())
861                .collect();
862            let column_map: HashMap<String, String> =
863                columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
864
865            // L2: Check jsonb_column
866            let jsonb_col = &query.jsonb_column;
867            if !jsonb_col.is_empty() {
868                if let Some(actual_type) = column_map.get(jsonb_col) {
869                    if !is_json_type(actual_type, db_type) {
870                        warnings.push(DatabaseWarning::WrongJsonColumnType {
871                            query_name:  query.name.clone(),
872                            sql_source:  source.clone(),
873                            column_name: jsonb_col.clone(),
874                            actual_type: actual_type.clone(),
875                        });
876                    }
877                } else {
878                    warnings.push(DatabaseWarning::MissingJsonColumn {
879                        query_name:  query.name.clone(),
880                        sql_source:  source.clone(),
881                        column_name: jsonb_col.clone(),
882                    });
883                }
884            }
885
886            // L2: Check relay_cursor_column
887            if query.relay {
888                if let Some(ref cursor_col) = query.relay_cursor_column {
889                    if !column_map.contains_key(cursor_col) {
890                        warnings.push(DatabaseWarning::MissingCursorColumn {
891                            query_name:  query.name.clone(),
892                            sql_source:  source.clone(),
893                            column_name: cursor_col.clone(),
894                        });
895                    }
896                }
897            }
898
899            // L2: pagination_order (#1303). Three questions, in the order the
900            // compiled value answers them: a declared column must exist; a derived
901            // identity is sharpened to `pk_<type>` (1.7x the unordered plan at a deep
902            // offset, against 4.2x for `id`) and needs one of the two (#1569); and
903            // either way, a view that orders itself is about to have that order replaced.
904            match &query.pagination_order {
905                Some(PaginationOrder::Column(col)) if !column_map.contains_key(col) => {
906                    warnings.push(DatabaseWarning::MissingPaginationColumn {
907                        query_name:  query.name.clone(),
908                        sql_source:  source.clone(),
909                        column_name: col.clone(),
910                    });
911                },
912                Some(PaginationOrder::Column(_)) => {},
913                Some(PaginationOrder::Identity) => {
914                    let pk = pk_column_name(&query.return_type);
915                    if column_map.contains_key(&pk) {
916                        pagination_orders.insert(query.name.clone(), PaginationOrder::Column(pk));
917                    } else if !column_map.contains_key("id") {
918                        warnings.push(DatabaseWarning::MissingIdentityColumn {
919                            query_name: query.name.clone(),
920                            sql_source: source.clone(),
921                            pk_column:  pk,
922                        });
923                    }
924                },
925                None => {},
926            }
927
928            // A view that orders itself is the one shape a default page ordering
929            // makes worse, and the author is the only one who can adjudicate it —
930            // so it is reported wherever a default is about to apply, whether the
931            // ordering came from the author or from the compiler. A query that
932            // declared `none` has already answered and is silent.
933            if query.pagination_order.is_some()
934                && introspector
935                    .get_view_definition(source)
936                    .await?
937                    .is_some_and(|body| orders_itself(&body))
938            {
939                warnings.push(DatabaseWarning::SelfOrderingView {
940                    query_name: query.name.clone(),
941                    sql_source: source.clone(),
942                });
943            }
944
945            // L3: Sample JSON keys if jsonb_column is valid JSON type
946            if !jsonb_col.is_empty() {
947                let json_type_ok =
948                    column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
949
950                if json_type_ok {
951                    validate_json_keys(
952                        schema,
953                        query,
954                        source,
955                        jsonb_col,
956                        introspector,
957                        source, // pass full schema-qualified source for sample queries
958                        &mut warnings,
959                    )
960                    .await?;
961                }
962            }
963
964            // L2: Detect native columns for direct (non-auto-param) arguments AND
965            // inject params. Both are filtered against the view at runtime, so a name
966            // that matches a real column must use the native-column path
967            // (`WHERE col = $N`) rather than the JSONB fallback (`data->>'name'`).
968            let direct_args: Vec<&str> = query
969                .arguments
970                .iter()
971                .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
972                .map(|a| a.name.as_str())
973                .collect();
974
975            let (query_native, arg_fallbacks) = detect_query_native_columns(
976                &direct_args,
977                query.inject_params.keys().map(String::as_str),
978                &column_map,
979                &not_null,
980            );
981            // L3 (#1579): an inject column the type publishes is read from the column at the
982            // root and from the document key wherever the document is embedded.
983            if !jsonb_col.is_empty()
984                && column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type))
985            {
986                check_native_keys_agree(
987                    schema,
988                    query,
989                    source,
990                    &query_native,
991                    introspector,
992                    &mut warnings,
993                )
994                .await?;
995            }
996
997            // An inject key or argument with no column falls back to `data->>'key'` at
998            // runtime. If the JSON does not carry it either, the predicate is false for every
999            // row (#1382, #1394); if it does, the fallback is only slower.
1000            let mut unresolved: Vec<(QueryFilter, String)> = query
1001                .inject_params
1002                .keys()
1003                .filter(|param| !column_map.contains_key(*param))
1004                .map(|param| (QueryFilter::Inject(param.clone()), param.clone()))
1005                .collect();
1006            unresolved.extend(arg_fallbacks.into_iter().map(|arg| {
1007                let key = fraiseql_core::utils::to_snake_case(&arg);
1008                (QueryFilter::Argument(arg), key)
1009            }));
1010            // L2 (#1571): on a TVIEW, an index declared with it serves those keys.
1011            if !unresolved.is_empty() {
1012                if let Some(declaration) = introspector.tview_declaration(source).await? {
1013                    warnings.extend(tview_index_advice(query, &declaration, &unresolved));
1014                }
1015            }
1016            check_filters_resolvable(
1017                query,
1018                source,
1019                unresolved,
1020                &column_map,
1021                db_type,
1022                introspector,
1023                &mut warnings,
1024            )
1025            .await?;
1026
1027            // L2 (type-convertibility): a direct argument that resolved to a native
1028            // column whose SQL type cannot cleanly drive the predicate is a likely
1029            // authoring bug (e.g. an `Int` argument filtering a `uuid` column).
1030            for arg in &query.arguments {
1031                if let Some(native) = query_native.get(&arg.name) {
1032                    let col_type = &native.pg_type;
1033                    if !arg_type_convertible(&arg.arg_type, col_type) {
1034                        warnings.push(DatabaseWarning::TypeConvertibility {
1035                            query_name:   query.name.clone(),
1036                            sql_source:   source.clone(),
1037                            arg_name:     arg.name.clone(),
1038                            graphql_type: arg.arg_type.to_graphql_string(),
1039                            column_type:  col_type.clone(),
1040                        });
1041                    }
1042                }
1043            }
1044
1045            // L2 (#1571): a sorted or filtered temporal key read out of a document.
1046            if !jsonb_col.is_empty() && compares_document_keys(query) {
1047                let backing = if introspector.is_tview(source).await? {
1048                    TemporalBacking::Tview
1049                } else if relation_kind(&schema_qualified, &unqualified, source)
1050                    == Some(fraiseql_core::db::RelationKind::View)
1051                    || introspector.get_view_definition(source).await?.is_some()
1052                {
1053                    TemporalBacking::View
1054                } else {
1055                    TemporalBacking::Table
1056                };
1057                warnings.extend(temporal_keys_in_document(
1058                    schema,
1059                    query,
1060                    source,
1061                    &query_native,
1062                    &column_map,
1063                    backing,
1064                ));
1065            }
1066
1067            if !query_native.is_empty() {
1068                native_columns.insert(query.name.clone(), query_native);
1069            }
1070
1071            // L1: Check additional_views (relations, same resolution as sql_source).
1072            for view in &query.additional_views {
1073                if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
1074                    .await?
1075                {
1076                    warnings.push(DatabaseWarning::MissingAdditionalView {
1077                        query_name: query.name.clone(),
1078                        view_name:  view.clone(),
1079                    });
1080                }
1081            }
1082        }
1083    }
1084
1085    // Validate mutations (L1 only). A mutation's `sql_source` is a FUNCTION, not a
1086    // relation — probe `pg_proc`, not the relation catalog (#485). Mirrors the
1087    // shared `fraiseql_core::schema::sql_source_probes` Relation/Function split.
1088    for mutation in &schema.mutations {
1089        if let Some(ref source) = mutation.sql_source {
1090            if !function_source_exists(introspector, source).await? {
1091                warnings.push(DatabaseWarning::MissingFunction {
1092                    mutation_name: mutation.name.clone(),
1093                    sql_source:    source.clone(),
1094                });
1095            }
1096        }
1097    }
1098
1099    // Validate type sources (L1 only, warn-grade). A `@fraiseql.type`'s `sql_source` is a
1100    // view/table; a declared source that does not exist is usually an embedded value object
1101    // the SDK synthesized a source for (#653). Surface it at its origin — never failing —
1102    // instead of letting it resurface later as an unrelated cascade `id` error. Unlike
1103    // queries/mutations, type sources are not on the shared `sql_source_probes` work-list,
1104    // so the hard `FRAISEQL_VALIDATE_SQL_SOURCES` gate does not flag them; this is the only
1105    // check that does. Synthetic types (empty source) and error types are skipped.
1106    //
1107    // This set INTENTIONALLY DIVERGES from `is_queryable_entity` (the cascade classifier).
1108    // After #665 that predicate ALSO excludes framework-`internal` projections, but this
1109    // existence check must NOT: a missing `v_entity_change_log` / `v_transport_checkpoint`
1110    // is exactly #569's failure mode ("no install path; every mutation fails on a fresh
1111    // stack"), so validating the change-log views is precisely the point. Do NOT DRY the
1112    // two predicates together — the `internal` exemption belongs to cascade classification
1113    // only. The `validator_checks_internal_projection_view_existence` tripwire test pins
1114    // this divergence so a future "unification" cannot silently delete the #569 signal.
1115    for ty in &schema.types {
1116        let source = ty.sql_source.as_str();
1117        if ty.is_error || source.is_empty() {
1118            continue;
1119        }
1120        if !relation_source_exists(introspector, &schema_qualified, &unqualified, source).await? {
1121            warnings.push(DatabaseWarning::MissingTypeSource {
1122                type_name:  ty.name.to_string(),
1123                sql_source: source.to_string(),
1124            });
1125        }
1126    }
1127
1128    unify_tview_index_advice(&mut warnings);
1129
1130    Ok(DatabaseValidationReport {
1131        warnings,
1132        native_columns,
1133        pagination_orders,
1134    })
1135}
1136
1137/// Give every [`TviewIndexMissing`](DatabaseWarning::TviewIndexMissing) of one TVIEW the
1138/// options with every index advised for it (#1571): a replace's options are its whole
1139/// declaration, so applying one query's statement after another's would drop the first's
1140/// index. Each statement is then the same declaration, whichever is applied, in any order.
1141fn unify_tview_index_advice(warnings: &mut [DatabaseWarning]) {
1142    let mut by_tview: HashMap<String, serde_json::Value> = HashMap::new();
1143    for warning in warnings.iter() {
1144        let DatabaseWarning::TviewIndexMissing { tview, options, .. } = warning else {
1145            continue;
1146        };
1147        let advised = options.get("indexes").and_then(serde_json::Value::as_array);
1148        match by_tview.get_mut(tview) {
1149            None => {
1150                by_tview.insert(tview.clone(), options.clone());
1151            },
1152            Some(merged) => {
1153                let Some(indexes) = merged.get_mut("indexes").and_then(|i| i.as_array_mut()) else {
1154                    continue;
1155                };
1156                for index in advised.into_iter().flatten() {
1157                    if !indexes.contains(index) {
1158                        indexes.push(index.clone());
1159                    }
1160                }
1161            },
1162        }
1163    }
1164    for warning in warnings.iter_mut() {
1165        if let DatabaseWarning::TviewIndexMissing { tview, options, .. } = warning {
1166            if let Some(merged) = by_tview.get(tview) {
1167                options.clone_from(merged);
1168            }
1169        }
1170    }
1171}
1172
1173/// The `DateTime`, `Date` and `Time` fields `query` sorts or filters while reading them out of
1174/// `source`'s document, with no native column mapped for them (#1571).
1175///
1176/// Every relation: the cast is unindexable whatever reads it. The caller asks only for a query
1177/// that [`compares_document_keys`]: one that does not never compares the key. The remedy
1178/// depends on what
1179/// `source` is: a TVIEW (`is_tview`, from `pg_tviews`' catalog, never from a name), a view or a
1180/// table.
1181fn temporal_keys_in_document(
1182    schema: &CompiledSchema,
1183    query: &fraiseql_core::schema::QueryDefinition,
1184    source: &str,
1185    query_native: &HashMap<String, fraiseql_core::schema::NativeColumn>,
1186    column_map: &HashMap<String, String>,
1187    backing: TemporalBacking,
1188) -> Vec<DatabaseWarning> {
1189    let Some(ty) = schema.find_type(&query.return_type) else {
1190        return Vec::new();
1191    };
1192    ty.fields
1193        .iter()
1194        .filter(|f| matches!(f.field_type, FieldType::DateTime | FieldType::Date | FieldType::Time))
1195        .filter(|f| !query_native.contains_key(f.name.as_str()))
1196        .map(|f| {
1197            let column = fraiseql_core::utils::to_snake_case(f.name.as_str());
1198            let remedy = match backing {
1199                _ if column_map.contains_key(&column) => TemporalKeyRemedy::MapColumn,
1200                TemporalBacking::Tview => TemporalKeyRemedy::AddToTview,
1201                TemporalBacking::View => TemporalKeyRemedy::ExposeFromView,
1202                TemporalBacking::Table => TemporalKeyRemedy::AddToTable,
1203            };
1204            DatabaseWarning::TemporalKeyInDocument {
1205                query_name: query.name.clone(),
1206                sql_source: source.to_string(),
1207                field: f.name.to_string(),
1208                column,
1209                remedy,
1210            }
1211        })
1212        .collect()
1213}
1214
1215/// Whether `query` compares its document's keys: it exposes `orderBy` or `where`.
1216const fn compares_document_keys(query: &fraiseql_core::schema::QueryDefinition) -> bool {
1217    query.auto_params.has_order_by || query.auto_params.has_where
1218}
1219
1220/// What a query's `sql_source` is, for [`temporal_keys_in_document`]'s advice.
1221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1222enum TemporalBacking {
1223    /// A table `pg_tviews` maintains.
1224    Tview,
1225    /// A view.
1226    View,
1227    /// Any other table.
1228    Table,
1229}
1230
1231/// The kind of `source` among `relations`: by schema when it is qualified, else by the
1232/// first schema the search path lists it in.
1233fn relation_kind(
1234    schema_qualified: &HashMap<(String, String), RelationInfo>,
1235    unqualified: &HashMap<String, Vec<String>>,
1236    source: &str,
1237) -> Option<fraiseql_core::db::RelationKind> {
1238    let (schema, name) = match source.split_once('.') {
1239        Some((schema, name)) => (schema.to_string(), name),
1240        None => (unqualified.get(source)?.first()?.clone(), source),
1241    };
1242    schema_qualified.get(&(schema, name.to_string())).map(|r| r.kind.clone())
1243}
1244
1245/// The index of `declaration`'s TVIEW that `filters` (the document keys `query` reads with
1246/// `data->>'key'` on every call) need, unless one it declares leads with them (#1571).
1247///
1248/// Each key as FraiseQL compares it, which is how `pg_tviews` writes the index: an argument's
1249/// JSON number is compared as `numeric`, a boolean as `boolean`, anything else (an inject
1250/// parameter included) as text.
1251fn tview_index_advice(
1252    query: &fraiseql_core::schema::QueryDefinition,
1253    declaration: &fraiseql_db::TviewDeclaration,
1254    filters: &[(QueryFilter, String)],
1255) -> Option<DatabaseWarning> {
1256    if filters.is_empty() {
1257        return None;
1258    }
1259    let keys: Vec<serde_json::Value> = filters
1260        .iter()
1261        .map(|(filter, key)| {
1262            let arg_type = match filter {
1263                QueryFilter::Argument(name) => {
1264                    query.arguments.iter().find(|a| &a.name == name).map(|a| &a.arg_type)
1265                },
1266                QueryFilter::Inject(_) => None,
1267            };
1268            match arg_type {
1269                Some(FieldType::Int | FieldType::Float) => {
1270                    serde_json::json!({ "path": key, "type": "numeric" })
1271                },
1272                Some(FieldType::Boolean) => serde_json::json!({ "path": key, "type": "boolean" }),
1273                _ => serde_json::json!(key),
1274            }
1275        })
1276        .collect();
1277    let declared: Vec<serde_json::Value> = declaration
1278        .options
1279        .get("indexes")
1280        .and_then(serde_json::Value::as_array)
1281        .cloned()
1282        .unwrap_or_default();
1283    if declared.iter().any(|index| index_leads_with(index, &keys)) {
1284        return None;
1285    }
1286    let mut options = declaration.options.clone();
1287    let mut indexes = declared;
1288    indexes.push(serde_json::Value::Array(keys));
1289    options
1290        .as_object_mut()?
1291        .insert("indexes".to_string(), serde_json::Value::Array(indexes));
1292    Some(DatabaseWarning::TviewIndexMissing {
1293        query_name: query.name.clone(),
1294        tview: declaration.tview.clone(),
1295        keys: filters.iter().map(|(_, key)| key.clone()).collect(),
1296        options,
1297    })
1298}
1299
1300/// Whether the `pg_tviews` index `index` (an array of keys) opens with `keys`: a key is a
1301/// path, or `{"path", "type"}` with `text` the default type; its direction does not matter
1302/// to an equality.
1303fn index_leads_with(index: &serde_json::Value, keys: &[serde_json::Value]) -> bool {
1304    fn key(value: &serde_json::Value) -> Option<(&str, &str)> {
1305        match value {
1306            serde_json::Value::String(path) => Some((path, "text")),
1307            serde_json::Value::Object(key) => Some((
1308                key.get("path")?.as_str()?,
1309                key.get("type").and_then(serde_json::Value::as_str).unwrap_or("text"),
1310            )),
1311            _ => None,
1312        }
1313    }
1314    let Some(index) = index.as_array() else {
1315        return false;
1316    };
1317    index.len() >= keys.len()
1318        && index.iter().zip(keys).all(|(a, b)| key(a).is_some_and(|a| Some(a) == key(b)))
1319}
1320
1321/// The Trinity primary-key column a type's relation is expected to expose.
1322///
1323/// The same derivation `relay_cursor_column` uses (`User` → `pk_user`), and
1324/// deliberately so: when a relation carries it, both pagination families cut
1325/// their pages on the same column, which is ADR-0017's "one identity, consumed
1326/// uniformly, not re-derived per subsystem".
1327fn pk_column_name(return_type: &str) -> String {
1328    format!("pk_{}", fraiseql_core::utils::to_snake_case(return_type))
1329}
1330
1331/// Does this view body carry its own `ORDER BY`?
1332///
1333/// Deliberately lexical, and deliberately not exact. `pg_get_viewdef` returns
1334/// normalised SQL, so `ORDER BY` appears upper-cased and space-separated when it
1335/// appears at all — but it appears for a window frame and a subquery too, where a
1336/// page ordering on the outer read is entirely correct. The finding this drives is
1337/// therefore advisory and says what it saw; a stricter parser would trade a
1338/// question the author can answer in a second for a dependency on parsing SQL.
1339fn orders_itself(view_body: &str) -> bool {
1340    view_body.contains("ORDER BY")
1341}
1342
1343/// Build lookup maps from the list of relations.
1344fn build_relation_maps(
1345    relations: &[RelationInfo],
1346) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
1347    let mut schema_qualified = HashMap::new();
1348    let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
1349
1350    for rel in relations {
1351        schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
1352        unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
1353    }
1354
1355    (schema_qualified, unqualified)
1356}
1357
1358/// Find every declared `sql_source` that is **not backed** by a live database
1359/// object, over the shared [`sql_source_probes`] work-list (#487).
1360///
1361/// Resolution mirrors the runtime exactly: a query relation by `to_regclass`
1362/// (qualified, verbatim) or the search_path-scoped relation map (bare); a mutation
1363/// function via `pg_proc`. Returns the unbacked probes in declaration order — an
1364/// empty vec means every source is backed. This is the existence gate the
1365/// `validate --against-db` CLI and the opt-in server boot check share, so the two
1366/// agree on "backed" by construction.
1367///
1368/// When the connector cannot probe a kind (non-Postgres), that source is treated
1369/// as backed (skipped) rather than false-failed.
1370///
1371/// # Errors
1372///
1373/// Returns an error if database introspection fails.
1374pub async fn find_unbacked_sources(
1375    schema: &CompiledSchema,
1376    introspector: &impl DatabaseIntrospector,
1377) -> fraiseql_core::Result<Vec<SourceProbe>> {
1378    let relations = introspector.list_relations().await?;
1379    let (schema_qualified, unqualified) = build_relation_maps(&relations);
1380
1381    let mut unbacked = Vec::new();
1382    for probe in sql_source_probes(schema) {
1383        let exists = match probe.kind {
1384            SourceKind::Function => introspector
1385                .function_exists(probe.schema.as_deref(), &probe.name)
1386                .await?
1387                .unwrap_or(true),
1388            SourceKind::Relation => {
1389                if let Some(s) = &probe.schema {
1390                    match introspector.qualified_relation_exists(s, &probe.name).await? {
1391                        Some(exists) => exists,
1392                        None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
1393                    }
1394                } else {
1395                    unqualified.contains_key(&probe.name)
1396                }
1397            },
1398        };
1399        if !exists {
1400            unbacked.push(probe);
1401        }
1402    }
1403    Ok(unbacked)
1404}
1405
1406/// Validate JSON keys in sampled data for L3 checking.
1407async fn validate_json_keys(
1408    schema: &CompiledSchema,
1409    query: &fraiseql_core::schema::QueryDefinition,
1410    source: &str,
1411    jsonb_col: &str,
1412    introspector: &impl DatabaseIntrospector,
1413    table_name: &str,
1414    warnings: &mut Vec<DatabaseWarning>,
1415) -> fraiseql_core::Result<()> {
1416    let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
1417
1418    if samples.is_empty() {
1419        return Ok(());
1420    }
1421
1422    // Merge all top-level keys from sampled rows
1423    let mut all_keys = HashSet::new();
1424    for sample in &samples {
1425        if let serde_json::Value::Object(map) = sample {
1426            for key in map.keys() {
1427                all_keys.insert(key.clone());
1428            }
1429        }
1430    }
1431
1432    if all_keys.is_empty() {
1433        return Ok(());
1434    }
1435
1436    // Find the type definition for this query's return type
1437    let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
1438
1439    if let Some(type_def) = type_def {
1440        for field in &type_def.fields {
1441            let field_str = field.name.as_str();
1442            // Use the canonical acronym/digit-aware caser the runtime queries with
1443            // (`httpResponse` → `http_response`, not `h_t_t_p_response`), so the
1444            // L3 JSON-key check never cries wolf on acronym/digit fields (#485).
1445            let json_key = fraiseql_core::utils::to_snake_case(field_str);
1446            // Skip fields that are top-level columns (not from JSONB)
1447            // Convention: fields like "id", "pk_*", "fk_*" are columns, not JSON keys
1448            if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
1449                continue;
1450            }
1451            if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
1452                warnings.push(DatabaseWarning::MissingJsonKey {
1453                    query_name: query.name.clone(),
1454                    type_name: type_def.name.as_str().to_string(),
1455                    sql_source: source.to_string(),
1456                    json_column: jsonb_col.to_string(),
1457                    field_name: field_str.to_string(),
1458                    json_key,
1459                    field_required: !field.nullable,
1460                });
1461            }
1462        }
1463    }
1464
1465    Ok(())
1466}
1467
1468/// Enum dispatch for database introspectors.
1469///
1470/// Uses enum dispatch instead of `Box<dyn DatabaseIntrospector>` because the
1471/// trait uses `async_fn_in_trait` and cannot be object-safe.
1472pub enum AnyIntrospector {
1473    /// `PostgreSQL` introspector.
1474    Postgres(fraiseql_core::db::PostgresIntrospector),
1475}
1476
1477impl DatabaseIntrospector for AnyIntrospector {
1478    async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
1479        match self {
1480            Self::Postgres(i) => i.list_fact_tables().await,
1481        }
1482    }
1483
1484    async fn get_columns(
1485        &self,
1486        table_name: &str,
1487    ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
1488        match self {
1489            Self::Postgres(i) => i.get_columns(table_name).await,
1490        }
1491    }
1492
1493    async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
1494        match self {
1495            Self::Postgres(i) => i.get_indexed_columns(table_name).await,
1496        }
1497    }
1498
1499    fn database_type(&self) -> DatabaseType {
1500        match self {
1501            Self::Postgres(i) => i.database_type(),
1502        }
1503    }
1504
1505    async fn get_sample_jsonb(
1506        &self,
1507        table_name: &str,
1508        column_name: &str,
1509    ) -> fraiseql_core::Result<Option<serde_json::Value>> {
1510        match self {
1511            Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
1512        }
1513    }
1514
1515    async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
1516        match self {
1517            Self::Postgres(i) => i.list_relations().await,
1518        }
1519    }
1520
1521    async fn get_sample_json_rows(
1522        &self,
1523        table_name: &str,
1524        column_name: &str,
1525        limit: usize,
1526    ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
1527        match self {
1528            Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
1529        }
1530    }
1531
1532    async fn column_key_disagreements(
1533        &self,
1534        relation: &str,
1535        column: &str,
1536        json_column: &str,
1537        key: &str,
1538        limit: usize,
1539    ) -> fraiseql_core::Result<Option<(u64, u64)>> {
1540        match self {
1541            Self::Postgres(i) => {
1542                i.column_key_disagreements(relation, column, json_column, key, limit).await
1543            },
1544        }
1545    }
1546
1547    async fn function_exists(
1548        &self,
1549        schema: Option<&str>,
1550        name: &str,
1551    ) -> fraiseql_core::Result<Option<bool>> {
1552        // Must delegate (not inherit the trait default) so the Postgres impl is
1553        // actually used — otherwise every variant would return `None`.
1554        match self {
1555            Self::Postgres(i) => i.function_exists(schema, name).await,
1556        }
1557    }
1558
1559    async fn qualified_relation_exists(
1560        &self,
1561        schema: &str,
1562        name: &str,
1563    ) -> fraiseql_core::Result<Option<bool>> {
1564        match self {
1565            Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
1566        }
1567    }
1568
1569    async fn get_view_definition(&self, relation: &str) -> fraiseql_core::Result<Option<String>> {
1570        match self {
1571            Self::Postgres(i) => i.get_view_definition(relation).await,
1572        }
1573    }
1574
1575    async fn tview_declaration(
1576        &self,
1577        relation: &str,
1578    ) -> fraiseql_core::Result<Option<fraiseql_db::TviewDeclaration>> {
1579        match self {
1580            Self::Postgres(i) => i.tview_declaration(relation).await,
1581        }
1582    }
1583
1584    async fn is_tview(&self, relation: &str) -> fraiseql_core::Result<bool> {
1585        match self {
1586            Self::Postgres(i) => i.is_tview(relation).await,
1587        }
1588    }
1589}
1590
1591/// Refuse a database URL that targets an engine whose support was removed (#374).
1592///
1593/// The first half of [`crate::connection::require_postgres`], the one rule for every
1594/// command that takes a database URL, run before anything tries to connect. Without it
1595/// `compile --database sqlite://…` failed inside the connection pool with "invalid
1596/// connection string".
1597///
1598/// # Errors
1599///
1600/// Returns an error naming the PostgreSQL-only rule for a MySQL / MariaDB / SQLite /
1601/// SQL Server URL or a SQLite database file path.
1602pub fn refuse_removed_engine_url(db_url: &str) -> anyhow::Result<()> {
1603    let removed_engine = db_url.starts_with("mysql")
1604        || db_url.starts_with("mariadb")
1605        || db_url.starts_with("sqlite")
1606        || db_url.starts_with("mssql")
1607        || db_url.starts_with("server=")
1608        || std::path::Path::new(db_url).extension().is_some_and(|ext| {
1609            ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite")
1610        });
1611    if removed_engine {
1612        anyhow::bail!(
1613            "This database URL targets an engine whose support was removed: FraiseQL is \
1614             PostgreSQL-only (G2 decision on #374; the non-PostgreSQL dialects were never \
1615             production-correct, see #721/#799). Use a postgresql:// URL."
1616        );
1617    }
1618    Ok(())
1619}
1620
1621/// Create an introspector from a database URL.
1622///
1623/// # Errors
1624///
1625/// Returns error if the URL is not a PostgreSQL connection string (see
1626/// [`crate::connection::require_postgres`]), the server cannot be reached, or it is
1627/// older than PostgreSQL 18.
1628pub async fn create_introspector(
1629    db_url: &str,
1630    tls: &PostgresTlsConfig,
1631) -> anyhow::Result<AnyIntrospector> {
1632    let pool = crate::connection::postgres_pool(db_url, "schema introspection", tls).await?;
1633    Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
1634}
1635
1636/// Report every query filter the relation cannot provide (#1382, #1394).
1637///
1638/// `unresolved` holds the filters that named no column, each with the JSON key the runtime
1639/// reads for it. That key is only sound if the JSON carries it: sampled rows that all lack
1640/// it are evidence it never does, and the filter is an error. One the JSON does carry is
1641/// only a slower path — an argument there keeps its `NativeColumnFallback` advisory. An
1642/// empty relation proves nothing and is reported as advisory.
1643async fn check_filters_resolvable(
1644    query: &fraiseql_core::schema::QueryDefinition,
1645    source: &str,
1646    unresolved: Vec<(QueryFilter, String)>,
1647    column_map: &HashMap<String, String>,
1648    db_type: DatabaseType,
1649    introspector: &impl DatabaseIntrospector,
1650    warnings: &mut Vec<DatabaseWarning>,
1651) -> fraiseql_core::Result<()> {
1652    if unresolved.is_empty() {
1653        return Ok(());
1654    }
1655    let jsonb_col = &query.jsonb_column;
1656    let json_ok = !jsonb_col.is_empty()
1657        && column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
1658    let carried: Option<HashSet<String>> = if json_ok {
1659        let samples =
1660            introspector.get_sample_json_rows(source, jsonb_col, INJECT_SAMPLE_ROWS).await?;
1661        if samples.is_empty() {
1662            None
1663        } else {
1664            Some(
1665                samples
1666                    .iter()
1667                    .filter_map(serde_json::Value::as_object)
1668                    .flat_map(|row| row.keys().cloned())
1669                    .collect(),
1670            )
1671        }
1672    } else {
1673        None
1674    };
1675    for (filter, key) in unresolved {
1676        let reason = match (&carried, json_ok) {
1677            (_, false) => InjectMiss::NoJsonColumn,
1678            (None, true) => InjectMiss::EmptyRelation {
1679                json_column: jsonb_col.clone(),
1680            },
1681            (Some(keys), true) if !keys.contains(&key) => InjectMiss::NotInSampledRows {
1682                json_column: jsonb_col.clone(),
1683            },
1684            (Some(_), true) => {
1685                if let QueryFilter::Argument(arg_name) = filter {
1686                    warnings.push(DatabaseWarning::NativeColumnFallback {
1687                        query_name: query.name.clone(),
1688                        sql_source: source.to_string(),
1689                        arg_name,
1690                    });
1691                }
1692                continue;
1693            },
1694        };
1695        warnings.push(DatabaseWarning::UnresolvableFilter {
1696            query_name: query.name.clone(),
1697            sql_source: source.to_string(),
1698            filter,
1699            key,
1700            reason,
1701        });
1702    }
1703    Ok(())
1704}
1705
1706/// Report every `inject_params` column of `query` that is a native column of `source`, is a
1707/// key its type publishes, and holds another value than the document's key in a sampled row
1708/// (#1579). The nested row gates read such a key off an embedded document; the root read
1709/// reads the column.
1710async fn check_native_keys_agree(
1711    schema: &fraiseql_core::schema::CompiledSchema,
1712    query: &fraiseql_core::schema::QueryDefinition,
1713    source: &str,
1714    query_native: &HashMap<String, fraiseql_core::schema::NativeColumn>,
1715    introspector: &impl DatabaseIntrospector,
1716    warnings: &mut Vec<DatabaseWarning>,
1717) -> fraiseql_core::Result<()> {
1718    let Some(declared) = schema.find_type(&query.return_type) else {
1719        return Ok(());
1720    };
1721    for column in query.inject_params.keys().filter(|c| query_native.contains_key(*c)) {
1722        let key = fraiseql_core::utils::to_snake_case(column);
1723        let published = declared
1724            .fields
1725            .iter()
1726            .any(|f| fraiseql_core::utils::to_snake_case(f.name.as_str()) == key);
1727        if !published {
1728            continue;
1729        }
1730        let compared = introspector
1731            .column_key_disagreements(
1732                source,
1733                column,
1734                &query.jsonb_column,
1735                &key,
1736                AGREEMENT_SAMPLE_ROWS,
1737            )
1738            .await?;
1739        if let Some((sampled, disagreeing)) = compared.filter(|(_, d)| *d > 0) {
1740            warnings.push(DatabaseWarning::NativeKeyDisagreement {
1741                query_name: query.name.clone(),
1742                sql_source: source.to_string(),
1743                column: column.clone(),
1744                json_column: query.jsonb_column.clone(),
1745                sampled,
1746                disagreeing,
1747            });
1748        }
1749    }
1750    Ok(())
1751}
1752
1753/// Rows sampled to check a native column against its document key (#1579).
1754const AGREEMENT_SAMPLE_ROWS: usize = 1000;
1755
1756/// Rows sampled to decide whether a filter key lives in the JSON column.
1757const INJECT_SAMPLE_ROWS: usize = 20;
1758
1759/// Build a query's native-column map from its explicit (non-auto-param) arguments
1760/// and its inject-param names, consulting the introspected `column_map`.
1761///
1762/// A name that matches a real view column is rendered as a native-column predicate
1763/// (`WHERE col = $N`) at runtime; a name with no matching column falls back to the
1764/// JSONB path (`data->>'name'`). inject params were previously omitted here, so an
1765/// inject-scoped list query against a view that keeps e.g. `tenant_id` as a real
1766/// column (and not inside `data`) rendered `data->>'tenant_id'` and matched no rows.
1767///
1768/// Returns the native-column map plus the explicit-arg names that did NOT resolve to
1769/// a column — the caller emits a `NativeColumnFallback` warning for each. Inject-param
1770/// misses are intentionally silent: a claim may legitimately live in the `data` JSONB.
1771fn detect_query_native_columns<'a>(
1772    direct_arg_names: &[&str],
1773    inject_param_names: impl Iterator<Item = &'a str>,
1774    column_map: &HashMap<String, String>,
1775    not_null: &std::collections::HashSet<String>,
1776) -> (HashMap<String, fraiseql_core::schema::NativeColumn>, Vec<String>) {
1777    let native_column = |column: &str, col_type: &String| fraiseql_core::schema::NativeColumn {
1778        pg_type:  col_type.clone(),
1779        not_null: not_null.contains(column),
1780    };
1781    let mut native: HashMap<String, fraiseql_core::schema::NativeColumn> = HashMap::new();
1782    let mut arg_fallbacks: Vec<String> = Vec::new();
1783
1784    for arg in direct_arg_names {
1785        // The runtime filters on the snake_case form of the argument's GraphQL name
1786        // (`combine_explicit_arg_where`, #486), so that is the column to look up (#1394):
1787        // `customerId` must find `customer_id`, never miss it and fall back to a key that
1788        // the view's `data` does not carry.
1789        let column = fraiseql_core::utils::to_snake_case(arg);
1790        if let Some(col_type) = column_map.get(&column) {
1791            native.insert((*arg).to_string(), native_column(&column, col_type));
1792        } else {
1793            arg_fallbacks.push((*arg).to_string());
1794        }
1795    }
1796
1797    for name in inject_param_names {
1798        if let Some(col_type) = column_map.get(name) {
1799            native.insert(name.to_string(), native_column(name, col_type));
1800        }
1801    }
1802
1803    (native, arg_fallbacks)
1804}
1805
1806#[cfg(test)]
1807#[path = "database_validator_tests.rs"]
1808mod database_validator_tests;