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 pub requires_result: bool,
81}
82
83impl ExpectedCall {
84 #[must_use]
86 pub fn total_arity(&self) -> usize {
87 self.base_arity + self.inject_names.len()
88 }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum Severity {
94 Error,
96 Warn,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum ContractViolation {
104 MissingFunction,
106 ArityMismatch {
108 expected: usize,
110 found: Vec<usize>,
112 },
113 AmbiguousFunction {
116 arity: usize,
118 count: usize,
120 },
121 PayloadNotJsonb {
123 actual: String,
125 },
126 InjectNameMismatch {
128 position: usize,
130 expected: String,
132 actual: String,
134 },
135 MissingRequiredColumn {
137 column: &'static str,
139 },
140 RequiredColumnWrongType {
142 column: &'static str,
144 actual: String,
146 },
147 MissingResultColumn,
150 ResultColumnWrongType {
153 actual: String,
155 },
156 OptionalColumnWrongType {
158 column: &'static str,
160 expected: &'static str,
162 actual: String,
164 },
165 PayloadKeyUnreferenced {
169 field: String,
171 snake_key: String,
173 },
174 ResponseShapeUnverifiable,
177 OffContractStamp {
181 arm: StampArm,
183 stamp: String,
185 allowed: Vec<String>,
187 },
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum StampArm {
193 Success,
195 Error,
197 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 #[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#[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 (CallShape::JsonbPayload, 1, true)
344 } else if let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) {
345 (CallShape::FlattenedFields, input_type.fields.len(), false)
347 } else {
348 (CallShape::FlatArgs, mutation.arguments.len(), false)
350 };
351
352 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
376fn 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
392fn 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
410fn 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#[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 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 check_payload_key_consumption(expected, func, &mut violations);
481
482 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
505fn 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
552const STAMPING_HELPERS: &[(&str, usize, StampArm)] = &[
554 ("mutation_ok", 2, StampArm::Success),
555 ("mutation_err", 4, StampArm::Error),
556];
557
558static 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
564static 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
570fn 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
613fn 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 (String::from_utf8(code).unwrap_or_else(|_| source.to_string()), in_string)
696}
697
698fn 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
728fn 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
737fn 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
760fn 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("ed) && !func.source.contains("ed_snake) {
807 violations.push(ContractViolation::PayloadKeyUnreferenced {
808 field: key.clone(),
809 snake_key,
810 });
811 }
812 }
813}
814
815const 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
830fn 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 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
883fn optional_column_ok(column: &str, is_enum: bool, type_name: &str) -> bool {
885 match column {
886 "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#[derive(Debug, Clone)]
920pub struct MutationReport {
921 pub mutation: String,
923 pub sql_source: String,
925 pub violations: Vec<ContractViolation>,
927}
928
929#[derive(Debug, Clone, Default)]
931pub struct ContractReport {
932 pub checked: usize,
934 pub skipped: usize,
936 pub mutations: Vec<MutationReport>,
938}
939
940impl ContractReport {
941 #[must_use]
943 pub fn error_count(&self) -> usize {
944 self.count(Severity::Error)
945 }
946
947 #[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 #[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 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
1027pub 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}