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