use std::{fmt, sync::LazyLock};
use anyhow::Result;
use fraiseql_core::{
runtime::StampContract,
schema::{CompiledSchema, FieldType, InputStyle, MutationDefinition, MutationOperation},
};
use regex::Regex;
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,
pub payload_keys: Vec<String>,
pub stamps: Option<StampContract>,
pub requires_result: 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,
},
MissingResultColumn,
ResultColumnWrongType {
actual: String,
},
OptionalColumnWrongType {
column: &'static str,
expected: &'static str,
actual: String,
},
PayloadKeyUnreferenced {
field: String,
snake_key: String,
},
ResponseShapeUnverifiable,
OffContractStamp {
arm: StampArm,
stamp: String,
allowed: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StampArm {
Success,
Error,
Either,
}
impl fmt::Display for StampArm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Success => "a success",
Self::Error => "a failure",
Self::Either => "the result",
})
}
}
impl ContractViolation {
#[must_use]
pub const fn severity(&self) -> Severity {
match self {
Self::MissingFunction
| Self::ArityMismatch { .. }
| Self::AmbiguousFunction { .. }
| Self::PayloadNotJsonb { .. }
| Self::MissingRequiredColumn { .. }
| Self::RequiredColumnWrongType { .. }
| Self::MissingResultColumn
| Self::ResultColumnWrongType { .. }
| Self::OffContractStamp { .. } => Severity::Error,
Self::InjectNameMismatch { .. }
| Self::OptionalColumnWrongType { .. }
| Self::PayloadKeyUnreferenced { .. }
| 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::PayloadKeyUnreferenced { field, snake_key } => {
let probed = if field == snake_key {
format!("'{field}'")
} else {
format!("'{field}' / '{snake_key}'")
};
write!(
f,
"input field `{field}`: the function body extracts other payload keys but \
never references {probed} — the value would be silently dropped (text scan)"
)
},
Self::MissingResultColumn => write!(
f,
"the mutation declares success fields, but its response row has no `result \
jsonb` column to carry them: declare `result jsonb` after the 13 \
mutation_response columns in this function's own row type, and build the row \
with `fraiseql.mutation_ok_result(…)` / `mutation_err_result(…)` (helpers \
2.4.0, `fraiseql setup`)"
),
Self::ResultColumnWrongType { actual } => write!(
f,
"the mutation declares success fields, so its response column `result` must \
be jsonb, but is `{actual}`"
),
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")
},
Self::OffContractStamp {
arm,
stamp,
allowed,
} => write!(
f,
"the function stamps entity_type '{stamp}' on {arm}, which this mutation cannot \
return (it can return: {}) — the server refuses such a write as a contract \
error and rolls it back",
if allowed.is_empty() {
"nothing".to_string()
} else {
allowed.join(", ")
}
),
}
}
}
#[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)
};
let payload_keys = if matches!(shape, CallShape::JsonbPayload) {
input_type_name
.and_then(|n| schema.find_input_type(n))
.map(|t| t.fields.iter().map(|f| f.name.clone()).collect())
.unwrap_or_default()
} else {
Vec::new()
};
Some(ExpectedCall {
sql_source,
shape,
base_arity,
inject_names: mutation.inject_params.keys().cloned().collect(),
first_is_jsonb_payload,
payload_keys,
stamps: Some(StampContract::of(schema, mutation)),
requires_result: !mutation.success_fields.is_empty(),
})
}
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(),
});
}
}
}
check_payload_key_consumption(expected, func, &mut violations);
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(expected, func, &mut violations);
check_literal_stamps(expected, func, &mut violations);
violations
}
fn check_literal_stamps(
expected: &ExpectedCall,
func: &PgFunction,
violations: &mut Vec<ContractViolation>,
) {
let Some(contract) = &expected.stamps else {
return;
};
if !matches!(func.language.as_str(), "plpgsql" | "sql") {
return;
}
let mut seen = Vec::new();
for (arm, stamp) in literal_stamps(&func.source) {
let allowed: Vec<String> = match arm {
StampArm::Success => contract.success.clone(),
StampArm::Error => contract.error.clone(),
StampArm::Either => {
let mut both = contract.success.clone();
both.extend(
contract.error.iter().filter(|t| !contract.success.contains(t)).cloned(),
);
both
},
};
if allowed.contains(&stamp) || seen.contains(&(arm, stamp.clone())) {
continue;
}
seen.push((arm, stamp.clone()));
violations.push(ContractViolation::OffContractStamp {
arm,
stamp,
allowed,
});
}
}
const STAMPING_HELPERS: &[(&str, usize, StampArm)] = &[
("mutation_ok", 2, StampArm::Success),
("mutation_err", 4, StampArm::Error),
];
static HELPER_CALL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(?:\bfraiseql\s*\.\s*|[^A-Za-z0-9_.$]|^)(mutation_ok|mutation_err)\s*\(")
.expect("helper call regex is valid")
});
static STAMP_ASSIGNMENT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b[A-Za-z_][A-Za-z0-9_$]*\s*\.\s*entity_type\s*:=\s*('(?:[^']|'')*')")
.expect("stamp assignment regex is valid")
});
fn literal_stamps(source: &str) -> Vec<(StampArm, String)> {
let (code, in_string) = mask_comments_and_strings(source);
let mut stamps = Vec::new();
for call in HELPER_CALL.captures_iter(&code) {
let (Some(name), Some(whole)) = (call.get(1), call.get(0)) else {
continue;
};
if in_string[name.start()] {
continue;
}
let Some(&(_, position, arm)) =
STAMPING_HELPERS.iter().find(|(h, ..)| h.eq_ignore_ascii_case(name.as_str()))
else {
continue;
};
let args = call_arguments(&code, &in_string, whole.end());
let stamp_arg =
args.iter().enumerate().find_map(|(index, arg)| match named_argument(arg) {
Some((param, value)) => {
param.eq_ignore_ascii_case("p_entity_type").then_some(value)
},
None => (index == position).then_some(arg.as_str()),
});
if let Some(stamp) = stamp_arg.and_then(string_literal) {
stamps.push((arm, stamp));
}
}
for assignment in STAMP_ASSIGNMENT.captures_iter(&code) {
let Some(literal) = assignment.get(1) else {
continue;
};
let start = assignment.get(0).map_or(literal.start(), |m| m.start());
if in_string[start] {
continue;
}
if let Some(stamp) = string_literal(literal.as_str()) {
stamps.push((StampArm::Either, stamp));
}
}
stamps
}
fn mask_comments_and_strings(source: &str) -> (String, Vec<bool>) {
let bytes = source.as_bytes();
let mut code = bytes.to_vec();
let mut in_string = vec![false; bytes.len()];
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'-' if bytes.get(i + 1) == Some(&b'-') => {
while i < bytes.len() && bytes[i] != b'\n' {
code[i] = b' ';
i += 1;
}
},
b'/' if bytes.get(i + 1) == Some(&b'*') => {
let mut depth = 0usize;
while i < bytes.len() {
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
depth += 1;
code[i] = b' ';
code[i + 1] = b' ';
i += 2;
} else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
depth -= 1;
code[i] = b' ';
code[i + 1] = b' ';
i += 2;
if depth == 0 {
break;
}
} else {
if bytes[i] != b'\n' {
code[i] = b' ';
}
i += 1;
}
}
},
b'\'' => {
i += 1;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
in_string[i] = true;
in_string[i + 1] = true;
i += 2;
continue;
}
i += 1;
break;
}
in_string[i] = true;
i += 1;
}
},
b'$' => {
let tag_end = bytes[i + 1..]
.iter()
.position(|b| !(b.is_ascii_alphanumeric() || *b == b'_'))
.map(|n| i + 1 + n);
match tag_end {
Some(end) if bytes[end] == b'$' => {
let tag = &bytes[i..=end];
let body_start = end + 1;
let close = bytes[body_start..]
.windows(tag.len())
.position(|w| w == tag)
.map_or(bytes.len(), |n| body_start + n);
for flag in &mut in_string[body_start..close] {
*flag = true;
}
i = (close + tag.len()).min(bytes.len());
},
_ => i += 1,
}
},
_ => i += 1,
}
}
(String::from_utf8(code).unwrap_or_else(|_| source.to_string()), in_string)
}
fn call_arguments(code: &str, in_string: &[bool], open_end: usize) -> Vec<String> {
let bytes = code.as_bytes();
let mut args = Vec::new();
let mut depth = 0usize;
let mut start = open_end;
let mut i = open_end;
while i < bytes.len() {
if !in_string[i] {
match bytes[i] {
b'(' | b'[' => depth += 1,
b')' | b']' if depth > 0 => depth -= 1,
b')' => {
args.push(code[start..i].trim().to_string());
break;
},
b',' if depth == 0 => {
args.push(code[start..i].trim().to_string());
start = i + 1;
},
_ => {},
}
}
i += 1;
}
args
}
fn named_argument(arg: &str) -> Option<(&str, &str)> {
let at = arg.find("=>").or_else(|| arg.find(":="))?;
let param = arg[..at].trim();
let is_identifier = !param.is_empty()
&& param.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$');
is_identifier.then(|| (param, arg[at + 2..].trim()))
}
fn string_literal(expr: &str) -> Option<String> {
let rest = expr.trim().strip_prefix('\'')?;
let mut value = String::new();
let mut chars = rest.char_indices();
let close = loop {
let (at, c) = chars.next()?;
if c != '\'' {
value.push(c);
} else if rest[at + 1..].starts_with('\'') {
value.push('\'');
chars.next();
} else {
break at;
}
};
let tail = rest[close + 1..].trim().to_ascii_lowercase();
let plain = tail.is_empty()
|| tail.strip_prefix("::").is_some_and(|t| matches!(t.trim(), "text" | "varchar"));
plain.then_some(value)
}
fn check_payload_key_consumption(
expected: &ExpectedCall,
func: &PgFunction,
violations: &mut Vec<ContractViolation>,
) {
const WHOLE_PAYLOAD_MARKERS: &[&str] = &[
"jsonb_populate_record",
"json_populate_record",
"jsonb_to_record",
"jsonb_each",
"jsonb_object_keys",
];
const EXTRACTION_OPS: &[&str] = &["->", "#>", "?"];
if !expected.first_is_jsonb_payload || expected.payload_keys.is_empty() {
return;
}
if !matches!(func.language.as_str(), "plpgsql" | "sql") {
return;
}
if WHOLE_PAYLOAD_MARKERS.iter().any(|m| func.source.contains(m)) {
return;
}
if !EXTRACTION_OPS.iter().any(|op| func.source.contains(op)) {
return;
}
for key in &expected.payload_keys {
let snake_key = fraiseql_core::utils::to_snake_case(key);
let quoted = format!("'{key}'");
let quoted_snake = format!("'{snake_key}'");
if !func.source.contains("ed) && !func.source.contains("ed_snake) {
violations.push(ContractViolation::PayloadKeyUnreferenced {
field: key.clone(),
snake_key,
});
}
}
}
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(
expected: &ExpectedCall,
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(_) => {},
}
}
if expected.requires_result {
match find("result") {
None => violations.push(ContractViolation::MissingResultColumn),
Some(c) if !is_jsonb(&c.type_name) => {
violations.push(ContractViolation::ResultColumnWrongType {
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)
}