Skip to main content

fraiseql_cli/schema/mutation_contract/
mod.rs

1//! Static server↔database mutation-contract validation (#397).
2//!
3//! For each DB-backed mutation in a compiled schema, this checks that the
4//! PostgreSQL function the server *will* call matches what the server *will*
5//! send and decode — without booting a server or invoking any mutation:
6//!
7//! - **Call binding** — `sql_source` resolves to exactly one function whose *input* arity equals
8//!   what the runtime sends (the positional args plus trailing injected params), the jsonb payload
9//!   parameter (update path) is actually `jsonb`, and the trailing parameter names match the inject
10//!   keys.
11//! - **Response shape** — the function's result row carries `succeeded` and `state_changed` (both
12//!   `boolean`, required by the `MutationResponse` decoder) and the optional columns it does
13//!   declare have compatible types.
14//!
15//! The arity/shape logic ([`expected_call`]) mirrors the runtime arg-building in
16//! `fraiseql-core`'s mutation runner exactly; [`check_mutation`] is a pure
17//! comparison against catalog facts so it is unit-tested without a database.
18//!
19//! Out of scope (deliberate): the *behavioural* response invariants
20//! (`succeeded ⇒ error_class IS NULL`, `http_status ∈ 100..=599`, …) are
21//! properties of the function's runtime output, only observable by invoking it —
22//! which would have database side effects. This check stays static and
23//! read-only.
24
25use std::fmt;
26
27use anyhow::Result;
28use fraiseql_core::schema::{
29    CompiledSchema, FieldType, InputStyle, MutationDefinition, MutationOperation,
30};
31
32use crate::schema::pg_catalog::{PgCatalog, PgFunction};
33
34#[cfg(test)]
35mod tests;
36
37/// How the runtime lays out the positional arguments for a mutation's SQL call.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CallShape {
40    /// Update with a single `input` object → one `jsonb` payload argument.
41    JsonbPayload,
42    /// Insert/Delete/Custom with a single `input` object whose type is in the
43    /// schema → one positional argument per input field.
44    FlattenedFields,
45    /// Flat arguments → one positional argument per declared mutation argument.
46    FlatArgs,
47}
48
49/// What the runtime will send to a mutation's `sql_source`, derived purely from
50/// the compiled schema.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ExpectedCall {
53    /// Resolved function name (the `sql_source`, or the operation's table).
54    pub sql_source:             String,
55    /// Argument-layout shape (diagnostics only).
56    pub shape:                  CallShape,
57    /// Number of positional arguments before injected params.
58    pub base_arity:             usize,
59    /// Inject-param keys, in call order — appended after the base args.
60    pub inject_names:           Vec<String>,
61    /// Whether the first argument is the jsonb payload (update path).
62    pub first_is_jsonb_payload: bool,
63}
64
65impl ExpectedCall {
66    /// Total positional arity the server binds: base args plus inject params.
67    #[must_use]
68    pub fn total_arity(&self) -> usize {
69        self.base_arity + self.inject_names.len()
70    }
71}
72
73/// Severity of a [`ContractViolation`].
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Severity {
76    /// Breaks the contract — the server would fail at runtime. Fails the check.
77    Error,
78    /// Likely a bug, but not a guaranteed failure. Does not fail the check.
79    Warn,
80}
81
82/// A single mismatch between a mutation's compiled contract and the live
83/// database.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ContractViolation {
86    /// No function of that name is visible on the search path.
87    MissingFunction,
88    /// Function(s) exist but none has the expected input arity.
89    ArityMismatch {
90        /// Arity the server will send.
91        expected: usize,
92        /// Distinct input arities of the existing overloads.
93        found:    Vec<usize>,
94    },
95    /// Multiple overloads share the expected arity — the untyped positional call
96    /// is ambiguous (`function is not unique`).
97    AmbiguousFunction {
98        /// The shared arity.
99        arity: usize,
100        /// How many overloads match it.
101        count: usize,
102    },
103    /// The update path sends a jsonb payload but the first parameter is not jsonb.
104    PayloadNotJsonb {
105        /// The actual first-parameter type.
106        actual: String,
107    },
108    /// A trailing parameter name does not match the inject key bound to it.
109    InjectNameMismatch {
110        /// Inject-key position (0-based, among inject params).
111        position: usize,
112        /// Inject key the server binds here.
113        expected: String,
114        /// The function's actual parameter name at that position.
115        actual:   String,
116    },
117    /// A required response column (`succeeded` / `state_changed`) is absent.
118    MissingRequiredColumn {
119        /// The missing column.
120        column: &'static str,
121    },
122    /// A required response column has the wrong type (must be `boolean`).
123    RequiredColumnWrongType {
124        /// The column.
125        column: &'static str,
126        /// Its actual type.
127        actual: String,
128    },
129    /// An optional response column is present but has an incompatible type.
130    OptionalColumnWrongType {
131        /// The column.
132        column:   &'static str,
133        /// The expected type family.
134        expected: &'static str,
135        /// Its actual type.
136        actual:   String,
137    },
138    /// The function returns a scalar / bare `record` — its response shape cannot
139    /// be introspected.
140    ResponseShapeUnverifiable,
141}
142
143impl ContractViolation {
144    /// Severity of this violation.
145    #[must_use]
146    pub const fn severity(&self) -> Severity {
147        match self {
148            Self::MissingFunction
149            | Self::ArityMismatch { .. }
150            | Self::AmbiguousFunction { .. }
151            | Self::PayloadNotJsonb { .. }
152            | Self::MissingRequiredColumn { .. }
153            | Self::RequiredColumnWrongType { .. } => Severity::Error,
154            Self::InjectNameMismatch { .. }
155            | Self::OptionalColumnWrongType { .. }
156            | Self::ResponseShapeUnverifiable => Severity::Warn,
157        }
158    }
159}
160
161impl fmt::Display for ContractViolation {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            Self::MissingFunction => {
165                write!(f, "no such function on the search path (function does not exist)")
166            },
167            Self::ArityMismatch { expected, found } => {
168                write!(f, "expected {expected} argument(s) but the function takes {found:?}")
169            },
170            Self::AmbiguousFunction { arity, count } => write!(
171                f,
172                "{count} overloads take {arity} argument(s) — the call is ambiguous (function is not unique)"
173            ),
174            Self::PayloadNotJsonb { actual } => write!(
175                f,
176                "update payload argument must be jsonb but the first parameter is `{actual}`"
177            ),
178            Self::InjectNameMismatch {
179                position,
180                expected,
181                actual,
182            } => write!(
183                f,
184                "inject param #{position} is bound positionally to parameter `{actual}` but the inject key is `{expected}`"
185            ),
186            Self::MissingRequiredColumn { column } => write!(
187                f,
188                "response row is missing required column `{column}` (the server cannot decode MutationResponse)"
189            ),
190            Self::RequiredColumnWrongType { column, actual } => {
191                write!(f, "response column `{column}` is `{actual}`, expected boolean")
192            },
193            Self::OptionalColumnWrongType {
194                column,
195                expected,
196                actual,
197            } => write!(f, "response column `{column}` is `{actual}`, expected {expected}"),
198            Self::ResponseShapeUnverifiable => {
199                write!(f, "function returns a scalar/record — response shape cannot be verified")
200            },
201        }
202    }
203}
204
205/// Derive what the runtime will send for `mutation`.
206///
207/// Returns `None` when the mutation is not database-backed (no `sql_source` and
208/// no operation table — e.g. a federation/non-SQL mutation) and should be
209/// skipped.
210///
211/// This mirrors the runtime arg-building in
212/// `fraiseql-core/.../runners/mutation/mod.rs` exactly. A single structured
213/// `input` arg is forwarded as ONE jsonb payload when the operation is `Update`,
214/// the mutation opts in via `input_style = jsonb`, **or** the input type is not
215/// in the schema (see `pass_as_single_jsonb`, pinned to `mutation/mod.rs:499-500`).
216/// Otherwise a known input type flattens to one positional arg per field;
217/// everything else is flat args.
218#[must_use]
219pub fn expected_call(
220    mutation: &MutationDefinition,
221    schema: &CompiledSchema,
222) -> Option<ExpectedCall> {
223    let sql_source = resolve_sql_source(mutation)?;
224
225    let input_type_name = single_input_type_name(mutation);
226
227    let (shape, base_arity, first_is_jsonb_payload) = if pass_as_single_jsonb(mutation, schema) {
228        // Single-JSONB path: Update, `input_style = jsonb`, or unknown input type
229        // → one jsonb payload arg. `first_is_jsonb_payload` enables the arg-1-is-
230        // jsonb assertion in `check_mutation` for every such case.
231        (CallShape::JsonbPayload, 1, true)
232    } else if let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) {
233        // Insert/Delete/Custom + single input object found in schema → flatten fields.
234        (CallShape::FlattenedFields, input_type.fields.len(), false)
235    } else {
236        // Flat args (a single non-Input `input`, or multiple scalar args).
237        (CallShape::FlatArgs, mutation.arguments.len(), false)
238    };
239
240    Some(ExpectedCall {
241        sql_source,
242        shape,
243        base_arity,
244        inject_names: mutation.inject_params.keys().cloned().collect(),
245        first_is_jsonb_payload,
246    })
247}
248
249/// The name of a single `input` argument typed as an Input object, else `None`.
250///
251/// This is the structured-input form the compiled `input` arg carries
252/// (`FieldType::Input`); it mirrors the runtime's `input_type_name`
253/// (`mutation/mod.rs:444-456`) scoped to that form.
254fn single_input_type_name(mutation: &MutationDefinition) -> Option<&str> {
255    if mutation.arguments.len() == 1 && mutation.arguments[0].name == "input" {
256        match &mutation.arguments[0].arg_type {
257            FieldType::Input(name) => Some(name.as_str()),
258            _ => None,
259        }
260    } else {
261        None
262    }
263}
264
265/// Faithful mirror of the runtime single-JSONB predicate
266/// (`fraiseql-core/.../runners/mutation/mod.rs:499-500`):
267/// ```text
268/// pass_input_as_single_jsonb =
269///     input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type)
270/// ```
271/// A structured single `input` arg is forwarded as ONE jsonb payload when the
272/// operation is `Update`, the mutation opts in via `input_style = jsonb`, or the
273/// input type is not in the compiled schema. Keep this in sync with that line.
274fn pass_as_single_jsonb(mutation: &MutationDefinition, schema: &CompiledSchema) -> bool {
275    let input_type_name = single_input_type_name(mutation);
276    let input_arg_is_structured = input_type_name.is_some();
277    let is_update = matches!(&mutation.operation, MutationOperation::Update { .. });
278    let jsonb_input_style = matches!(mutation.input_style, InputStyle::Jsonb);
279    let known_input_type = input_type_name.and_then(|n| schema.find_input_type(n)).is_some();
280    input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type)
281}
282
283/// Resolve a mutation's SQL function name: `sql_source`, else the operation's
284/// non-empty table, else `None` (not DB-backed).
285fn resolve_sql_source(mutation: &MutationDefinition) -> Option<String> {
286    if let Some(src) = &mutation.sql_source {
287        return Some(src.clone());
288    }
289    match &mutation.operation {
290        MutationOperation::Insert { table }
291        | MutationOperation::Update { table }
292        | MutationOperation::Delete { table }
293            if !table.is_empty() =>
294        {
295            Some(table.clone())
296        },
297        _ => None,
298    }
299}
300
301/// Compare an [`ExpectedCall`] against the candidate functions resolved from the
302/// database. Pure — no I/O.
303#[must_use]
304pub fn check_mutation(
305    expected: &ExpectedCall,
306    candidates: &[PgFunction],
307) -> Vec<ContractViolation> {
308    let mut violations = Vec::new();
309
310    if candidates.is_empty() {
311        violations.push(ContractViolation::MissingFunction);
312        return violations;
313    }
314
315    let want = expected.total_arity();
316    let matched: Vec<&PgFunction> =
317        candidates.iter().filter(|f| f.in_types.len() == want).collect();
318
319    let func = match matched.as_slice() {
320        [] => {
321            let mut found: Vec<usize> = candidates.iter().map(|f| f.in_types.len()).collect();
322            found.sort_unstable();
323            found.dedup();
324            violations.push(ContractViolation::ArityMismatch {
325                expected: want,
326                found,
327            });
328            return violations;
329        },
330        [one] => *one,
331        many => {
332            violations.push(ContractViolation::AmbiguousFunction {
333                arity: want,
334                count: many.len(),
335            });
336            return violations;
337        },
338    };
339
340    // Call binding: the update path's first parameter must be jsonb.
341    if expected.first_is_jsonb_payload {
342        if let Some(first) = func.in_types.first() {
343            if !is_jsonb(first) {
344                violations.push(ContractViolation::PayloadNotJsonb {
345                    actual: first.clone(),
346                });
347            }
348        }
349    }
350
351    // Call binding: trailing parameter names should match the inject keys, in
352    // order. Advisory — the runtime binds positionally — and only checkable when
353    // the function declares parameter names.
354    if !expected.inject_names.is_empty() {
355        let start = func.in_types.len().saturating_sub(expected.inject_names.len());
356        for (position, want_name) in expected.inject_names.iter().enumerate() {
357            if let Some(Some(actual)) = func.in_names.get(start + position) {
358                if actual != want_name {
359                    violations.push(ContractViolation::InjectNameMismatch {
360                        position,
361                        expected: want_name.clone(),
362                        actual: actual.clone(),
363                    });
364                }
365            }
366        }
367    }
368
369    check_response_shape(func, &mut violations);
370    violations
371}
372
373/// Optional response columns and their expected type family.
374const OPTIONAL_COLUMNS: &[(&str, &str)] = &[
375    ("error_class", "text or enum"),
376    ("status_detail", "text"),
377    ("http_status", "an integer type"),
378    ("message", "text"),
379    ("entity_id", "uuid"),
380    ("entity_type", "text"),
381    ("entity", "jsonb"),
382    ("updated_fields", "a text array"),
383    ("cascade", "jsonb"),
384    ("error_detail", "jsonb"),
385    ("metadata", "jsonb"),
386];
387
388/// Validate the function's result row against the `MutationResponse` decoder:
389/// `succeeded` + `state_changed` are required booleans; present optional columns
390/// must have compatible types.
391fn check_response_shape(func: &PgFunction, violations: &mut Vec<ContractViolation>) {
392    if func.out_columns.is_empty() {
393        violations.push(ContractViolation::ResponseShapeUnverifiable);
394        return;
395    }
396    let find = |name: &str| func.out_columns.iter().find(|c| c.name == name);
397
398    for column in ["succeeded", "state_changed"] {
399        match find(column) {
400            None => violations.push(ContractViolation::MissingRequiredColumn { column }),
401            Some(c) if !is_bool(&c.type_name) => {
402                violations.push(ContractViolation::RequiredColumnWrongType {
403                    column,
404                    actual: c.type_name.clone(),
405                });
406            },
407            Some(_) => {},
408        }
409    }
410
411    for &(column, expected) in OPTIONAL_COLUMNS {
412        if let Some(c) = find(column) {
413            if !optional_column_ok(column, c.is_enum, &c.type_name) {
414                violations.push(ContractViolation::OptionalColumnWrongType {
415                    column,
416                    expected,
417                    actual: c.type_name.clone(),
418                });
419            }
420        }
421    }
422}
423
424/// Whether an optional response column's type is compatible with the decoder.
425fn optional_column_ok(column: &str, is_enum: bool, type_name: &str) -> bool {
426    match column {
427        // `error_class` decodes from text or a project enum.
428        "error_class" => is_enum || is_text(type_name),
429        "status_detail" | "message" | "entity_type" => is_text(type_name),
430        "http_status" => is_int(type_name),
431        "entity_id" => type_name == "uuid",
432        "updated_fields" => type_name.ends_with("[]"),
433        "entity" | "cascade" | "error_detail" | "metadata" => is_jsonb(type_name),
434        _ => true,
435    }
436}
437
438fn is_bool(type_name: &str) -> bool {
439    type_name == "boolean"
440}
441
442fn is_jsonb(type_name: &str) -> bool {
443    type_name == "jsonb" || type_name == "json"
444}
445
446fn is_int(type_name: &str) -> bool {
447    matches!(type_name, "smallint" | "integer" | "bigint")
448}
449
450fn is_text(type_name: &str) -> bool {
451    matches!(type_name, "text" | "varchar" | "name" | "bpchar" | "citext")
452        || type_name.starts_with("character varying")
453        || type_name.starts_with("character(")
454        || type_name == "character"
455}
456
457// ─── Report ─────────────────────────────────────────────────────────────────
458
459/// Per-mutation contract findings.
460#[derive(Debug, Clone)]
461pub struct MutationReport {
462    /// GraphQL mutation name.
463    pub mutation:   String,
464    /// Resolved `sql_source` checked.
465    pub sql_source: String,
466    /// Violations found (empty entries are not stored in the report).
467    pub violations: Vec<ContractViolation>,
468}
469
470/// Aggregate result of validating every mutation's contract.
471#[derive(Debug, Clone, Default)]
472pub struct ContractReport {
473    /// DB-backed mutations checked.
474    pub checked:   usize,
475    /// Non-DB-backed mutations skipped.
476    pub skipped:   usize,
477    /// Mutations with at least one violation.
478    pub mutations: Vec<MutationReport>,
479}
480
481impl ContractReport {
482    /// Total error-severity violations across all mutations.
483    #[must_use]
484    pub fn error_count(&self) -> usize {
485        self.count(Severity::Error)
486    }
487
488    /// Total warning-severity violations across all mutations.
489    #[must_use]
490    pub fn warn_count(&self) -> usize {
491        self.count(Severity::Warn)
492    }
493
494    fn count(&self, severity: Severity) -> usize {
495        self.mutations
496            .iter()
497            .flat_map(|m| &m.violations)
498            .filter(|v| v.severity() == severity)
499            .count()
500    }
501
502    /// Render the report as machine-readable JSON.
503    #[must_use]
504    pub fn to_json(&self) -> serde_json::Value {
505        let mutations: Vec<serde_json::Value> = self
506            .mutations
507            .iter()
508            .map(|m| {
509                let findings: Vec<serde_json::Value> = m
510                    .violations
511                    .iter()
512                    .map(|v| {
513                        serde_json::json!({
514                            "severity": match v.severity() {
515                                Severity::Error => "error",
516                                Severity::Warn => "warning",
517                            },
518                            "message": v.to_string(),
519                        })
520                    })
521                    .collect();
522                serde_json::json!({
523                    "mutation": m.mutation,
524                    "sqlSource": m.sql_source,
525                    "findings": findings,
526                })
527            })
528            .collect();
529        serde_json::json!({
530            "checked": self.checked,
531            "skipped": self.skipped,
532            "errors": self.error_count(),
533            "warnings": self.warn_count(),
534            "mutations": mutations,
535        })
536    }
537
538    /// Print the report in human-readable form to stdout.
539    pub fn print_text(&self) {
540        println!("\nChecking mutation contract against the database...\n");
541        if self.mutations.is_empty() {
542            println!(
543                "  All {} DB-backed mutation(s) match the database contract ({} skipped).",
544                self.checked, self.skipped
545            );
546            return;
547        }
548        for m in &self.mutations {
549            println!("  {} (sql_source: {})", m.mutation, m.sql_source);
550            for v in &m.violations {
551                let symbol = match v.severity() {
552                    Severity::Error => "✗",
553                    Severity::Warn => "!",
554                };
555                println!("    [{symbol}] {v}");
556            }
557        }
558        println!(
559            "\nSummary: {} error(s), {} warning(s) across {} checked mutation(s) ({} skipped).",
560            self.error_count(),
561            self.warn_count(),
562            self.checked,
563            self.skipped,
564        );
565    }
566}
567
568/// Validate every DB-backed mutation's contract in `schema` against `catalog`.
569///
570/// # Errors
571///
572/// Returns an error if any catalog query fails.
573pub async fn validate_mutation_contract(
574    schema: &CompiledSchema,
575    catalog: &PgCatalog,
576) -> Result<ContractReport> {
577    let mut report = ContractReport::default();
578    for mutation in &schema.mutations {
579        let Some(expected) = expected_call(mutation, schema) else {
580            report.skipped += 1;
581            continue;
582        };
583        report.checked += 1;
584        let candidates = catalog.resolve_functions(&expected.sql_source).await?;
585        let violations = check_mutation(&expected, &candidates);
586        if !violations.is_empty() {
587            report.mutations.push(MutationReport {
588                mutation: mutation.name.clone(),
589                sql_source: expected.sql_source.clone(),
590                violations,
591            });
592        }
593    }
594    Ok(report)
595}