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//! - **Literal stamps** — a literal `entity_type` the body stamps (`fraiseql.mutation_ok(…,
20//!   p_entity_type => 'T')`, `fraiseql.mutation_err(…, 'T')`, `result.entity_type := 'T'`) must be
21//!   a type the mutation can return on that outcome ([`StampContract`], the runtime's own
22//!   derivation): the server refuses any other as a contract error (rulings AA 1, AG 3, AJ 2).
23//!
24//! Out of scope (deliberate): the *behavioural* response invariants
25//! (`succeeded ⇒ error_class IS NULL`, `http_status ∈ 100..=599`, …) are
26//! properties of the function's runtime output, only observable by invoking it —
27//! which would have database side effects. This check stays static and
28//! read-only.
29
30use std::{fmt, sync::LazyLock};
31
32use anyhow::Result;
33use fraiseql_core::{
34    runtime::StampContract,
35    schema::{CompiledSchema, FieldType, InputStyle, MutationDefinition, MutationOperation},
36};
37use regex::Regex;
38
39use crate::schema::pg_catalog::{PgCatalog, PgFunction};
40
41#[cfg(test)]
42mod tests;
43
44/// How the runtime lays out the positional arguments for a mutation's SQL call.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CallShape {
47    /// Update with a single `input` object → one `jsonb` payload argument.
48    JsonbPayload,
49    /// Insert/Delete/Custom with a single `input` object whose type is in the
50    /// schema → one positional argument per input field.
51    FlattenedFields,
52    /// Flat arguments → one positional argument per declared mutation argument.
53    FlatArgs,
54}
55
56/// What the runtime will send to a mutation's `sql_source`, derived purely from
57/// the compiled schema.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ExpectedCall {
60    /// Resolved function name (the `sql_source`, or the operation's table).
61    pub sql_source:             String,
62    /// Argument-layout shape (diagnostics only).
63    pub shape:                  CallShape,
64    /// Number of positional arguments before injected params.
65    pub base_arity:             usize,
66    /// Inject-param keys, in call order — appended after the base args.
67    pub inject_names:           Vec<String>,
68    /// Whether the first argument is the jsonb payload (update path).
69    pub first_is_jsonb_payload: bool,
70    /// Canonical (stored) key names the runtime writes into the single-JSONB
71    /// payload — the declared input type's field names. Only populated on the
72    /// single-JSONB path with a known input type; drives the payload-key
73    /// consumption scan (#384 category 2).
74    pub payload_keys:           Vec<String>,
75    /// What the function may stamp in `entity_type`, per outcome — the runtime's own
76    /// derivation (ruling AJ 1), read by the literal-stamp lint. `None` skips the lint.
77    pub stamps:                 Option<StampContract>,
78}
79
80impl ExpectedCall {
81    /// Total positional arity the server binds: base args plus inject params.
82    #[must_use]
83    pub fn total_arity(&self) -> usize {
84        self.base_arity + self.inject_names.len()
85    }
86}
87
88/// Severity of a [`ContractViolation`].
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Severity {
91    /// Breaks the contract — the server would fail at runtime. Fails the check.
92    Error,
93    /// Likely a bug, but not a guaranteed failure. Does not fail the check.
94    Warn,
95}
96
97/// A single mismatch between a mutation's compiled contract and the live
98/// database.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum ContractViolation {
101    /// No function of that name is visible on the search path.
102    MissingFunction,
103    /// Function(s) exist but none has the expected input arity.
104    ArityMismatch {
105        /// Arity the server will send.
106        expected: usize,
107        /// Distinct input arities of the existing overloads.
108        found:    Vec<usize>,
109    },
110    /// Multiple overloads share the expected arity — the untyped positional call
111    /// is ambiguous (`function is not unique`).
112    AmbiguousFunction {
113        /// The shared arity.
114        arity: usize,
115        /// How many overloads match it.
116        count: usize,
117    },
118    /// The update path sends a jsonb payload but the first parameter is not jsonb.
119    PayloadNotJsonb {
120        /// The actual first-parameter type.
121        actual: String,
122    },
123    /// A trailing parameter name does not match the inject key bound to it.
124    InjectNameMismatch {
125        /// Inject-key position (0-based, among inject params).
126        position: usize,
127        /// Inject key the server binds here.
128        expected: String,
129        /// The function's actual parameter name at that position.
130        actual:   String,
131    },
132    /// A required response column (`succeeded` / `state_changed`) is absent.
133    MissingRequiredColumn {
134        /// The missing column.
135        column: &'static str,
136    },
137    /// A required response column has the wrong type (must be `boolean`).
138    RequiredColumnWrongType {
139        /// The column.
140        column: &'static str,
141        /// Its actual type.
142        actual: String,
143    },
144    /// An optional response column is present but has an incompatible type.
145    OptionalColumnWrongType {
146        /// The column.
147        column:   &'static str,
148        /// The expected type family.
149        expected: &'static str,
150        /// Its actual type.
151        actual:   String,
152    },
153    /// A declared input field's payload key is never referenced in the function
154    /// body, while the body does extract *other* keys — the classic
155    /// silently-dropped input (#384 category 2). Text-scan based, so advisory.
156    PayloadKeyUnreferenced {
157        /// The declared input field.
158        field:     String,
159        /// The canonical (stored) key probed for, when it differs from `field`.
160        snake_key: String,
161    },
162    /// The function returns a scalar / bare `record` — its response shape cannot
163    /// be introspected.
164    ResponseShapeUnverifiable,
165    /// The body stamps a literal `entity_type` this mutation cannot return on that outcome
166    /// (rulings AA 1, AG 3, AJ 2): the server refuses such a write as a contract error and
167    /// rolls it back.
168    OffContractStamp {
169        /// The outcome the stamp is written on.
170        arm:     StampArm,
171        /// The literal stamp.
172        stamp:   String,
173        /// The types that outcome can be stamped with.
174        allowed: Vec<String>,
175    },
176}
177
178/// The outcome a literal stamp is written on.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum StampArm {
181    /// `mutation_ok(…)`: a success.
182    Success,
183    /// `mutation_err(…)`: a failure.
184    Error,
185    /// An assignment to `entity_type`: either outcome.
186    Either,
187}
188
189impl fmt::Display for StampArm {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        f.write_str(match self {
192            Self::Success => "a success",
193            Self::Error => "a failure",
194            Self::Either => "the result",
195        })
196    }
197}
198
199impl ContractViolation {
200    /// Severity of this violation.
201    #[must_use]
202    pub const fn severity(&self) -> Severity {
203        match self {
204            Self::MissingFunction
205            | Self::ArityMismatch { .. }
206            | Self::AmbiguousFunction { .. }
207            | Self::PayloadNotJsonb { .. }
208            | Self::MissingRequiredColumn { .. }
209            | Self::RequiredColumnWrongType { .. }
210            | Self::OffContractStamp { .. } => Severity::Error,
211            Self::InjectNameMismatch { .. }
212            | Self::OptionalColumnWrongType { .. }
213            | Self::PayloadKeyUnreferenced { .. }
214            | Self::ResponseShapeUnverifiable => Severity::Warn,
215        }
216    }
217}
218
219impl fmt::Display for ContractViolation {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            Self::MissingFunction => {
223                write!(f, "no such function on the search path (function does not exist)")
224            },
225            Self::ArityMismatch { expected, found } => {
226                write!(f, "expected {expected} argument(s) but the function takes {found:?}")
227            },
228            Self::AmbiguousFunction { arity, count } => write!(
229                f,
230                "{count} overloads take {arity} argument(s) — the call is ambiguous (function is not unique)"
231            ),
232            Self::PayloadNotJsonb { actual } => write!(
233                f,
234                "update payload argument must be jsonb but the first parameter is `{actual}`"
235            ),
236            Self::InjectNameMismatch {
237                position,
238                expected,
239                actual,
240            } => write!(
241                f,
242                "inject param #{position} is bound positionally to parameter `{actual}` but the inject key is `{expected}`"
243            ),
244            Self::PayloadKeyUnreferenced { field, snake_key } => {
245                let probed = if field == snake_key {
246                    format!("'{field}'")
247                } else {
248                    format!("'{field}' / '{snake_key}'")
249                };
250                write!(
251                    f,
252                    "input field `{field}`: the function body extracts other payload keys but \
253                     never references {probed} — the value would be silently dropped (text scan)"
254                )
255            },
256            Self::MissingRequiredColumn { column } => write!(
257                f,
258                "response row is missing required column `{column}` (the server cannot decode MutationResponse)"
259            ),
260            Self::RequiredColumnWrongType { column, actual } => {
261                write!(f, "response column `{column}` is `{actual}`, expected boolean")
262            },
263            Self::OptionalColumnWrongType {
264                column,
265                expected,
266                actual,
267            } => write!(f, "response column `{column}` is `{actual}`, expected {expected}"),
268            Self::ResponseShapeUnverifiable => {
269                write!(f, "function returns a scalar/record — response shape cannot be verified")
270            },
271            Self::OffContractStamp {
272                arm,
273                stamp,
274                allowed,
275            } => write!(
276                f,
277                "the function stamps entity_type '{stamp}' on {arm}, which this mutation cannot \
278                 return (it can return: {}) — the server refuses such a write as a contract \
279                 error and rolls it back",
280                if allowed.is_empty() {
281                    "nothing".to_string()
282                } else {
283                    allowed.join(", ")
284                }
285            ),
286        }
287    }
288}
289
290/// Derive what the runtime will send for `mutation`.
291///
292/// Returns `None` when the mutation is not database-backed (no `sql_source` and
293/// no operation table — e.g. a federation/non-SQL mutation) and should be
294/// skipped.
295///
296/// This mirrors the runtime arg-building in
297/// `fraiseql-core/.../runners/mutation/mod.rs` exactly. A single structured
298/// `input` arg is forwarded as ONE jsonb payload when the operation is `Update`,
299/// the mutation opts in via `input_style = jsonb`, **or** the input type is not
300/// in the schema (see `pass_as_single_jsonb`, pinned to `mutation/mod.rs:499-500`).
301/// Otherwise a known input type flattens to one positional arg per field;
302/// everything else is flat args.
303#[must_use]
304pub fn expected_call(
305    mutation: &MutationDefinition,
306    schema: &CompiledSchema,
307) -> Option<ExpectedCall> {
308    let sql_source = resolve_sql_source(mutation)?;
309
310    let input_type_name = single_input_type_name(mutation);
311
312    let (shape, base_arity, first_is_jsonb_payload) = if pass_as_single_jsonb(mutation, schema) {
313        // Single-JSONB path: Update, `input_style = jsonb`, or unknown input type
314        // → one jsonb payload arg. `first_is_jsonb_payload` enables the arg-1-is-
315        // jsonb assertion in `check_mutation` for every such case.
316        (CallShape::JsonbPayload, 1, true)
317    } else if let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) {
318        // Insert/Delete/Custom + single input object found in schema → flatten fields.
319        (CallShape::FlattenedFields, input_type.fields.len(), false)
320    } else {
321        // Flat args (a single non-Input `input`, or multiple scalar args).
322        (CallShape::FlatArgs, mutation.arguments.len(), false)
323    };
324
325    // Payload keys for the consumption scan (#384 category 2): only on the
326    // single-JSONB path with a *known* input type — those field names are the
327    // canonical keys the runtime writes into the payload.
328    let payload_keys = if matches!(shape, CallShape::JsonbPayload) {
329        input_type_name
330            .and_then(|n| schema.find_input_type(n))
331            .map(|t| t.fields.iter().map(|f| f.name.clone()).collect())
332            .unwrap_or_default()
333    } else {
334        Vec::new()
335    };
336
337    Some(ExpectedCall {
338        sql_source,
339        shape,
340        base_arity,
341        inject_names: mutation.inject_params.keys().cloned().collect(),
342        first_is_jsonb_payload,
343        payload_keys,
344        stamps: Some(StampContract::of(schema, mutation)),
345    })
346}
347
348/// The name of a single `input` argument typed as an Input object, else `None`.
349///
350/// This is the structured-input form the compiled `input` arg carries
351/// (`FieldType::Input`); it mirrors the runtime's `input_type_name`
352/// (`mutation/mod.rs:444-456`) scoped to that form.
353fn single_input_type_name(mutation: &MutationDefinition) -> Option<&str> {
354    if mutation.arguments.len() == 1 && mutation.arguments[0].name == "input" {
355        match &mutation.arguments[0].arg_type {
356            FieldType::Input(name) => Some(name.as_str()),
357            _ => None,
358        }
359    } else {
360        None
361    }
362}
363
364/// Faithful mirror of the runtime single-JSONB predicate
365/// (`fraiseql-core/.../runners/mutation/mod.rs:499-500`):
366/// ```text
367/// pass_input_as_single_jsonb =
368///     input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type)
369/// ```
370/// A structured single `input` arg is forwarded as ONE jsonb payload when the
371/// operation is `Update`, the mutation opts in via `input_style = jsonb`, or the
372/// input type is not in the compiled schema. Keep this in sync with that line.
373fn pass_as_single_jsonb(mutation: &MutationDefinition, schema: &CompiledSchema) -> bool {
374    let input_type_name = single_input_type_name(mutation);
375    let input_arg_is_structured = input_type_name.is_some();
376    let is_update = matches!(&mutation.operation, MutationOperation::Update { .. });
377    let jsonb_input_style = matches!(mutation.input_style, InputStyle::Jsonb);
378    let known_input_type = input_type_name.and_then(|n| schema.find_input_type(n)).is_some();
379    input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type)
380}
381
382/// Resolve a mutation's SQL function name: `sql_source`, else the operation's
383/// non-empty table, else `None` (not DB-backed).
384fn resolve_sql_source(mutation: &MutationDefinition) -> Option<String> {
385    if let Some(src) = &mutation.sql_source {
386        return Some(src.clone());
387    }
388    match &mutation.operation {
389        MutationOperation::Insert { table }
390        | MutationOperation::Update { table }
391        | MutationOperation::Delete { table }
392            if !table.is_empty() =>
393        {
394            Some(table.clone())
395        },
396        _ => None,
397    }
398}
399
400/// Compare an [`ExpectedCall`] against the candidate functions resolved from the
401/// database. Pure — no I/O.
402#[must_use]
403pub fn check_mutation(
404    expected: &ExpectedCall,
405    candidates: &[PgFunction],
406) -> Vec<ContractViolation> {
407    let mut violations = Vec::new();
408
409    if candidates.is_empty() {
410        violations.push(ContractViolation::MissingFunction);
411        return violations;
412    }
413
414    let want = expected.total_arity();
415    let matched: Vec<&PgFunction> =
416        candidates.iter().filter(|f| f.in_types.len() == want).collect();
417
418    let func = match matched.as_slice() {
419        [] => {
420            let mut found: Vec<usize> = candidates.iter().map(|f| f.in_types.len()).collect();
421            found.sort_unstable();
422            found.dedup();
423            violations.push(ContractViolation::ArityMismatch {
424                expected: want,
425                found,
426            });
427            return violations;
428        },
429        [one] => *one,
430        many => {
431            violations.push(ContractViolation::AmbiguousFunction {
432                arity: want,
433                count: many.len(),
434            });
435            return violations;
436        },
437    };
438
439    // Call binding: the update path's first parameter must be jsonb.
440    if expected.first_is_jsonb_payload {
441        if let Some(first) = func.in_types.first() {
442            if !is_jsonb(first) {
443                violations.push(ContractViolation::PayloadNotJsonb {
444                    actual: first.clone(),
445                });
446            }
447        }
448    }
449
450    // Payload-key consumption (#384 category 2): a declared input field whose
451    // key the function body never reads is silently dropped at runtime.
452    check_payload_key_consumption(expected, func, &mut violations);
453
454    // Call binding: trailing parameter names should match the inject keys, in
455    // order. Advisory — the runtime binds positionally — and only checkable when
456    // the function declares parameter names.
457    if !expected.inject_names.is_empty() {
458        let start = func.in_types.len().saturating_sub(expected.inject_names.len());
459        for (position, want_name) in expected.inject_names.iter().enumerate() {
460            if let Some(Some(actual)) = func.in_names.get(start + position) {
461                if actual != want_name {
462                    violations.push(ContractViolation::InjectNameMismatch {
463                        position,
464                        expected: want_name.clone(),
465                        actual: actual.clone(),
466                    });
467                }
468            }
469        }
470    }
471
472    check_response_shape(func, &mut violations);
473    check_literal_stamps(expected, func, &mut violations);
474    violations
475}
476
477/// Judge every literal `entity_type` stamp in the function body against the mutation's
478/// [`StampContract`] (ruling AJ 2), emitting [`ContractViolation::OffContractStamp`] for one
479/// outside its outcome's set.
480///
481/// A text scan, conservative as the payload-key scan is: `plpgsql` / `sql` bodies only;
482/// comments are stripped and nothing inside a string literal is read as code; only a
483/// literal is judged (`NULL`, a variable or an expression is left to the runtime, which
484/// refuses an off-contract stamp whatever produced it); `=` is never read as an assignment,
485/// since it is also a comparison. Recognised: the stamp argument of `mutation_ok` (3rd) and
486/// `mutation_err` (5th), bare or `fraiseql.`-qualified, by name (`p_entity_type => …` or
487/// `:=`) or position; and `<var>.entity_type := '…'`.
488fn check_literal_stamps(
489    expected: &ExpectedCall,
490    func: &PgFunction,
491    violations: &mut Vec<ContractViolation>,
492) {
493    let Some(contract) = &expected.stamps else {
494        return;
495    };
496    if !matches!(func.language.as_str(), "plpgsql" | "sql") {
497        return;
498    }
499    let mut seen = Vec::new();
500    for (arm, stamp) in literal_stamps(&func.source) {
501        let allowed: Vec<String> = match arm {
502            StampArm::Success => contract.success.clone(),
503            StampArm::Error => contract.error.clone(),
504            StampArm::Either => {
505                let mut both = contract.success.clone();
506                both.extend(
507                    contract.error.iter().filter(|t| !contract.success.contains(t)).cloned(),
508                );
509                both
510            },
511        };
512        if allowed.contains(&stamp) || seen.contains(&(arm, stamp.clone())) {
513            continue;
514        }
515        seen.push((arm, stamp.clone()));
516        violations.push(ContractViolation::OffContractStamp {
517            arm,
518            stamp,
519            allowed,
520        });
521    }
522}
523
524/// A helper call whose stamp argument is read: `(name, stamp position, outcome)`.
525const STAMPING_HELPERS: &[(&str, usize, StampArm)] = &[
526    ("mutation_ok", 2, StampArm::Success),
527    ("mutation_err", 4, StampArm::Error),
528];
529
530/// A helper call's name, bare or `fraiseql.`-qualified, followed by its opening parenthesis.
531static HELPER_CALL: LazyLock<Regex> = LazyLock::new(|| {
532    Regex::new(r"(?i)(?:\bfraiseql\s*\.\s*|[^A-Za-z0-9_.$]|^)(mutation_ok|mutation_err)\s*\(")
533        .expect("helper call regex is valid")
534});
535
536/// An assignment of a literal to a record's `entity_type` (`:=` only).
537static STAMP_ASSIGNMENT: LazyLock<Regex> = LazyLock::new(|| {
538    Regex::new(r"(?i)\b[A-Za-z_][A-Za-z0-9_$]*\s*\.\s*entity_type\s*:=\s*('(?:[^']|'')*')")
539        .expect("stamp assignment regex is valid")
540});
541
542/// The literal stamps in `source`, each with the outcome it is written on.
543fn literal_stamps(source: &str) -> Vec<(StampArm, String)> {
544    let (code, in_string) = mask_comments_and_strings(source);
545    let mut stamps = Vec::new();
546    for call in HELPER_CALL.captures_iter(&code) {
547        let (Some(name), Some(whole)) = (call.get(1), call.get(0)) else {
548            continue;
549        };
550        if in_string[name.start()] {
551            continue;
552        }
553        let Some(&(_, position, arm)) =
554            STAMPING_HELPERS.iter().find(|(h, ..)| h.eq_ignore_ascii_case(name.as_str()))
555        else {
556            continue;
557        };
558        let args = call_arguments(&code, &in_string, whole.end());
559        let stamp_arg =
560            args.iter().enumerate().find_map(|(index, arg)| match named_argument(arg) {
561                Some((param, value)) => {
562                    param.eq_ignore_ascii_case("p_entity_type").then_some(value)
563                },
564                None => (index == position).then_some(arg.as_str()),
565            });
566        if let Some(stamp) = stamp_arg.and_then(string_literal) {
567            stamps.push((arm, stamp));
568        }
569    }
570    for assignment in STAMP_ASSIGNMENT.captures_iter(&code) {
571        let Some(literal) = assignment.get(1) else {
572            continue;
573        };
574        let start = assignment.get(0).map_or(literal.start(), |m| m.start());
575        if in_string[start] {
576            continue;
577        }
578        if let Some(stamp) = string_literal(literal.as_str()) {
579            stamps.push((StampArm::Either, stamp));
580        }
581    }
582    stamps
583}
584
585/// `source` with every comment blanked out (same length, so offsets stay valid), and for
586/// each byte whether it lies inside a string literal (`'…'` with `''` escapes, or a
587/// dollar-quoted `$tag$…$tag$`). String contents are kept: the stamps are literals.
588fn mask_comments_and_strings(source: &str) -> (String, Vec<bool>) {
589    let bytes = source.as_bytes();
590    let mut code = bytes.to_vec();
591    let mut in_string = vec![false; bytes.len()];
592    let mut i = 0;
593    while i < bytes.len() {
594        match bytes[i] {
595            b'-' if bytes.get(i + 1) == Some(&b'-') => {
596                while i < bytes.len() && bytes[i] != b'\n' {
597                    code[i] = b' ';
598                    i += 1;
599                }
600            },
601            b'/' if bytes.get(i + 1) == Some(&b'*') => {
602                let mut depth = 0usize;
603                while i < bytes.len() {
604                    if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
605                        depth += 1;
606                        code[i] = b' ';
607                        code[i + 1] = b' ';
608                        i += 2;
609                    } else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
610                        depth -= 1;
611                        code[i] = b' ';
612                        code[i + 1] = b' ';
613                        i += 2;
614                        if depth == 0 {
615                            break;
616                        }
617                    } else {
618                        if bytes[i] != b'\n' {
619                            code[i] = b' ';
620                        }
621                        i += 1;
622                    }
623                }
624            },
625            b'\'' => {
626                i += 1;
627                while i < bytes.len() {
628                    if bytes[i] == b'\'' {
629                        if bytes.get(i + 1) == Some(&b'\'') {
630                            in_string[i] = true;
631                            in_string[i + 1] = true;
632                            i += 2;
633                            continue;
634                        }
635                        i += 1;
636                        break;
637                    }
638                    in_string[i] = true;
639                    i += 1;
640                }
641            },
642            b'$' => {
643                let tag_end = bytes[i + 1..]
644                    .iter()
645                    .position(|b| !(b.is_ascii_alphanumeric() || *b == b'_'))
646                    .map(|n| i + 1 + n);
647                match tag_end {
648                    Some(end) if bytes[end] == b'$' => {
649                        let tag = &bytes[i..=end];
650                        let body_start = end + 1;
651                        let close = bytes[body_start..]
652                            .windows(tag.len())
653                            .position(|w| w == tag)
654                            .map_or(bytes.len(), |n| body_start + n);
655                        for flag in &mut in_string[body_start..close] {
656                            *flag = true;
657                        }
658                        i = (close + tag.len()).min(bytes.len());
659                    },
660                    _ => i += 1,
661                }
662            },
663            _ => i += 1,
664        }
665    }
666    // Only ASCII bytes were replaced (by ASCII spaces), so the text is still UTF-8.
667    (String::from_utf8(code).unwrap_or_else(|_| source.to_string()), in_string)
668}
669
670/// The top-level arguments of the call whose `(` ends at `open_end` in `code` (comments
671/// already blanked), trimmed; commas inside parentheses, brackets and string literals do not
672/// split.
673fn call_arguments(code: &str, in_string: &[bool], open_end: usize) -> Vec<String> {
674    let bytes = code.as_bytes();
675    let mut args = Vec::new();
676    let mut depth = 0usize;
677    let mut start = open_end;
678    let mut i = open_end;
679    while i < bytes.len() {
680        if !in_string[i] {
681            match bytes[i] {
682                b'(' | b'[' => depth += 1,
683                b')' | b']' if depth > 0 => depth -= 1,
684                b')' => {
685                    args.push(code[start..i].trim().to_string());
686                    break;
687                },
688                b',' if depth == 0 => {
689                    args.push(code[start..i].trim().to_string());
690                    start = i + 1;
691                },
692                _ => {},
693            }
694        }
695        i += 1;
696    }
697    args
698}
699
700/// `param => value` or `param := value` → `(param, value)`.
701fn named_argument(arg: &str) -> Option<(&str, &str)> {
702    let at = arg.find("=>").or_else(|| arg.find(":="))?;
703    let param = arg[..at].trim();
704    let is_identifier = !param.is_empty()
705        && param.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$');
706    is_identifier.then(|| (param, arg[at + 2..].trim()))
707}
708
709/// The value of a plain string literal (`'T'`, `''` escapes, optionally `::text` /
710/// `::varchar`), else `None`.
711fn string_literal(expr: &str) -> Option<String> {
712    let rest = expr.trim().strip_prefix('\'')?;
713    let mut value = String::new();
714    let mut chars = rest.char_indices();
715    let close = loop {
716        let (at, c) = chars.next()?;
717        if c != '\'' {
718            value.push(c);
719        } else if rest[at + 1..].starts_with('\'') {
720            value.push('\'');
721            chars.next();
722        } else {
723            break at;
724        }
725    };
726    let tail = rest[close + 1..].trim().to_ascii_lowercase();
727    let plain = tail.is_empty()
728        || tail.strip_prefix("::").is_some_and(|t| matches!(t.trim(), "text" | "varchar"));
729    plain.then_some(value)
730}
731
732/// Scan the function body for each declared payload key (#384 category 2).
733///
734/// Emits [`ContractViolation::PayloadKeyUnreferenced`] (warn-grade) for every
735/// declared input field whose key — probed in both its declared and snake_case
736/// form, single-quoted — never appears in the body. Deliberately conservative
737/// about when it runs at all, because it is a text scan, not a parse:
738///
739/// - only `plpgsql` / `sql` bodies are scannable source text (for other languages `prosrc` is a
740///   symbol name);
741/// - a body containing a whole-payload consumer (`jsonb_populate_record` and kin) reads every key
742///   without naming any — skip;
743/// - a body with **no** jsonb extraction operator (`->`, `->>`, `#>`, `?`) never reads keys inline
744///   (it forwards the payload to a helper) — skip. This also means the scan only fires when the
745///   body demonstrably extracts *some* keys but not the declared one, which is exactly the
746///   silently-dropped-input shape.
747fn check_payload_key_consumption(
748    expected: &ExpectedCall,
749    func: &PgFunction,
750    violations: &mut Vec<ContractViolation>,
751) {
752    const WHOLE_PAYLOAD_MARKERS: &[&str] = &[
753        "jsonb_populate_record",
754        "json_populate_record",
755        "jsonb_to_record",
756        "jsonb_each",
757        "jsonb_object_keys",
758    ];
759    const EXTRACTION_OPS: &[&str] = &["->", "#>", "?"];
760
761    if !expected.first_is_jsonb_payload || expected.payload_keys.is_empty() {
762        return;
763    }
764    if !matches!(func.language.as_str(), "plpgsql" | "sql") {
765        return;
766    }
767    if WHOLE_PAYLOAD_MARKERS.iter().any(|m| func.source.contains(m)) {
768        return;
769    }
770    if !EXTRACTION_OPS.iter().any(|op| func.source.contains(op)) {
771        return;
772    }
773
774    for key in &expected.payload_keys {
775        let snake_key = fraiseql_core::utils::to_snake_case(key);
776        let quoted = format!("'{key}'");
777        let quoted_snake = format!("'{snake_key}'");
778        if !func.source.contains(&quoted) && !func.source.contains(&quoted_snake) {
779            violations.push(ContractViolation::PayloadKeyUnreferenced {
780                field: key.clone(),
781                snake_key,
782            });
783        }
784    }
785}
786
787/// Optional response columns and their expected type family.
788const OPTIONAL_COLUMNS: &[(&str, &str)] = &[
789    ("error_class", "text or enum"),
790    ("status_detail", "text"),
791    ("http_status", "an integer type"),
792    ("message", "text"),
793    ("entity_id", "uuid"),
794    ("entity_type", "text"),
795    ("entity", "jsonb"),
796    ("updated_fields", "a text array"),
797    ("cascade", "jsonb"),
798    ("error_detail", "jsonb"),
799    ("metadata", "jsonb"),
800];
801
802/// Validate the function's result row against the `MutationResponse` decoder:
803/// `succeeded` + `state_changed` are required booleans; present optional columns
804/// must have compatible types.
805fn check_response_shape(func: &PgFunction, violations: &mut Vec<ContractViolation>) {
806    if func.out_columns.is_empty() {
807        violations.push(ContractViolation::ResponseShapeUnverifiable);
808        return;
809    }
810    let find = |name: &str| func.out_columns.iter().find(|c| c.name == name);
811
812    for column in ["succeeded", "state_changed"] {
813        match find(column) {
814            None => violations.push(ContractViolation::MissingRequiredColumn { column }),
815            Some(c) if !is_bool(&c.type_name) => {
816                violations.push(ContractViolation::RequiredColumnWrongType {
817                    column,
818                    actual: c.type_name.clone(),
819                });
820            },
821            Some(_) => {},
822        }
823    }
824
825    for &(column, expected) in OPTIONAL_COLUMNS {
826        if let Some(c) = find(column) {
827            if !optional_column_ok(column, c.is_enum, &c.type_name) {
828                violations.push(ContractViolation::OptionalColumnWrongType {
829                    column,
830                    expected,
831                    actual: c.type_name.clone(),
832                });
833            }
834        }
835    }
836}
837
838/// Whether an optional response column's type is compatible with the decoder.
839fn optional_column_ok(column: &str, is_enum: bool, type_name: &str) -> bool {
840    match column {
841        // `error_class` decodes from text or a project enum.
842        "error_class" => is_enum || is_text(type_name),
843        "status_detail" | "message" | "entity_type" => is_text(type_name),
844        "http_status" => is_int(type_name),
845        "entity_id" => type_name == "uuid",
846        "updated_fields" => type_name.ends_with("[]"),
847        "entity" | "cascade" | "error_detail" | "metadata" => is_jsonb(type_name),
848        _ => true,
849    }
850}
851
852fn is_bool(type_name: &str) -> bool {
853    type_name == "boolean"
854}
855
856fn is_jsonb(type_name: &str) -> bool {
857    type_name == "jsonb" || type_name == "json"
858}
859
860fn is_int(type_name: &str) -> bool {
861    matches!(type_name, "smallint" | "integer" | "bigint")
862}
863
864fn is_text(type_name: &str) -> bool {
865    matches!(type_name, "text" | "varchar" | "name" | "bpchar" | "citext")
866        || type_name.starts_with("character varying")
867        || type_name.starts_with("character(")
868        || type_name == "character"
869}
870
871// ─── Report ─────────────────────────────────────────────────────────────────
872
873/// Per-mutation contract findings.
874#[derive(Debug, Clone)]
875pub struct MutationReport {
876    /// GraphQL mutation name.
877    pub mutation:   String,
878    /// Resolved `sql_source` checked.
879    pub sql_source: String,
880    /// Violations found (empty entries are not stored in the report).
881    pub violations: Vec<ContractViolation>,
882}
883
884/// Aggregate result of validating every mutation's contract.
885#[derive(Debug, Clone, Default)]
886pub struct ContractReport {
887    /// DB-backed mutations checked.
888    pub checked:   usize,
889    /// Non-DB-backed mutations skipped.
890    pub skipped:   usize,
891    /// Mutations with at least one violation.
892    pub mutations: Vec<MutationReport>,
893}
894
895impl ContractReport {
896    /// Total error-severity violations across all mutations.
897    #[must_use]
898    pub fn error_count(&self) -> usize {
899        self.count(Severity::Error)
900    }
901
902    /// Total warning-severity violations across all mutations.
903    #[must_use]
904    pub fn warn_count(&self) -> usize {
905        self.count(Severity::Warn)
906    }
907
908    fn count(&self, severity: Severity) -> usize {
909        self.mutations
910            .iter()
911            .flat_map(|m| &m.violations)
912            .filter(|v| v.severity() == severity)
913            .count()
914    }
915
916    /// Render the report as machine-readable JSON.
917    #[must_use]
918    pub fn to_json(&self) -> serde_json::Value {
919        let mutations: Vec<serde_json::Value> = self
920            .mutations
921            .iter()
922            .map(|m| {
923                let findings: Vec<serde_json::Value> = m
924                    .violations
925                    .iter()
926                    .map(|v| {
927                        serde_json::json!({
928                            "severity": match v.severity() {
929                                Severity::Error => "error",
930                                Severity::Warn => "warning",
931                            },
932                            "message": v.to_string(),
933                        })
934                    })
935                    .collect();
936                serde_json::json!({
937                    "mutation": m.mutation,
938                    "sqlSource": m.sql_source,
939                    "findings": findings,
940                })
941            })
942            .collect();
943        serde_json::json!({
944            "checked": self.checked,
945            "skipped": self.skipped,
946            "errors": self.error_count(),
947            "warnings": self.warn_count(),
948            "mutations": mutations,
949        })
950    }
951
952    /// Print the report in human-readable form to stdout.
953    pub fn print_text(&self) {
954        println!("\nChecking mutation contract against the database...\n");
955        if self.mutations.is_empty() {
956            println!(
957                "  All {} DB-backed mutation(s) match the database contract ({} skipped).",
958                self.checked, self.skipped
959            );
960            return;
961        }
962        for m in &self.mutations {
963            println!("  {} (sql_source: {})", m.mutation, m.sql_source);
964            for v in &m.violations {
965                let symbol = match v.severity() {
966                    Severity::Error => "✗",
967                    Severity::Warn => "!",
968                };
969                println!("    [{symbol}] {v}");
970            }
971        }
972        println!(
973            "\nSummary: {} error(s), {} warning(s) across {} checked mutation(s) ({} skipped).",
974            self.error_count(),
975            self.warn_count(),
976            self.checked,
977            self.skipped,
978        );
979    }
980}
981
982/// Validate every DB-backed mutation's contract in `schema` against `catalog`.
983///
984/// # Errors
985///
986/// Returns an error if any catalog query fails.
987pub async fn validate_mutation_contract(
988    schema: &CompiledSchema,
989    catalog: &PgCatalog,
990) -> Result<ContractReport> {
991    let mut report = ContractReport::default();
992    for mutation in &schema.mutations {
993        let Some(expected) = expected_call(mutation, schema) else {
994            report.skipped += 1;
995            continue;
996        };
997        report.checked += 1;
998        let candidates = catalog.resolve_functions(&expected.sql_source).await?;
999        let violations = check_mutation(&expected, &candidates);
1000        if !violations.is_empty() {
1001            report.mutations.push(MutationReport {
1002                mutation: mutation.name.clone(),
1003                sql_source: expected.sql_source.clone(),
1004                violations,
1005            });
1006        }
1007    }
1008    Ok(report)
1009}