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