mod derived_cascade;
mod invalidation;
mod payload_gates;
use std::sync::Arc;
use fraiseql_db::{ChangeLogWrite, ViewName};
pub use payload_gates::{StampContract, mutation_contract_errors};
use self::payload_gates::{PayloadGates, PayloadPosition};
use super::{
super::{context::ExecutorContext, mutation::WriteSelections, resolve_mutation_inject_value},
query_projection::selections_contain_field,
};
use crate::{
error::{FraiseQLError, Result},
graphql::{DirectiveEvaluator, FieldSelection},
runtime::{
ResultProjector,
mutation_result::{MutationOutcome, parse_mutation_row},
projection::effective_selections,
suggest_similar,
},
schema::{CompiledSchema, InputStyle, MutationOperation, NamingConvention},
security::SecurityContext,
};
#[allow(clippy::too_many_arguments)]
fn serve_entity(
ctx: &ExecutorContext,
security_ctx: Option<&SecurityContext>,
gates: &PayloadGates,
position: PayloadPosition,
type_name: &str,
selections: &[FieldSelection],
entity: &serde_json::Value,
variables: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value> {
gates.serve(ctx, security_ctx, position, type_name, selections, entity, variables)
}
const CASCADE_UPDATES_TYPE: &str = "CascadeUpdates";
const CASCADE_METADATA_TYPE: &str = "CascadeMetadata";
const QUERY_INVALIDATION_TYPE: &str = "QueryInvalidation";
const UPDATED_ENTITY_TYPE: &str = "UpdatedEntity";
const DELETED_ENTITY_TYPE: &str = "DeletedEntity";
const MIB: usize = 1024 * 1024;
fn resolve_payload_type(return_type: &str, schema: &CompiledSchema) -> String {
schema
.find_union(return_type)
.and_then(|u| {
u.member_types
.iter()
.find(|t| schema.find_type(t).is_none_or(|td| !td.is_error))
.cloned()
})
.unwrap_or_else(|| return_type.to_string())
}
fn resolve_cascade_views(cascade: &serde_json::Value, schema: &CompiledSchema) -> Vec<ViewName> {
let mut seen = std::collections::HashSet::new();
let mut views = Vec::new();
let mut push = |src: &str| {
if !src.is_empty() && seen.insert(src.to_string()) {
views.push(ViewName::from(src));
}
};
let entries = |arm: &str| {
cascade
.get(arm)
.and_then(serde_json::Value::as_array)
.map_or(&[][..], Vec::as_slice)
};
for arm in ["updated", "deleted"] {
for entry in entries(arm) {
let Some(typename) = entry.get("__typename").and_then(serde_json::Value::as_str) else {
continue;
};
if let Some(td) = schema.types.iter().find(|t| t.name.as_str() == typename) {
push(td.sql_source.as_str());
}
}
}
for hint in entries("invalidations") {
let Some(name) = hint.get("queryName").and_then(serde_json::Value::as_str) else {
continue;
};
if let Some(query) = schema.queries.iter().find(|q| q.name == name) {
for view in query.sql_source.iter().chain(&query.additional_views) {
push(view);
}
}
}
views
}
fn unstamped_type(schema: &CompiledSchema, return_type: &str, is_cascade: bool) -> Option<String> {
if is_cascade {
return payload_entity_type(&resolve_payload_type(return_type, schema), schema);
}
match schema.success_types(return_type).as_slice() {
[only] => Some(only.clone()),
_ => None,
}
}
fn unstamped_ambiguity(return_type: &str, candidates: &[String]) -> FraiseQLError {
payload_gates::contract_error(format!(
"the mutation function did not stamp entity_type, and a '{return_type}' success can be \
any of: {} — stamp the one it produced; the write was rolled back",
candidates.join(", ")
))
}
fn unstamped_error_ambiguity(return_type: &str, candidates: &[String]) -> FraiseQLError {
payload_gates::contract_error(format!(
"the mutation function did not stamp entity_type on a failure, and a '{return_type}' \
failure can be any of: {} — stamp the one it produced \
(`fraiseql.mutation_err(…, p_entity_type => '<ErrorType>')`); the write was rolled \
back",
candidates.join(", ")
))
}
fn payload_entity_type(payload_type: &str, schema: &CompiledSchema) -> Option<String> {
schema
.find_type(payload_type)?
.fields
.iter()
.find(|f| f.name.as_str() == "entity")?
.field_type
.type_name()
.map(std::string::ToString::to_string)
}
#[allow(clippy::too_many_arguments)]
fn build_cascade_payload(
ctx: &ExecutorContext,
security_ctx: Option<&SecurityContext>,
gates: &PayloadGates,
payload_type: &str,
entity_type: &str,
entity: &serde_json::Value,
cascade: Option<&serde_json::Value>,
updated_fields: &[String],
result: Option<&serde_json::Value>,
selections: &[FieldSelection],
variables: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value> {
let declared = ctx.schema.find_type(payload_type);
for field in declared.iter().flat_map(|t| &t.fields) {
let name = field.name.as_str();
if PAYLOAD_OWN_FIELDS.contains(&name) {
continue;
}
if result.is_none() {
return Err(payload_gates::contract_error_in(
"result",
format!(
"the mutation declares success field `{name}`, but its function's response \
row has no `result` column; the write was rolled back"
),
));
}
success_field_value_ok(&ctx.schema, field, &success_value(result, name)).map_err(
|held| {
payload_gates::contract_error_in(
"result",
format!(
"the mutation function returned {held} for success field `{name}` ({}); \
the write was rolled back",
field.field_type
),
)
},
)?;
}
let mut out = serde_json::Map::new();
for sel in effective_selections(selections, payload_type, &ctx.schema) {
match sel.name.as_str() {
"__typename" => {
out.insert(
sel.response_key().to_string(),
serde_json::Value::String(payload_type.to_string()),
);
},
"entity" => {
let projected = serve_entity(
ctx,
security_ctx,
gates,
PayloadPosition::CascadeEntity,
entity_type,
&sel.nested_fields,
entity,
variables,
)?;
out.insert(sel.response_key().to_string(), projected);
},
"cascade" => {
let built = build_cascade_updates(
ctx,
security_ctx,
gates,
cascade,
&sel.nested_fields,
variables,
)?;
out.insert(sel.response_key().to_string(), built);
},
"updatedFields" => {
out.insert(
sel.response_key().to_string(),
serde_json::Value::Array(
updated_fields.iter().cloned().map(serde_json::Value::String).collect(),
),
);
},
name => {
if declared.is_some_and(|t| t.fields.iter().any(|f| f.name.as_str() == name)) {
out.insert(sel.response_key().to_string(), success_value(result, name));
}
},
}
}
Ok(serde_json::Value::Object(out))
}
const PAYLOAD_OWN_FIELDS: [&str; 3] = ["entity", "cascade", "updatedFields"];
fn success_value(result: Option<&serde_json::Value>, name: &str) -> serde_json::Value {
result
.and_then(|r| r.get(crate::utils::casing::to_snake_case(name)))
.cloned()
.unwrap_or(serde_json::Value::Null)
}
fn success_field_value_ok(
schema: &CompiledSchema,
field: &crate::schema::FieldDefinition,
value: &serde_json::Value,
) -> std::result::Result<(), String> {
use serde_json::Value;
use crate::schema::FieldType;
let ok = match (&field.field_type, value) {
(_, Value::Null) => field.nullable,
(FieldType::Int, Value::Number(n)) => n.as_i64().is_some_and(|i| i32::try_from(i).is_ok()),
(FieldType::Float | FieldType::Decimal, Value::Number(_))
| (
FieldType::String
| FieldType::Id
| FieldType::DateTime
| FieldType::Date
| FieldType::Time
| FieldType::Uuid
| FieldType::Decimal
| FieldType::BitVector
| FieldType::SparseVector,
Value::String(_),
)
| (FieldType::Boolean, Value::Bool(_))
| (FieldType::Json, _) => true,
(FieldType::Id, Value::Number(n)) => n.is_i64() || n.is_u64(),
(FieldType::Vector | FieldType::HalfVector, Value::Array(items)) => {
items.iter().all(Value::is_number)
},
(FieldType::Enum(name), Value::String(s)) => {
schema.find_enum(name).is_some_and(|e| e.values.iter().any(|v| v.name == *s))
},
(FieldType::Scalar(name), _) => {
!crate::schema::RICH_SCALARS.contains(&name.as_str())
|| !matches!(value, Value::Array(_) | Value::Object(_))
},
(
FieldType::Int
| FieldType::Float
| FieldType::String
| FieldType::Boolean
| FieldType::Id
| FieldType::DateTime
| FieldType::Date
| FieldType::Time
| FieldType::Uuid
| FieldType::Decimal
| FieldType::Vector
| FieldType::BitVector
| FieldType::HalfVector
| FieldType::SparseVector
| FieldType::Enum(_)
| FieldType::List { item: _, .. }
| FieldType::Object(_)
| FieldType::Input(_)
| FieldType::Interface(_)
| FieldType::Union(_),
_,
) => false,
};
if ok {
return Ok(());
}
Err(match value {
Value::String(_) => "a string",
Value::Number(_) => "a number",
Value::Bool(_) => "a boolean",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
Value::Null => "no value",
}
.to_string())
}
fn build_cascade_updates(
ctx: &ExecutorContext,
security_ctx: Option<&SecurityContext>,
gates: &PayloadGates,
cascade: Option<&serde_json::Value>,
selections: &[FieldSelection],
variables: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value> {
let cascade_obj = cascade.and_then(serde_json::Value::as_object);
let empty: Vec<serde_json::Value> = Vec::new();
let updated: &[serde_json::Value] = cascade_obj
.and_then(|c| c.get("updated"))
.and_then(serde_json::Value::as_array)
.map_or(empty.as_slice(), Vec::as_slice);
let deleted: &[serde_json::Value] = cascade_obj
.and_then(|c| c.get("deleted"))
.and_then(serde_json::Value::as_array)
.map_or(empty.as_slice(), Vec::as_slice);
let limits = ctx.config.cascade_limits;
let total = updated.len() + deleted.len();
let truncated = total > limits.max_updated_entities;
let (updated, deleted) = if truncated {
let updated_keep = updated.len().min(limits.max_updated_entities);
let deleted_keep = deleted.len().min(limits.max_updated_entities - updated_keep);
(&updated[..updated_keep], &deleted[..deleted_keep])
} else {
(updated, deleted)
};
let affected_count = updated.len() + deleted.len();
let mut out = serde_json::Map::new();
for sel in effective_selections(selections, CASCADE_UPDATES_TYPE, &ctx.schema) {
match sel.name.as_str() {
"__typename" => {
out.insert(
sel.response_key().to_string(),
serde_json::Value::String(CASCADE_UPDATES_TYPE.to_string()),
);
},
"updated" => {
let arr = build_updated_entities(
ctx,
security_ctx,
gates,
updated,
&sel.nested_fields,
variables,
)?;
out.insert(sel.response_key().to_string(), arr);
},
"deleted" => {
let arr = build_deleted_entities(deleted, &sel.nested_fields, &ctx.schema)?;
out.insert(sel.response_key().to_string(), arr);
},
"metadata" => {
let meta = build_cascade_metadata(
&ctx.schema,
cascade_obj.and_then(|c| c.get("metadata")),
affected_count,
truncated,
truncated.then_some(total),
&sel.nested_fields,
);
out.insert(sel.response_key().to_string(), meta);
},
"invalidations" => {
let arr = build_invalidations(
&ctx.schema,
cascade_obj.and_then(|c| c.get("invalidations")),
&sel.nested_fields,
);
out.insert(sel.response_key().to_string(), arr);
},
_ => {},
}
}
let cascade_value = serde_json::Value::Object(out);
let max_bytes = limits.max_response_size_mb.saturating_mul(MIB);
if max_bytes > 0 {
let size = serde_json::to_vec(&cascade_value).map_or(0, |v| v.len());
if size > max_bytes {
return Err(FraiseQLError::Validation {
message: format!(
"cascade response too large: {size} bytes exceeds the \
max_response_size_mb={} ceiling",
limits.max_response_size_mb
),
path: Some("cascade".to_string()),
});
}
}
Ok(cascade_value)
}
fn build_cascade_metadata(
schema: &CompiledSchema,
db_metadata: Option<&serde_json::Value>,
affected_count: usize,
truncated: bool,
original_count: Option<usize>,
selections: &[FieldSelection],
) -> serde_json::Value {
let db = db_metadata.and_then(serde_json::Value::as_object);
let mut out = serde_json::Map::new();
for sel in effective_selections(selections, CASCADE_METADATA_TYPE, schema) {
let key = sel.response_key().to_string();
match sel.name.as_str() {
"__typename" => {
out.insert(key, serde_json::Value::String(CASCADE_METADATA_TYPE.to_string()));
},
"affectedCount" => {
out.insert(key, serde_json::Value::from(affected_count as u64));
},
"truncated" => {
out.insert(key, serde_json::Value::Bool(truncated));
},
"originalCount" => {
if let Some(oc) = original_count {
out.insert(key, serde_json::Value::from(oc as u64));
}
},
"depth" => {
let depth = db
.and_then(|m| m.get("depth"))
.cloned()
.unwrap_or_else(|| serde_json::Value::from(0u64));
out.insert(key, depth);
},
"timestamp" => {
let ts = db
.and_then(|m| m.get("timestamp"))
.cloned()
.unwrap_or_else(|| serde_json::Value::String(chrono::Utc::now().to_rfc3339()));
out.insert(key, ts);
},
"transactionId" => {
if let Some(v) =
db.and_then(|m| m.get("transactionId").or_else(|| m.get("transaction_id")))
{
out.insert(key, v.clone());
}
},
_ => {},
}
}
serde_json::Value::Object(out)
}
fn build_invalidations(
schema: &CompiledSchema,
entries: Option<&serde_json::Value>,
selections: &[FieldSelection],
) -> serde_json::Value {
let Some(arr) = entries.and_then(serde_json::Value::as_array) else {
return serde_json::Value::Array(Vec::new());
};
let effective = effective_selections(selections, QUERY_INVALIDATION_TYPE, schema);
let mut result = Vec::with_capacity(arr.len());
for entry in arr {
let Some(entry_obj) = entry.as_object() else {
continue;
};
let mut item = serde_json::Map::new();
for sel in &effective {
if sel.name == "__typename" {
item.insert(
sel.response_key().to_string(),
serde_json::Value::String(QUERY_INVALIDATION_TYPE.to_string()),
);
} else if let Some(v) = entry_obj.get(sel.name.as_str()) {
item.insert(sel.response_key().to_string(), v.clone());
}
}
result.push(serde_json::Value::Object(item));
}
serde_json::Value::Array(result)
}
fn build_updated_entities(
ctx: &ExecutorContext,
security_ctx: Option<&SecurityContext>,
gates: &PayloadGates,
entries: &[serde_json::Value],
selections: &[FieldSelection],
variables: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value> {
let effective = effective_selections(selections, UPDATED_ENTITY_TYPE, &ctx.schema);
let mut result = Vec::with_capacity(entries.len());
for entry in entries {
let Some(entry_obj) = entry.as_object() else {
return Err(FraiseQLError::Validation {
message: "cascade.updated entry is not an object".to_string(),
path: Some("cascade.updated".to_string()),
});
};
let Some(typename) = entry_obj.get("__typename").and_then(serde_json::Value::as_str) else {
return Err(FraiseQLError::Validation {
message: "cascade.updated entry is missing __typename".to_string(),
path: Some("cascade.updated.__typename".to_string()),
});
};
match ctx.schema.find_type(typename) {
None => {
return Err(FraiseQLError::Validation {
message: format!("cascade.updated entry has unknown __typename '{typename}'"),
path: Some("cascade.updated.__typename".to_string()),
});
},
Some(ty) if ty.internal => {
return Err(FraiseQLError::Validation {
message: format!(
"cascade.updated entry names framework-internal type '{typename}', a \
bookkeeping projection that is not a cascade-deliverable entity"
),
path: Some("cascade.updated.__typename".to_string()),
});
},
Some(_) => {},
}
if !entry_obj.contains_key("id") {
return Err(FraiseQLError::Validation {
message: "cascade.updated entry is missing id".to_string(),
path: Some("cascade.updated.id".to_string()),
});
}
match entry_obj.get("operation").and_then(serde_json::Value::as_str) {
Some("CREATED" | "UPDATED" | "DELETED") => {},
other => {
return Err(FraiseQLError::Validation {
message: format!(
"cascade.updated entry has a missing or invalid operation: {other:?} \
(expected CREATED, UPDATED, or DELETED)"
),
path: Some("cascade.updated.operation".to_string()),
});
},
}
let mut item = serde_json::Map::new();
for sel in &effective {
match sel.name.as_str() {
"__typename" => {
item.insert(
sel.response_key().to_string(),
serde_json::Value::String(UPDATED_ENTITY_TYPE.to_string()),
);
},
"id" => {
if let Some(id) = entry_obj.get("id") {
item.insert(sel.response_key().to_string(), id.clone());
}
},
"operation" => {
if let Some(op) = entry_obj.get("operation") {
item.insert(sel.response_key().to_string(), op.clone());
}
},
"entity" => {
let entity_blob =
entry_obj.get("entity").cloned().unwrap_or(serde_json::Value::Null);
let projected = serve_entity(
ctx,
security_ctx,
gates,
PayloadPosition::UpdatedEntity,
typename,
&sel.nested_fields,
&entity_blob,
variables,
)?;
item.insert(sel.response_key().to_string(), projected);
},
_ => {},
}
}
result.push(serde_json::Value::Object(item));
}
Ok(serde_json::Value::Array(result))
}
fn build_deleted_entities(
entries: &[serde_json::Value],
selections: &[FieldSelection],
schema: &CompiledSchema,
) -> Result<serde_json::Value> {
let effective = effective_selections(selections, DELETED_ENTITY_TYPE, schema);
let mut result = Vec::with_capacity(entries.len());
for entry in entries {
let Some(entry_obj) = entry.as_object() else {
return Err(FraiseQLError::Validation {
message: "cascade.deleted entry is not an object".to_string(),
path: Some("cascade.deleted".to_string()),
});
};
if !entry_obj.contains_key("id") {
return Err(FraiseQLError::Validation {
message: "cascade.deleted entry is missing id".to_string(),
path: Some("cascade.deleted.id".to_string()),
});
}
let deleted_at = entry_obj.get("deletedAt").or_else(|| entry_obj.get("deleted_at"));
let Some(deleted_at) = deleted_at else {
return Err(FraiseQLError::Validation {
message: "cascade.deleted entry is missing deletedAt".to_string(),
path: Some("cascade.deleted.deletedAt".to_string()),
});
};
let mut item = serde_json::Map::new();
for sel in &effective {
match sel.name.as_str() {
"__typename" => {
item.insert(
sel.response_key().to_string(),
serde_json::Value::String(DELETED_ENTITY_TYPE.to_string()),
);
},
"id" => {
if let Some(id) = entry_obj.get("id") {
item.insert(sel.response_key().to_string(), id.clone());
}
},
"deletedAt" => {
item.insert(sel.response_key().to_string(), deleted_at.clone());
},
_ => {},
}
}
result.push(serde_json::Value::Object(item));
}
Ok(serde_json::Value::Array(result))
}
pub(in super::super) struct MutationRunner {
ctx: Arc<ExecutorContext>,
}
impl MutationRunner {
pub(in super::super) const fn new(ctx: Arc<ExecutorContext>) -> Self {
Self { ctx }
}
pub(in super::super) async fn execute_mutation(
&self,
mutation_name: &str,
variables: Option<&serde_json::Value>,
selections: WriteSelections<'_>,
) -> Result<serde_json::Value> {
execute_mutation_impl(
&self.ctx,
mutation_name,
mutation_name,
variables,
None,
selections,
&[],
)
.await
.map(|execution| execution.data)
}
}
fn recase_input_payload(
value: serde_json::Value,
input_type_name: Option<&str>,
schema: &CompiledSchema,
) -> serde_json::Value {
if schema.naming_convention != NamingConvention::CamelCase {
return value;
}
let Some(input_type) = input_type_name.and_then(|n| schema.find_input_type(n)) else {
return recase_jsonb_keys_to_snake(value);
};
match value {
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (key, val) in map {
let field = input_type.fields.iter().find(|f| schema.display_name(&f.name) == key);
let canonical = field.map_or_else(
|| crate::utils::to_snake_case(&key),
|f| crate::utils::to_snake_case(&f.name),
);
let recased = match field {
Some(f) => recase_input_field_value(val, &f.field_type, schema),
None => val,
};
out.insert(canonical, recased);
}
serde_json::Value::Object(out)
},
other => other,
}
}
fn recase_input_field_value(
value: serde_json::Value,
field_type: &str,
schema: &CompiledSchema,
) -> serde_json::Value {
let Some(nested) = nested_input_type_name(field_type, schema) else {
return value;
};
match value {
serde_json::Value::Object(_) => recase_input_payload(value, Some(nested.as_str()), schema),
serde_json::Value::Array(items) => serde_json::Value::Array(
items
.into_iter()
.map(|it| match it {
serde_json::Value::Object(_) => {
recase_input_payload(it, Some(nested.as_str()), schema)
},
other => other,
})
.collect(),
),
other => other,
}
}
fn recase_jsonb_keys_to_snake(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut out = serde_json::Map::with_capacity(map.len());
for (key, val) in map {
out.insert(crate::utils::to_snake_case(&key), recase_jsonb_keys_to_snake(val));
}
serde_json::Value::Object(out)
},
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.into_iter().map(recase_jsonb_keys_to_snake).collect())
},
other => other,
}
}
fn nested_input_type_name(field_type: &str, schema: &CompiledSchema) -> Option<String> {
let base = field_type.trim_matches(|c| c == '[' || c == ']' || c == '!');
schema.find_input_type(base).map(|_| base.to_string())
}
#[derive(Debug)]
#[non_exhaustive]
pub struct MutationExecution {
pub data: serde_json::Value,
pub outcome: MutationOutcome,
}
#[allow(clippy::too_many_arguments)]
fn build_mutation_result(
ctx: &ExecutorContext,
security_ctx: Option<&SecurityContext>,
gates: &PayloadGates,
outcome: MutationOutcome,
mutation_return_type: &str,
is_cascade: bool,
selections: &[FieldSelection],
authz_variables: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<serde_json::Value> {
match outcome {
MutationOutcome::Success {
entity,
entity_type,
cascade,
updated_fields,
result,
..
} if is_cascade => {
let payload_type = resolve_payload_type(mutation_return_type, &ctx.schema);
let entity_type_name = match entity_type {
Some(stamp) => stamp,
None => {
unstamped_type(&ctx.schema, mutation_return_type, true).ok_or_else(|| {
unstamped_ambiguity(
mutation_return_type,
&payload_gates::cascade_node_types(&ctx.schema),
)
})?
},
};
build_cascade_payload(
ctx,
security_ctx,
gates,
&payload_type,
&entity_type_name,
&entity,
cascade.as_ref(),
&updated_fields,
result.as_ref(),
selections,
authz_variables,
)
},
MutationOutcome::Success {
entity,
entity_type,
cascade,
updated_fields,
..
} => {
let successes = &gates.contract().success;
let typename = match entity_type {
Some(stamp) if successes.contains(&stamp) => stamp,
Some(stamp) => {
return Err(payload_gates::off_contract(
PayloadPosition::Root,
&stamp,
successes,
));
},
None => unstamped_type(&ctx.schema, mutation_return_type, false)
.ok_or_else(|| unstamped_ambiguity(mutation_return_type, successes))?,
};
let mut projected = serve_entity(
ctx,
security_ctx,
gates,
PayloadPosition::Root,
&typename,
selections,
&entity,
authz_variables,
)?;
let _ = cascade;
if selections_contain_field(selections, "updatedFields") {
if let serde_json::Value::Object(ref mut map) = projected {
map.insert(
"updatedFields".to_string(),
serde_json::Value::Array(
updated_fields.into_iter().map(serde_json::Value::String).collect(),
),
);
}
}
Ok(projected)
},
MutationOutcome::Error {
error_class,
message,
http_status,
entity_type,
metadata,
} => {
let status = error_class.as_str();
let mut source_map = match metadata {
serde_json::Value::Object(map) => map,
_ => serde_json::Map::new(),
};
if !message.is_empty() {
source_map
.entry("message".to_string())
.or_insert_with(|| serde_json::Value::String(message));
}
if let Some(code) = http_status {
source_map
.entry("http_status".to_string())
.or_insert_with(|| serde_json::json!(code));
}
source_map
.entry("error_class".to_string())
.or_insert_with(|| serde_json::Value::String(status.to_string()));
source_map.insert("status".to_string(), serde_json::Value::String(status.to_string()));
let source = serde_json::Value::Object(source_map);
let errors = &gates.contract().error;
let returns_members = ctx.schema.find_union(mutation_return_type).is_some()
|| ctx.schema.find_interface(mutation_return_type).is_some();
let error_type = match entity_type {
Some(stamp) if errors.contains(&stamp) => ctx.schema.find_type(&stamp),
Some(stamp) => {
return Err(payload_gates::off_contract(PayloadPosition::Root, &stamp, errors));
},
None if !returns_members => None,
None => match errors.as_slice() {
[] => None,
[only] => ctx.schema.find_type(only),
_ => return Err(unstamped_error_ambiguity(mutation_return_type, errors)),
},
};
let mut result = if let Some(td) = error_type {
serve_entity(
ctx,
security_ctx,
gates,
PayloadPosition::Root,
td.name.as_str(),
selections,
&source,
authz_variables,
)?
} else {
let mut map = serde_json::Map::new();
if selections_contain_field(selections, "__typename") {
map.insert(
"__typename".to_string(),
serde_json::Value::String(mutation_return_type.to_string()),
);
}
serde_json::Value::Object(map)
};
if let serde_json::Value::Object(ref mut map) = result {
map.entry("status".to_string())
.or_insert_with(|| serde_json::Value::String(status.to_string()));
}
Ok(result)
},
}
}
fn constraint_violation_outcome(
error: &FraiseQLError,
schema: &crate::schema::CompiledSchema,
return_type: &str,
contract: &payload_gates::StampContract,
entry: Option<serde_json::Map<String, serde_json::Value>>,
) -> Option<MutationOutcome> {
let FraiseQLError::Database {
sql_state: Some(sql_state),
..
} = error
else {
return None;
};
let returns_members =
schema.find_union(return_type).is_some() || schema.find_interface(return_type).is_some();
if !returns_members || contract.error.len() != 1 {
return None;
}
let (error_class, http_status, message) = match sql_state.as_str() {
"23502" | "23514" => (
crate::runtime::cascade::MutationErrorClass::Validation,
422,
"The request contains an invalid value",
),
state if state.starts_with("23") => (
crate::runtime::cascade::MutationErrorClass::Conflict,
409,
"The request conflicts with the current state of the data",
),
_ => return None,
};
let metadata = entry.map_or(serde_json::Value::Null, |mut entry| {
entry.insert("code".to_string(), serde_json::json!(http_status));
entry.insert("message".to_string(), serde_json::json!(message));
serde_json::json!({ "errors": [entry] })
});
Some(MutationOutcome::Error {
error_class,
message: message.to_string(),
http_status: Some(http_status),
entity_type: None,
metadata,
})
}
async fn constraint_entry(
ctx: &ExecutorContext,
error: &FraiseQLError,
) -> Option<serde_json::Map<String, serde_json::Value>> {
use crate::runtime::ConstraintMetadata;
let full = match ctx.config.constraint_metadata {
ConstraintMetadata::Identifier => false,
ConstraintMetadata::Full => true,
_ => return None,
};
let FraiseQLError::Database {
sql_state: Some(sql_state),
constraint: Some(violation),
..
} = error
else {
return None;
};
if !sql_state.starts_with("23") {
return None;
}
let described = if full || violation.name.is_none() {
match ctx.adapter.describe_constraint(violation).await {
Ok(described) => described,
Err(lookup) => {
tracing::warn!(error = %lookup, "could not describe a violated constraint");
crate::backend::ConstraintDescription {
name: violation.name.clone(),
columns: None,
}
},
}
} else {
crate::backend::ConstraintDescription {
name: violation.name.clone(),
columns: None,
}
};
let identifier = described.name?;
let mut details = serde_json::Map::new();
details.insert("sqlstate".to_string(), serde_json::json!(sql_state));
if full {
if let Some(table) = &violation.table {
details.insert("table".to_string(), serde_json::json!(table));
}
if let Some(columns) = described.columns {
details.insert("columns".to_string(), serde_json::json!(columns));
}
}
let mut entry = serde_json::Map::new();
entry.insert("identifier".to_string(), serde_json::json!(identifier));
entry.insert("details".to_string(), serde_json::Value::Object(details));
Some(entry)
}
pub(in super::super) async fn execute_mutation_impl(
ctx: &Arc<ExecutorContext>,
mutation_name: &str,
response_key: &str,
variables: Option<&serde_json::Value>,
security_ctx: Option<&SecurityContext>,
selections: WriteSelections<'_>,
inline_arguments: &[crate::graphql::GraphQLArgument],
) -> Result<MutationExecution> {
let writer = ctx.writer(mutation_name)?;
let prepared = prepare_write(
ctx,
mutation_name,
response_key,
variables,
security_ctx,
selections,
inline_arguments,
)
.await?;
let parts = prepared.parts();
let request = parts.request(&prepared);
let built = std::sync::OnceLock::new();
let gate = |rows: &[std::collections::HashMap<String, serde_json::Value>]| {
drop(built.set(prepared.adjudicate(rows)?));
Ok(())
};
let adjudicated = match writer.execute_write(&request, &gate).await {
Ok(_) => built.into_inner().ok_or_else(|| FraiseQLError::Internal {
message: format!(
"Mutation '{mutation_name}': the commit gate never ran, so the write \
committed unadjudicated"
),
source: None,
})?,
Err(error) => prepared.answer_violation(error).await?,
};
after_commit(ctx, writer, prepared.mutation_def, mutation_name, security_ctx, &[&adjudicated])
.await?;
Ok(prepared.execution(adjudicated))
}
async fn prepare_write<'a>(
ctx: &'a Arc<ExecutorContext>,
mutation_name: &'a str,
response_key: &str,
variables: Option<&serde_json::Value>,
security_ctx: Option<&'a SecurityContext>,
selections: WriteSelections<'_>,
inline_arguments: &[crate::graphql::GraphQLArgument],
) -> Result<PreparedWrite<'a>> {
let selections = selections.as_slice();
if let Some(gate) = ctx.config.write_gate.as_ref() {
gate.admit(mutation_name)?;
}
crate::runtime::executor::support::security::enforce_enrichment_resolved(
&ctx.schema,
security_ctx,
)?;
let mutation_def = ctx.schema.find_mutation(mutation_name).ok_or_else(|| {
let display_names: Vec<String> =
ctx.schema.mutations.iter().map(|m| ctx.schema.display_name(&m.name)).collect();
let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
let suggestion = suggest_similar(mutation_name, &candidate_refs);
let message = match suggestion.as_slice() {
[s] => {
format!("Mutation '{mutation_name}' not found in schema. Did you mean '{s}'?")
},
[a, b] => format!(
"Mutation '{mutation_name}' not found in schema. Did you mean '{a}' or '{b}'?"
),
[a, b, c, ..] => format!(
"Mutation '{mutation_name}' not found in schema. Did you mean '{a}', '{b}', or \
'{c}'?"
),
_ => format!("Mutation '{mutation_name}' not found in schema"),
};
FraiseQLError::Validation {
message,
path: None,
}
})?;
if let Some(authorizer) = ctx.config.authorizer.as_ref() {
let ops = [crate::security::AuthzOperation::root(
crate::security::OperationKind::Mutation,
mutation_name,
Some(mutation_def.return_type.as_str()),
)];
crate::security::authorizer::enforce_authz(
authorizer.as_ref(),
security_ctx,
&ops,
variables,
)?;
}
crate::security::role_gate::enforce_requires_role(
"Mutation",
mutation_name,
mutation_def.requires_role.as_deref(),
security_ctx,
)?;
crate::security::actor_type::enforce_requires_actor(
"Mutation",
mutation_name,
&mutation_def.requires_actor,
security_ctx,
)?;
crate::graphql::validate_selection_set(&ctx.schema, &mutation_def.return_type, selections)?;
crate::graphql::validate_leaf_field_selections(
&ctx.schema,
&mutation_def.return_type,
selections,
)?;
crate::runtime::validate_argument_names(
&format!("Mutation.{}", ctx.schema.display_name(&mutation_def.name)),
&mutation_def.arguments.iter().map(|arg| arg.name.clone()).collect::<Vec<_>>(),
inline_arguments,
)?;
let sql_source_owned: String;
let sql_source: &str = if let Some(src) = mutation_def.sql_source.as_deref() {
src
} else {
match &mutation_def.operation {
MutationOperation::Insert { table }
| MutationOperation::Update { table }
| MutationOperation::Delete { table }
if !table.is_empty() =>
{
sql_source_owned = table.clone();
&sql_source_owned
},
_ => {
return Err(FraiseQLError::Validation {
message: format!("Mutation '{mutation_name}' has no sql_source configured"),
path: None,
});
},
}
};
let merged_variables: Option<serde_json::Value> = if inline_arguments.is_empty() {
None
} else {
let var_map = crate::runtime::matcher::QueryMatcher::extract_arguments(variables);
let mut obj = match variables {
Some(serde_json::Value::Object(m)) => m.clone(),
_ => serde_json::Map::new(),
};
let mut added = false;
for arg in inline_arguments {
if !obj.contains_key(&arg.name) {
if let Some(val) =
crate::runtime::matcher::QueryMatcher::resolve_inline_arg(arg, &var_map)?
{
obj.insert(arg.name.clone(), val);
added = true;
}
}
}
added.then_some(serde_json::Value::Object(obj))
};
let variables = merged_variables.as_ref().or(variables);
let gated_variables = crate::security::mutation_gate::enforce_before_mutation(
ctx.config.before_mutation_gate.as_deref(),
security_ctx,
mutation_name,
response_key,
variables,
|| {
std::sync::Arc::new(
crate::runtime::executor::support::hook_reader::CallerScopedReader::new(
std::sync::Arc::clone(ctx),
security_ctx,
),
)
},
)
.await?;
let variables = gated_variables.as_ref().or(variables);
let authz_variables = crate::runtime::matcher::QueryMatcher::extract_arguments(variables);
let localized = crate::runtime::coerce_localized_arguments(
&ctx.schema,
&format!("Mutation.{}", ctx.schema.display_name(mutation_name)),
&mutation_def.arguments,
variables,
)?;
let variables = localized.as_ref().or(variables);
crate::runtime::validate_mutation_argument_values(
&ctx.schema,
&format!("Mutation.{}", ctx.schema.display_name(mutation_name)),
&mutation_def.arguments,
variables,
)?;
let enum_coerced = crate::runtime::coerce_enum_argument_object(
&ctx.schema,
&mutation_def.arguments,
variables,
);
let variables = enum_coerced.as_ref().or(variables);
let vars_obj = variables.and_then(|v| v.as_object());
let mut missing_required: Vec<&str> = Vec::new();
let total_args = mutation_def.arguments.len() + mutation_def.inject_params.len();
let mut args: Vec<serde_json::Value> = Vec::with_capacity(total_args);
let single_input_arg =
mutation_def.arguments.len() == 1 && mutation_def.arguments[0].name == "input";
let input_type_name = if single_input_arg {
match &mutation_def.arguments[0].arg_type {
crate::schema::FieldType::Input(name) => Some(name.as_str()),
crate::schema::FieldType::Object(name)
if ctx.schema.find_input_type(name).is_some() =>
{
Some(name.as_str())
},
_ => None,
}
} else {
None
};
let input_arg_is_structured = single_input_arg
&& match &mutation_def.arguments[0].arg_type {
crate::schema::FieldType::Input(_) | crate::schema::FieldType::Json => true,
crate::schema::FieldType::Object(name) => ctx.schema.find_input_type(name).is_some(),
_ => false,
};
let is_update = matches!(&mutation_def.operation, MutationOperation::Update { .. });
let jsonb_input_style = matches!(mutation_def.input_style, InputStyle::Jsonb);
let known_input_type = input_type_name.and_then(|n| ctx.schema.find_input_type(n)).is_some();
let pass_input_as_single_jsonb =
input_arg_is_structured && (is_update || jsonb_input_style || !known_input_type);
if pass_input_as_single_jsonb {
let arg = &mutation_def.arguments[0];
match vars_obj.and_then(|obj| obj.get("input")) {
Some(value) if !value.is_null() => {
args.push(recase_input_payload(value.clone(), input_type_name, &ctx.schema));
},
_ if !arg.nullable && arg.default_value.is_none() => missing_required.push("input"),
_ => args
.push(arg.default_value.as_ref().map_or(serde_json::Value::Null, |v| v.to_json())),
}
} else if let Some(input_type) = input_type_name.and_then(|n| ctx.schema.find_input_type(n)) {
let input_obj = vars_obj.and_then(|obj| obj.get("input")).and_then(|v| v.as_object());
if let Some(input_obj) = input_obj {
let mut missing_input_fields: Vec<&str> = Vec::new();
for field in &input_type.fields {
let key = ctx.schema.display_name(&field.name);
let value = input_obj.get(&key);
if field.is_required() && value.is_none_or(serde_json::Value::is_null) {
missing_input_fields.push(field.name.as_str());
}
let raw = value.cloned().unwrap_or(serde_json::Value::Null);
args.push(recase_input_field_value(raw, &field.field_type, &ctx.schema));
}
if !missing_input_fields.is_empty() {
return Err(FraiseQLError::Validation {
message: format!(
"Mutation '{mutation_name}': required input field(s) not provided or \
null: {}",
missing_input_fields.join(", ")
),
path: None,
});
}
} else if !mutation_def.arguments[0].nullable {
missing_required.push("input");
}
} else {
args.extend(mutation_def.arguments.iter().map(|arg| {
let value = vars_obj.and_then(|obj| obj.get(&arg.name)).cloned();
if let Some(v) = value {
v
} else {
if !arg.nullable && arg.default_value.is_none() {
missing_required.push(&arg.name);
}
arg.default_value.as_ref().map_or(serde_json::Value::Null, |v| v.to_json())
}
}));
}
if !missing_required.is_empty() {
return Err(FraiseQLError::Validation {
message: format!(
"Mutation '{mutation_name}' is missing required argument(s): {}",
missing_required.join(", ")
),
path: None,
});
}
if !mutation_def.inject_params.is_empty() {
let sec_ctx = security_ctx.ok_or_else(|| FraiseQLError::Validation {
message: format!(
"Mutation '{}' requires inject params but no security context is available \
(unauthenticated request)",
mutation_name
),
path: None,
})?;
for (param_name, source) in &mutation_def.inject_params {
args.push(resolve_mutation_inject_value(
param_name,
source,
sec_ctx,
ctx.schema.tenant_claim(),
)?);
}
}
let variables_map: std::collections::HashMap<String, serde_json::Value> = match variables {
Some(serde_json::Value::Object(map)) => {
map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
},
_ => std::collections::HashMap::new(),
};
let filtered_selections = DirectiveEvaluator::filter_selections(selections, &variables_map)
.map_err(|e| FraiseQLError::Validation {
message: e.to_string(),
path: Some("directives".to_string()),
})?;
let selections: &[FieldSelection] = &filtered_selections;
let payload_gates = PayloadGates::classify(
ctx,
security_ctx,
variables,
&mutation_def.return_type,
mutation_def.cascade,
selections,
)?;
let session_vars = {
let sv = &ctx.schema.session_variables;
if sv.variables.is_empty() && !sv.inject_started_at {
Vec::new()
} else {
crate::runtime::executor::support::security::resolve_session_variables(
sv,
security_ctx,
ctx.schema.tenant_claim(),
crate::runtime::executor::support::security::SessionPurpose::Write,
)?
}
};
let write_changelog = ctx.config.changelog_enabled && mutation_def.changelog;
let trace_context_json = security_ctx
.and_then(SecurityContext::trace_context)
.map(serde_json::Value::to_string);
let changelog_object_type =
unstamped_type(&ctx.schema, &mutation_def.return_type, mutation_def.cascade)
.unwrap_or_else(|| mutation_def.return_type.clone());
let derived_views = (mutation_def.cascade_source == crate::schema::CascadeSource::PgTviews)
.then(|| {
derived_cascade::views(&ctx.schema)
.into_iter()
.filter(|(name, _)| {
!derived_cascade::engine_filtered(
&ctx.schema,
ctx.config.rls_policy.is_some(),
name,
)
})
.collect()
});
Ok(PreparedWrite {
ctx,
mutation_def,
mutation_name,
security_ctx,
sql_source: sql_source.to_string(),
args,
response_key: response_key.to_string(),
selections: filtered_selections,
authz_variables,
payload_gates,
session_vars,
modification_type: mutation_def.operation.kind_str().to_uppercase(),
changelog_object_type: write_changelog.then_some(changelog_object_type),
trace_context_json,
derived_views,
})
}
struct PreparedWrite<'a> {
ctx: &'a Arc<ExecutorContext>,
mutation_def: &'a crate::schema::MutationDefinition,
mutation_name: &'a str,
security_ctx: Option<&'a SecurityContext>,
sql_source: String,
args: Vec<serde_json::Value>,
response_key: String,
selections: Vec<FieldSelection>,
authz_variables: std::collections::HashMap<String, serde_json::Value>,
payload_gates: PayloadGates,
session_vars: Vec<(String, String)>,
modification_type: String,
changelog_object_type: Option<String>,
trace_context_json: Option<String>,
derived_views: Option<Vec<(&'a str, &'a str)>>,
}
type Adjudicated = (
MutationOutcome,
serde_json::Value,
std::collections::BTreeSet<String>,
Option<crate::backend::ChangedRows>,
);
struct WriteParts<'p> {
session_pairs: Vec<(&'p str, &'p str)>,
changelog: Option<ChangeLogWrite<'p>>,
derived: Option<crate::backend::DerivedCascade<'p>>,
report_changes: Option<i32>,
mode: crate::backend::WriteMode,
}
impl WriteParts<'_> {
fn request<'r>(&'r self, prepared: &'r PreparedWrite<'_>) -> crate::backend::WriteRequest<'r> {
crate::backend::WriteRequest::new(&prepared.sql_source, &prepared.args)
.with_session_vars(&self.session_pairs)
.with_changelog(self.changelog.as_ref())
.with_mode(self.mode)
.with_derived_cascade(self.derived.as_ref())
.with_changes_reported(self.report_changes)
}
}
impl PreparedWrite<'_> {
fn parts(&self) -> WriteParts<'_> {
let ctx = self.ctx;
let security_ctx = self.security_ctx;
let changelog = self.changelog_object_type.as_deref().map(|object_type| {
ChangeLogWrite::new(object_type, &self.modification_type)
.with_tenant_id(
security_ctx
.and_then(|c| c.tenant_id.as_ref())
.and_then(|t| uuid::Uuid::parse_str(t.as_str()).ok()),
)
.with_trace_id(security_ctx.and_then(SecurityContext::trace_id))
.with_schema_version(Some(&ctx.schema_version))
.with_trace_context(self.trace_context_json.as_deref())
.with_actor_type(security_ctx.map(|c| c.actor_type().as_str()))
.with_acting_for(security_ctx.and_then(SecurityContext::acting_for))
.with_transport(security_ctx.and_then(SecurityContext::transport))
.with_pre_image(self.mutation_def.changelog_pre_image)
});
let mode = if ctx.config.dry_run_mutations {
crate::backend::WriteMode::DryRun
} else {
crate::backend::WriteMode::Commit
};
WriteParts {
session_pairs: self
.session_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect(),
changelog,
derived: self
.derived_views
.as_deref()
.map(|views| crate::backend::DerivedCascade::new(max_entities(ctx), views)),
report_changes: ctx.adapter.reports_changed_rows().then(|| max_entities(ctx)),
mode,
}
}
fn adjudicate(
&self,
rows: &[std::collections::HashMap<String, serde_json::Value>],
) -> Result<Adjudicated> {
let ctx = self.ctx;
let (mutation_name, sql_source) = (self.mutation_name, self.sql_source.as_str());
let row = rows.first().ok_or_else(|| FraiseQLError::Validation {
message: format!("Mutation '{mutation_name}': function returned no rows"),
path: None,
})?;
let mut reported = derived_cascade::reported_types(row);
let changed = derived_cascade::changed_rows(row);
if let Some(changed) = &changed {
reported.extend(changed.type_names().map(str::to_string));
}
let row = derived_cascade::merge(&ctx.schema, row, |type_name| {
derived_cascade::engine_filtered(
&ctx.schema,
ctx.config.rls_policy.is_some(),
type_name,
)
});
let outcome = parse_mutation_row(&row)?;
crate::runtime::mutation_result::check_error_shape(
ctx.config.mutation_error_shape_check,
&outcome,
mutation_name,
sql_source,
);
let envelope = outcome.clone();
let result_json = self.build(outcome).inspect_err(|e| {
if payload_gates::is_contract_error(e) {
tracing::warn!(
mutation = %mutation_name,
function = %sql_source,
error = %e,
"mutation contract error: the write was rolled back"
);
}
})?;
Ok((envelope, result_json, reported, changed))
}
fn build(&self, outcome: MutationOutcome) -> Result<serde_json::Value> {
build_mutation_result(
self.ctx,
self.security_ctx,
&self.payload_gates,
outcome,
&self.mutation_def.return_type,
self.mutation_def.cascade,
&self.selections,
&self.authz_variables,
)
}
async fn answer_violation(&self, error: FraiseQLError) -> Result<Adjudicated> {
let ctx = self.ctx;
let entry = constraint_entry(ctx, &error).await;
let Some(outcome) = constraint_violation_outcome(
&error,
&ctx.schema,
&self.mutation_def.return_type,
self.payload_gates.contract(),
entry,
) else {
return Err(error);
};
tracing::info!(
mutation = %self.mutation_name,
function = %self.sql_source,
error = %error,
"constraint violation answered as the mutation's typed error"
);
let envelope = outcome.clone();
let result_json = self.build(outcome)?;
let unchanged = Some(crate::backend::ChangedRows::default());
Ok((envelope, result_json, std::collections::BTreeSet::new(), unchanged))
}
fn execution(&self, (outcome, result_json, ..): Adjudicated) -> MutationExecution {
MutationExecution {
data: ResultProjector::wrap_in_data_envelope(result_json, &self.response_key),
outcome,
}
}
}
fn refused_item(
index: usize,
count: usize,
mutation_name: &str,
outcome: &MutationOutcome,
) -> Option<FraiseQLError> {
use crate::runtime::cascade::MutationErrorClass as Class;
let MutationOutcome::Error {
error_class,
message,
..
} = outcome
else {
return None;
};
let message = format!(
"Mutation '{mutation_name}' refused it ({}): {message}; nothing was written",
error_class.as_str()
);
let error = match error_class {
Class::Validation => FraiseQLError::Validation {
message,
path: None,
},
Class::NotFound => FraiseQLError::NotFound {
resource_type: format!("Bulk item {} of {count}", index + 1),
identifier: message,
},
Class::Unauthorized => FraiseQLError::Authentication { message },
Class::Forbidden => FraiseQLError::Authorization {
message,
action: Some("mutation".to_string()),
resource: Some(mutation_name.to_string()),
},
Class::Internal => FraiseQLError::Internal {
message,
source: None,
},
Class::RateLimited | Class::ServiceUnavailable => FraiseQLError::ServiceUnavailable {
message,
retry_after: None,
},
Class::Conflict | Class::TransactionFailed | Class::Timeout => {
FraiseQLError::Conflict { message }
},
};
Some(error.in_batch_item(index, count))
}
fn max_entities(ctx: &ExecutorContext) -> i32 {
i32::try_from(ctx.config.cascade_limits.max_updated_entities).unwrap_or(i32::MAX)
}
async fn after_commit(
ctx: &ExecutorContext,
writer: &dyn crate::backend::Writer,
mutation_def: &crate::schema::MutationDefinition,
mutation_name: &str,
security_ctx: Option<&SecurityContext>,
written: &[&Adjudicated],
) -> Result<()> {
if ctx.config.dry_run_mutations {
return Ok(());
}
let succeeded = || {
written
.iter()
.filter(|(envelope, ..)| matches!(envelope, MutationOutcome::Success { .. }))
};
if succeeded().next().is_some() && !mutation_def.invalidates_fact_tables.is_empty() {
writer.bump_fact_table_versions(&mutation_def.invalidates_fact_tables).await?;
}
let mut plan = invalidation::InvalidationPlan::default();
let mut entities = Vec::new();
let mut changed = Some(crate::backend::ChangedRows::default());
for (envelope, _, reported, call_changed) in written.iter().copied() {
let call = invalidation::plan_invalidation(mutation_def, envelope, &ctx.schema)
.with_reported_types(reported, &ctx.schema);
entities.extend(plan.absorb(call));
match (changed.as_mut(), call_changed) {
(Some(all), Some(call_changed)) => all.absorb(call_changed.clone()),
_ => changed = None,
}
}
match changed.filter(|_| ctx.adapter.reports_changed_rows()) {
Some(changed) => {
ctx.adapter.invalidate_written(&plan.views, &changed).await?;
},
None if !plan.views.is_empty() => {
ctx.adapter.invalidate_views(&plan.views).await?;
},
None => {},
}
for (etype, eid) in &entities {
ctx.adapter.invalidate_by_entity(etype, eid).await?;
}
for (envelope, ..) in written.iter().copied() {
if ctx.config.audit_mutations {
tracing::info!(
target: "fraiseql::mutation_audit",
mutation_name = mutation_name,
entity_type = %mutation_def.return_type,
operation = %mutation_def.operation.kind_str(),
tenant_id = %security_ctx
.and_then(|c| c.tenant_id.as_ref().map(|t| t.as_str()))
.unwrap_or(""),
"mutation.executed"
);
}
if let (
Some(observer),
MutationOutcome::Success {
entity,
entity_type,
entity_id,
..
},
) = (ctx.config.after_mutation_observer.as_deref(), envelope)
{
let produced = entity_type.clone().or_else(|| {
unstamped_type(&ctx.schema, &mutation_def.return_type, mutation_def.cascade)
});
if let Some(produced) = produced {
observer.on_committed(&crate::runtime::CommittedMutation {
mutation_name,
entity_type: &produced,
operation: &mutation_def.operation,
entity,
entity_id: entity_id.as_deref(),
security_ctx,
dispatch_depth: crate::runtime::current_dispatch_depth(),
});
}
}
}
Ok(())
}
pub(in super::super) async fn execute_mutation_batch_impl(
ctx: &Arc<ExecutorContext>,
mutation_name: &str,
items: &[serde_json::Value],
security_ctx: Option<&SecurityContext>,
selections: WriteSelections<'_>,
) -> Result<Vec<MutationExecution>> {
let writer = ctx.writer(mutation_name)?;
let count = items.len();
let mut prepared = Vec::with_capacity(count);
for (index, item) in items.iter().enumerate() {
let write = prepare_write(
ctx,
mutation_name,
mutation_name,
Some(item),
security_ctx,
selections,
&[],
)
.await
.map_err(|e| e.in_batch_item(index, count))?;
prepared.push(write);
}
let parts: Vec<WriteParts<'_>> = prepared.iter().map(PreparedWrite::parts).collect();
let requests: Vec<crate::backend::WriteRequest<'_>> =
parts.iter().zip(&prepared).map(|(parts, write)| parts.request(write)).collect();
let built: Vec<std::sync::OnceLock<Adjudicated>> =
std::iter::repeat_with(std::sync::OnceLock::new).take(count).collect();
let gate = |index: usize, rows: &[std::collections::HashMap<String, serde_json::Value>]| {
let adjudicated =
prepared[index].adjudicate(rows).map_err(|e| e.in_batch_item(index, count))?;
if let Some(refused) = refused_item(index, count, mutation_name, &adjudicated.0) {
return Err(refused);
}
drop(built[index].set(adjudicated));
Ok(())
};
writer.execute_writes(&requests, &gate).await?;
let built: Vec<Adjudicated> = built
.into_iter()
.map(std::sync::OnceLock::into_inner)
.collect::<Option<_>>()
.ok_or_else(|| FraiseQLError::Internal {
message: format!(
"Mutation '{mutation_name}': the batch committed with an item the commit gate \
never adjudicated"
),
source: None,
})?;
if let Some(first) = prepared.first() {
let written: Vec<&Adjudicated> = built.iter().collect();
after_commit(ctx, writer, first.mutation_def, mutation_name, security_ctx, &written)
.await?;
}
Ok(prepared
.iter()
.zip(built)
.map(|(write, built)| write.execution(built))
.collect())
}
#[cfg(test)]
mod tests;