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