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