use std::fmt;
use anyhow::Result;
use fraiseql_core::schema::{
CompiledSchema, FieldType, InputStyle, MutationDefinition, MutationOperation,
};
use crate::schema::pg_catalog::{PgCatalog, PgFunction};
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallShape {
JsonbPayload,
FlattenedFields,
FlatArgs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpectedCall {
pub sql_source: String,
pub shape: CallShape,
pub base_arity: usize,
pub inject_names: Vec<String>,
pub first_is_jsonb_payload: bool,
}
impl ExpectedCall {
#[must_use]
pub fn total_arity(&self) -> usize {
self.base_arity + self.inject_names.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warn,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContractViolation {
MissingFunction,
ArityMismatch {
expected: usize,
found: Vec<usize>,
},
AmbiguousFunction {
arity: usize,
count: usize,
},
PayloadNotJsonb {
actual: String,
},
InjectNameMismatch {
position: usize,
expected: String,
actual: String,
},
MissingRequiredColumn {
column: &'static str,
},
RequiredColumnWrongType {
column: &'static str,
actual: String,
},
OptionalColumnWrongType {
column: &'static str,
expected: &'static str,
actual: String,
},
ResponseShapeUnverifiable,
}
impl ContractViolation {
#[must_use]
pub const fn severity(&self) -> Severity {
match self {
Self::MissingFunction
| Self::ArityMismatch { .. }
| Self::AmbiguousFunction { .. }
| Self::PayloadNotJsonb { .. }
| Self::MissingRequiredColumn { .. }
| Self::RequiredColumnWrongType { .. } => Severity::Error,
Self::InjectNameMismatch { .. }
| Self::OptionalColumnWrongType { .. }
| Self::ResponseShapeUnverifiable => Severity::Warn,
}
}
}
impl fmt::Display for ContractViolation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingFunction => {
write!(f, "no such function on the search path (function does not exist)")
},
Self::ArityMismatch { expected, found } => {
write!(f, "expected {expected} argument(s) but the function takes {found:?}")
},
Self::AmbiguousFunction { arity, count } => write!(
f,
"{count} overloads take {arity} argument(s) — the call is ambiguous (function is not unique)"
),
Self::PayloadNotJsonb { actual } => write!(
f,
"update payload argument must be jsonb but the first parameter is `{actual}`"
),
Self::InjectNameMismatch {
position,
expected,
actual,
} => write!(
f,
"inject param #{position} is bound positionally to parameter `{actual}` but the inject key is `{expected}`"
),
Self::MissingRequiredColumn { column } => write!(
f,
"response row is missing required column `{column}` (the server cannot decode MutationResponse)"
),
Self::RequiredColumnWrongType { column, actual } => {
write!(f, "response column `{column}` is `{actual}`, expected boolean")
},
Self::OptionalColumnWrongType {
column,
expected,
actual,
} => write!(f, "response column `{column}` is `{actual}`, expected {expected}"),
Self::ResponseShapeUnverifiable => {
write!(f, "function returns a scalar/record — response shape cannot be verified")
},
}
}
}
#[must_use]
pub fn expected_call(
mutation: &MutationDefinition,
schema: &CompiledSchema,
) -> Option<ExpectedCall> {
let sql_source = resolve_sql_source(mutation)?;
let input_type_name = single_input_type_name(mutation);
let (shape, base_arity, first_is_jsonb_payload) = if pass_as_single_jsonb(mutation, schema) {
(CallShape::JsonbPayload, 1, true)
} else if let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) {
(CallShape::FlattenedFields, input_type.fields.len(), false)
} else {
(CallShape::FlatArgs, mutation.arguments.len(), false)
};
Some(ExpectedCall {
sql_source,
shape,
base_arity,
inject_names: mutation.inject_params.keys().cloned().collect(),
first_is_jsonb_payload,
})
}
fn single_input_type_name(mutation: &MutationDefinition) -> Option<&str> {
if mutation.arguments.len() == 1 && mutation.arguments[0].name == "input" {
match &mutation.arguments[0].arg_type {
FieldType::Input(name) => Some(name.as_str()),
_ => None,
}
} else {
None
}
}
fn pass_as_single_jsonb(mutation: &MutationDefinition, schema: &CompiledSchema) -> bool {
let input_type_name = single_input_type_name(mutation);
let input_arg_is_structured = input_type_name.is_some();
let is_update = matches!(&mutation.operation, MutationOperation::Update { .. });
let jsonb_input_style = matches!(mutation.input_style, InputStyle::Jsonb);
let known_input_type = input_type_name.and_then(|n| schema.find_input_type(n)).is_some();
input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type)
}
fn resolve_sql_source(mutation: &MutationDefinition) -> Option<String> {
if let Some(src) = &mutation.sql_source {
return Some(src.clone());
}
match &mutation.operation {
MutationOperation::Insert { table }
| MutationOperation::Update { table }
| MutationOperation::Delete { table }
if !table.is_empty() =>
{
Some(table.clone())
},
_ => None,
}
}
#[must_use]
pub fn check_mutation(
expected: &ExpectedCall,
candidates: &[PgFunction],
) -> Vec<ContractViolation> {
let mut violations = Vec::new();
if candidates.is_empty() {
violations.push(ContractViolation::MissingFunction);
return violations;
}
let want = expected.total_arity();
let matched: Vec<&PgFunction> =
candidates.iter().filter(|f| f.in_types.len() == want).collect();
let func = match matched.as_slice() {
[] => {
let mut found: Vec<usize> = candidates.iter().map(|f| f.in_types.len()).collect();
found.sort_unstable();
found.dedup();
violations.push(ContractViolation::ArityMismatch {
expected: want,
found,
});
return violations;
},
[one] => *one,
many => {
violations.push(ContractViolation::AmbiguousFunction {
arity: want,
count: many.len(),
});
return violations;
},
};
if expected.first_is_jsonb_payload {
if let Some(first) = func.in_types.first() {
if !is_jsonb(first) {
violations.push(ContractViolation::PayloadNotJsonb {
actual: first.clone(),
});
}
}
}
if !expected.inject_names.is_empty() {
let start = func.in_types.len().saturating_sub(expected.inject_names.len());
for (position, want_name) in expected.inject_names.iter().enumerate() {
if let Some(Some(actual)) = func.in_names.get(start + position) {
if actual != want_name {
violations.push(ContractViolation::InjectNameMismatch {
position,
expected: want_name.clone(),
actual: actual.clone(),
});
}
}
}
}
check_response_shape(func, &mut violations);
violations
}
const OPTIONAL_COLUMNS: &[(&str, &str)] = &[
("error_class", "text or enum"),
("status_detail", "text"),
("http_status", "an integer type"),
("message", "text"),
("entity_id", "uuid"),
("entity_type", "text"),
("entity", "jsonb"),
("updated_fields", "a text array"),
("cascade", "jsonb"),
("error_detail", "jsonb"),
("metadata", "jsonb"),
];
fn check_response_shape(func: &PgFunction, violations: &mut Vec<ContractViolation>) {
if func.out_columns.is_empty() {
violations.push(ContractViolation::ResponseShapeUnverifiable);
return;
}
let find = |name: &str| func.out_columns.iter().find(|c| c.name == name);
for column in ["succeeded", "state_changed"] {
match find(column) {
None => violations.push(ContractViolation::MissingRequiredColumn { column }),
Some(c) if !is_bool(&c.type_name) => {
violations.push(ContractViolation::RequiredColumnWrongType {
column,
actual: c.type_name.clone(),
});
},
Some(_) => {},
}
}
for &(column, expected) in OPTIONAL_COLUMNS {
if let Some(c) = find(column) {
if !optional_column_ok(column, c.is_enum, &c.type_name) {
violations.push(ContractViolation::OptionalColumnWrongType {
column,
expected,
actual: c.type_name.clone(),
});
}
}
}
}
fn optional_column_ok(column: &str, is_enum: bool, type_name: &str) -> bool {
match column {
"error_class" => is_enum || is_text(type_name),
"status_detail" | "message" | "entity_type" => is_text(type_name),
"http_status" => is_int(type_name),
"entity_id" => type_name == "uuid",
"updated_fields" => type_name.ends_with("[]"),
"entity" | "cascade" | "error_detail" | "metadata" => is_jsonb(type_name),
_ => true,
}
}
fn is_bool(type_name: &str) -> bool {
type_name == "boolean"
}
fn is_jsonb(type_name: &str) -> bool {
type_name == "jsonb" || type_name == "json"
}
fn is_int(type_name: &str) -> bool {
matches!(type_name, "smallint" | "integer" | "bigint")
}
fn is_text(type_name: &str) -> bool {
matches!(type_name, "text" | "varchar" | "name" | "bpchar" | "citext")
|| type_name.starts_with("character varying")
|| type_name.starts_with("character(")
|| type_name == "character"
}
#[derive(Debug, Clone)]
pub struct MutationReport {
pub mutation: String,
pub sql_source: String,
pub violations: Vec<ContractViolation>,
}
#[derive(Debug, Clone, Default)]
pub struct ContractReport {
pub checked: usize,
pub skipped: usize,
pub mutations: Vec<MutationReport>,
}
impl ContractReport {
#[must_use]
pub fn error_count(&self) -> usize {
self.count(Severity::Error)
}
#[must_use]
pub fn warn_count(&self) -> usize {
self.count(Severity::Warn)
}
fn count(&self, severity: Severity) -> usize {
self.mutations
.iter()
.flat_map(|m| &m.violations)
.filter(|v| v.severity() == severity)
.count()
}
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
let mutations: Vec<serde_json::Value> = self
.mutations
.iter()
.map(|m| {
let findings: Vec<serde_json::Value> = m
.violations
.iter()
.map(|v| {
serde_json::json!({
"severity": match v.severity() {
Severity::Error => "error",
Severity::Warn => "warning",
},
"message": v.to_string(),
})
})
.collect();
serde_json::json!({
"mutation": m.mutation,
"sqlSource": m.sql_source,
"findings": findings,
})
})
.collect();
serde_json::json!({
"checked": self.checked,
"skipped": self.skipped,
"errors": self.error_count(),
"warnings": self.warn_count(),
"mutations": mutations,
})
}
pub fn print_text(&self) {
println!("\nChecking mutation contract against the database...\n");
if self.mutations.is_empty() {
println!(
" All {} DB-backed mutation(s) match the database contract ({} skipped).",
self.checked, self.skipped
);
return;
}
for m in &self.mutations {
println!(" {} (sql_source: {})", m.mutation, m.sql_source);
for v in &m.violations {
let symbol = match v.severity() {
Severity::Error => "✗",
Severity::Warn => "!",
};
println!(" [{symbol}] {v}");
}
}
println!(
"\nSummary: {} error(s), {} warning(s) across {} checked mutation(s) ({} skipped).",
self.error_count(),
self.warn_count(),
self.checked,
self.skipped,
);
}
}
pub async fn validate_mutation_contract(
schema: &CompiledSchema,
catalog: &PgCatalog,
) -> Result<ContractReport> {
let mut report = ContractReport::default();
for mutation in &schema.mutations {
let Some(expected) = expected_call(mutation, schema) else {
report.skipped += 1;
continue;
};
report.checked += 1;
let candidates = catalog.resolve_functions(&expected.sql_source).await?;
let violations = check_mutation(&expected, &candidates);
if !violations.is_empty() {
report.mutations.push(MutationReport {
mutation: mutation.name.clone(),
sql_source: expected.sql_source.clone(),
violations,
});
}
}
Ok(report)
}