1use std::fmt;
26
27use anyhow::Result;
28use fraiseql_core::schema::{
29 CompiledSchema, FieldType, InputStyle, MutationDefinition, MutationOperation,
30};
31
32use crate::schema::pg_catalog::{PgCatalog, PgFunction};
33
34#[cfg(test)]
35mod tests;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CallShape {
40 JsonbPayload,
42 FlattenedFields,
45 FlatArgs,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ExpectedCall {
53 pub sql_source: String,
55 pub shape: CallShape,
57 pub base_arity: usize,
59 pub inject_names: Vec<String>,
61 pub first_is_jsonb_payload: bool,
63}
64
65impl ExpectedCall {
66 #[must_use]
68 pub fn total_arity(&self) -> usize {
69 self.base_arity + self.inject_names.len()
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum Severity {
76 Error,
78 Warn,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ContractViolation {
86 MissingFunction,
88 ArityMismatch {
90 expected: usize,
92 found: Vec<usize>,
94 },
95 AmbiguousFunction {
98 arity: usize,
100 count: usize,
102 },
103 PayloadNotJsonb {
105 actual: String,
107 },
108 InjectNameMismatch {
110 position: usize,
112 expected: String,
114 actual: String,
116 },
117 MissingRequiredColumn {
119 column: &'static str,
121 },
122 RequiredColumnWrongType {
124 column: &'static str,
126 actual: String,
128 },
129 OptionalColumnWrongType {
131 column: &'static str,
133 expected: &'static str,
135 actual: String,
137 },
138 ResponseShapeUnverifiable,
141}
142
143impl ContractViolation {
144 #[must_use]
146 pub const fn severity(&self) -> Severity {
147 match self {
148 Self::MissingFunction
149 | Self::ArityMismatch { .. }
150 | Self::AmbiguousFunction { .. }
151 | Self::PayloadNotJsonb { .. }
152 | Self::MissingRequiredColumn { .. }
153 | Self::RequiredColumnWrongType { .. } => Severity::Error,
154 Self::InjectNameMismatch { .. }
155 | Self::OptionalColumnWrongType { .. }
156 | Self::ResponseShapeUnverifiable => Severity::Warn,
157 }
158 }
159}
160
161impl fmt::Display for ContractViolation {
162 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163 match self {
164 Self::MissingFunction => {
165 write!(f, "no such function on the search path (function does not exist)")
166 },
167 Self::ArityMismatch { expected, found } => {
168 write!(f, "expected {expected} argument(s) but the function takes {found:?}")
169 },
170 Self::AmbiguousFunction { arity, count } => write!(
171 f,
172 "{count} overloads take {arity} argument(s) — the call is ambiguous (function is not unique)"
173 ),
174 Self::PayloadNotJsonb { actual } => write!(
175 f,
176 "update payload argument must be jsonb but the first parameter is `{actual}`"
177 ),
178 Self::InjectNameMismatch {
179 position,
180 expected,
181 actual,
182 } => write!(
183 f,
184 "inject param #{position} is bound positionally to parameter `{actual}` but the inject key is `{expected}`"
185 ),
186 Self::MissingRequiredColumn { column } => write!(
187 f,
188 "response row is missing required column `{column}` (the server cannot decode MutationResponse)"
189 ),
190 Self::RequiredColumnWrongType { column, actual } => {
191 write!(f, "response column `{column}` is `{actual}`, expected boolean")
192 },
193 Self::OptionalColumnWrongType {
194 column,
195 expected,
196 actual,
197 } => write!(f, "response column `{column}` is `{actual}`, expected {expected}"),
198 Self::ResponseShapeUnverifiable => {
199 write!(f, "function returns a scalar/record — response shape cannot be verified")
200 },
201 }
202 }
203}
204
205#[must_use]
219pub fn expected_call(
220 mutation: &MutationDefinition,
221 schema: &CompiledSchema,
222) -> Option<ExpectedCall> {
223 let sql_source = resolve_sql_source(mutation)?;
224
225 let input_type_name = single_input_type_name(mutation);
226
227 let (shape, base_arity, first_is_jsonb_payload) = if pass_as_single_jsonb(mutation, schema) {
228 (CallShape::JsonbPayload, 1, true)
232 } else if let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) {
233 (CallShape::FlattenedFields, input_type.fields.len(), false)
235 } else {
236 (CallShape::FlatArgs, mutation.arguments.len(), false)
238 };
239
240 Some(ExpectedCall {
241 sql_source,
242 shape,
243 base_arity,
244 inject_names: mutation.inject_params.keys().cloned().collect(),
245 first_is_jsonb_payload,
246 })
247}
248
249fn single_input_type_name(mutation: &MutationDefinition) -> Option<&str> {
255 if mutation.arguments.len() == 1 && mutation.arguments[0].name == "input" {
256 match &mutation.arguments[0].arg_type {
257 FieldType::Input(name) => Some(name.as_str()),
258 _ => None,
259 }
260 } else {
261 None
262 }
263}
264
265fn pass_as_single_jsonb(mutation: &MutationDefinition, schema: &CompiledSchema) -> bool {
275 let input_type_name = single_input_type_name(mutation);
276 let input_arg_is_structured = input_type_name.is_some();
277 let is_update = matches!(&mutation.operation, MutationOperation::Update { .. });
278 let jsonb_input_style = matches!(mutation.input_style, InputStyle::Jsonb);
279 let known_input_type = input_type_name.and_then(|n| schema.find_input_type(n)).is_some();
280 input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type)
281}
282
283fn resolve_sql_source(mutation: &MutationDefinition) -> Option<String> {
286 if let Some(src) = &mutation.sql_source {
287 return Some(src.clone());
288 }
289 match &mutation.operation {
290 MutationOperation::Insert { table }
291 | MutationOperation::Update { table }
292 | MutationOperation::Delete { table }
293 if !table.is_empty() =>
294 {
295 Some(table.clone())
296 },
297 _ => None,
298 }
299}
300
301#[must_use]
304pub fn check_mutation(
305 expected: &ExpectedCall,
306 candidates: &[PgFunction],
307) -> Vec<ContractViolation> {
308 let mut violations = Vec::new();
309
310 if candidates.is_empty() {
311 violations.push(ContractViolation::MissingFunction);
312 return violations;
313 }
314
315 let want = expected.total_arity();
316 let matched: Vec<&PgFunction> =
317 candidates.iter().filter(|f| f.in_types.len() == want).collect();
318
319 let func = match matched.as_slice() {
320 [] => {
321 let mut found: Vec<usize> = candidates.iter().map(|f| f.in_types.len()).collect();
322 found.sort_unstable();
323 found.dedup();
324 violations.push(ContractViolation::ArityMismatch {
325 expected: want,
326 found,
327 });
328 return violations;
329 },
330 [one] => *one,
331 many => {
332 violations.push(ContractViolation::AmbiguousFunction {
333 arity: want,
334 count: many.len(),
335 });
336 return violations;
337 },
338 };
339
340 if expected.first_is_jsonb_payload {
342 if let Some(first) = func.in_types.first() {
343 if !is_jsonb(first) {
344 violations.push(ContractViolation::PayloadNotJsonb {
345 actual: first.clone(),
346 });
347 }
348 }
349 }
350
351 if !expected.inject_names.is_empty() {
355 let start = func.in_types.len().saturating_sub(expected.inject_names.len());
356 for (position, want_name) in expected.inject_names.iter().enumerate() {
357 if let Some(Some(actual)) = func.in_names.get(start + position) {
358 if actual != want_name {
359 violations.push(ContractViolation::InjectNameMismatch {
360 position,
361 expected: want_name.clone(),
362 actual: actual.clone(),
363 });
364 }
365 }
366 }
367 }
368
369 check_response_shape(func, &mut violations);
370 violations
371}
372
373const OPTIONAL_COLUMNS: &[(&str, &str)] = &[
375 ("error_class", "text or enum"),
376 ("status_detail", "text"),
377 ("http_status", "an integer type"),
378 ("message", "text"),
379 ("entity_id", "uuid"),
380 ("entity_type", "text"),
381 ("entity", "jsonb"),
382 ("updated_fields", "a text array"),
383 ("cascade", "jsonb"),
384 ("error_detail", "jsonb"),
385 ("metadata", "jsonb"),
386];
387
388fn check_response_shape(func: &PgFunction, violations: &mut Vec<ContractViolation>) {
392 if func.out_columns.is_empty() {
393 violations.push(ContractViolation::ResponseShapeUnverifiable);
394 return;
395 }
396 let find = |name: &str| func.out_columns.iter().find(|c| c.name == name);
397
398 for column in ["succeeded", "state_changed"] {
399 match find(column) {
400 None => violations.push(ContractViolation::MissingRequiredColumn { column }),
401 Some(c) if !is_bool(&c.type_name) => {
402 violations.push(ContractViolation::RequiredColumnWrongType {
403 column,
404 actual: c.type_name.clone(),
405 });
406 },
407 Some(_) => {},
408 }
409 }
410
411 for &(column, expected) in OPTIONAL_COLUMNS {
412 if let Some(c) = find(column) {
413 if !optional_column_ok(column, c.is_enum, &c.type_name) {
414 violations.push(ContractViolation::OptionalColumnWrongType {
415 column,
416 expected,
417 actual: c.type_name.clone(),
418 });
419 }
420 }
421 }
422}
423
424fn optional_column_ok(column: &str, is_enum: bool, type_name: &str) -> bool {
426 match column {
427 "error_class" => is_enum || is_text(type_name),
429 "status_detail" | "message" | "entity_type" => is_text(type_name),
430 "http_status" => is_int(type_name),
431 "entity_id" => type_name == "uuid",
432 "updated_fields" => type_name.ends_with("[]"),
433 "entity" | "cascade" | "error_detail" | "metadata" => is_jsonb(type_name),
434 _ => true,
435 }
436}
437
438fn is_bool(type_name: &str) -> bool {
439 type_name == "boolean"
440}
441
442fn is_jsonb(type_name: &str) -> bool {
443 type_name == "jsonb" || type_name == "json"
444}
445
446fn is_int(type_name: &str) -> bool {
447 matches!(type_name, "smallint" | "integer" | "bigint")
448}
449
450fn is_text(type_name: &str) -> bool {
451 matches!(type_name, "text" | "varchar" | "name" | "bpchar" | "citext")
452 || type_name.starts_with("character varying")
453 || type_name.starts_with("character(")
454 || type_name == "character"
455}
456
457#[derive(Debug, Clone)]
461pub struct MutationReport {
462 pub mutation: String,
464 pub sql_source: String,
466 pub violations: Vec<ContractViolation>,
468}
469
470#[derive(Debug, Clone, Default)]
472pub struct ContractReport {
473 pub checked: usize,
475 pub skipped: usize,
477 pub mutations: Vec<MutationReport>,
479}
480
481impl ContractReport {
482 #[must_use]
484 pub fn error_count(&self) -> usize {
485 self.count(Severity::Error)
486 }
487
488 #[must_use]
490 pub fn warn_count(&self) -> usize {
491 self.count(Severity::Warn)
492 }
493
494 fn count(&self, severity: Severity) -> usize {
495 self.mutations
496 .iter()
497 .flat_map(|m| &m.violations)
498 .filter(|v| v.severity() == severity)
499 .count()
500 }
501
502 #[must_use]
504 pub fn to_json(&self) -> serde_json::Value {
505 let mutations: Vec<serde_json::Value> = self
506 .mutations
507 .iter()
508 .map(|m| {
509 let findings: Vec<serde_json::Value> = m
510 .violations
511 .iter()
512 .map(|v| {
513 serde_json::json!({
514 "severity": match v.severity() {
515 Severity::Error => "error",
516 Severity::Warn => "warning",
517 },
518 "message": v.to_string(),
519 })
520 })
521 .collect();
522 serde_json::json!({
523 "mutation": m.mutation,
524 "sqlSource": m.sql_source,
525 "findings": findings,
526 })
527 })
528 .collect();
529 serde_json::json!({
530 "checked": self.checked,
531 "skipped": self.skipped,
532 "errors": self.error_count(),
533 "warnings": self.warn_count(),
534 "mutations": mutations,
535 })
536 }
537
538 pub fn print_text(&self) {
540 println!("\nChecking mutation contract against the database...\n");
541 if self.mutations.is_empty() {
542 println!(
543 " All {} DB-backed mutation(s) match the database contract ({} skipped).",
544 self.checked, self.skipped
545 );
546 return;
547 }
548 for m in &self.mutations {
549 println!(" {} (sql_source: {})", m.mutation, m.sql_source);
550 for v in &m.violations {
551 let symbol = match v.severity() {
552 Severity::Error => "✗",
553 Severity::Warn => "!",
554 };
555 println!(" [{symbol}] {v}");
556 }
557 }
558 println!(
559 "\nSummary: {} error(s), {} warning(s) across {} checked mutation(s) ({} skipped).",
560 self.error_count(),
561 self.warn_count(),
562 self.checked,
563 self.skipped,
564 );
565 }
566}
567
568pub async fn validate_mutation_contract(
574 schema: &CompiledSchema,
575 catalog: &PgCatalog,
576) -> Result<ContractReport> {
577 let mut report = ContractReport::default();
578 for mutation in &schema.mutations {
579 let Some(expected) = expected_call(mutation, schema) else {
580 report.skipped += 1;
581 continue;
582 };
583 report.checked += 1;
584 let candidates = catalog.resolve_functions(&expected.sql_source).await?;
585 let violations = check_mutation(&expected, &candidates);
586 if !violations.is_empty() {
587 report.mutations.push(MutationReport {
588 mutation: mutation.name.clone(),
589 sql_source: expected.sql_source.clone(),
590 violations,
591 });
592 }
593 }
594 Ok(report)
595}