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