1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum CallShape {
47 JsonbPayload,
49 FlattenedFields,
52 FlatArgs,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ExpectedCall {
60 pub sql_source: String,
62 pub shape: CallShape,
64 pub base_arity: usize,
66 pub inject_names: Vec<String>,
68 pub first_is_jsonb_payload: bool,
70 pub payload_keys: Vec<String>,
75 pub stamps: Option<StampContract>,
78}
79
80impl ExpectedCall {
81 #[must_use]
83 pub fn total_arity(&self) -> usize {
84 self.base_arity + self.inject_names.len()
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Severity {
91 Error,
93 Warn,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum ContractViolation {
101 MissingFunction,
103 ArityMismatch {
105 expected: usize,
107 found: Vec<usize>,
109 },
110 AmbiguousFunction {
113 arity: usize,
115 count: usize,
117 },
118 PayloadNotJsonb {
120 actual: String,
122 },
123 InjectNameMismatch {
125 position: usize,
127 expected: String,
129 actual: String,
131 },
132 MissingRequiredColumn {
134 column: &'static str,
136 },
137 RequiredColumnWrongType {
139 column: &'static str,
141 actual: String,
143 },
144 OptionalColumnWrongType {
146 column: &'static str,
148 expected: &'static str,
150 actual: String,
152 },
153 PayloadKeyUnreferenced {
157 field: String,
159 snake_key: String,
161 },
162 ResponseShapeUnverifiable,
165 OffContractStamp {
169 arm: StampArm,
171 stamp: String,
173 allowed: Vec<String>,
175 },
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum StampArm {
181 Success,
183 Error,
185 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 #[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#[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 (CallShape::JsonbPayload, 1, true)
317 } else if let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) {
318 (CallShape::FlattenedFields, input_type.fields.len(), false)
320 } else {
321 (CallShape::FlatArgs, mutation.arguments.len(), false)
323 };
324
325 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
348fn 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
364fn 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
382fn 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#[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 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 check_payload_key_consumption(expected, func, &mut violations);
453
454 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
477fn 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
524const STAMPING_HELPERS: &[(&str, usize, StampArm)] = &[
526 ("mutation_ok", 2, StampArm::Success),
527 ("mutation_err", 4, StampArm::Error),
528];
529
530static 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
536static 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
542fn 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
585fn 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 (String::from_utf8(code).unwrap_or_else(|_| source.to_string()), in_string)
668}
669
670fn 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
700fn 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
709fn 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
732fn 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("ed) && !func.source.contains("ed_snake) {
779 violations.push(ContractViolation::PayloadKeyUnreferenced {
780 field: key.clone(),
781 snake_key,
782 });
783 }
784 }
785}
786
787const 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
802fn 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
838fn optional_column_ok(column: &str, is_enum: bool, type_name: &str) -> bool {
840 match column {
841 "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#[derive(Debug, Clone)]
875pub struct MutationReport {
876 pub mutation: String,
878 pub sql_source: String,
880 pub violations: Vec<ContractViolation>,
882}
883
884#[derive(Debug, Clone, Default)]
886pub struct ContractReport {
887 pub checked: usize,
889 pub skipped: usize,
891 pub mutations: Vec<MutationReport>,
893}
894
895impl ContractReport {
896 #[must_use]
898 pub fn error_count(&self) -> usize {
899 self.count(Severity::Error)
900 }
901
902 #[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 #[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 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
982pub 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}