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