use std::sync::Arc;
use fraiseql_db::{
ChangeLogWrite, DirectMutationContext, DirectMutationOp, MutationStrategy, ViewName,
};
use super::{
super::{context::ExecutorContext, resolve_inject_value},
query_projection::selections_contain_field,
};
use crate::{
db::traits::{DatabaseAdapter, SupportsMutations},
error::{FraiseQLError, Result},
graphql::{DirectiveEvaluator, FieldSelection},
runtime::{
ResultProjector,
mutation_result::{MutationOutcome, parse_mutation_row},
project_entity, suggest_similar,
},
schema::{CompiledSchema, InputStyle, MutationOperation, NamingConvention},
security::SecurityContext,
};
fn enforce_mutation_field_authz<A: DatabaseAdapter>(
ctx: &ExecutorContext<A>,
security_ctx: Option<&SecurityContext>,
type_name: &str,
selections: &[FieldSelection],
entity: &serde_json::Value,
projected: &mut serde_json::Value,
) -> Result<()> {
use crate::security::field_authorizer as authz;
if !authz::selection_set_selects_gated_field(&ctx.schema, type_name, selections) {
return Ok(());
}
let Some(principal) = security_ctx else {
return Err(FraiseQLError::Authorization {
message: format!(
"Field-level authorization is required for a selected field on type \
'{type_name}' but the request is not authenticated"
),
action: Some("read".to_string()),
resource: Some(type_name.to_string()),
});
};
let Some(authorizer) = ctx.config.field_authorizer.as_ref() else {
return Err(FraiseQLError::Authorization {
message: format!(
"Field-level authorization is required for a selected field on type \
'{type_name}' but no field authorizer is configured"
),
action: Some("read".to_string()),
resource: Some(type_name.to_string()),
});
};
if authz::selection_set_has_nested_gated_field(&ctx.schema, type_name, selections) {
return Err(FraiseQLError::Authorization {
message: format!(
"Field-level authorization of nested fields on type '{type_name}' is not \
supported in this version"
),
action: Some("read".to_string()),
resource: Some(type_name.to_string()),
});
}
let gated = authz::collect_top_level_gated_fields(&ctx.schema, type_name, selections);
let pass = authz::FieldAuthzPass {
authorizer: authorizer.as_ref(),
principal,
type_name,
gated: &gated,
statically_masked: &[],
};
authz::apply_field_authorizer_to_entity(&pass, entity, projected)
}
pub(in super::super) struct MutationRunner<A: DatabaseAdapter + SupportsMutations> {
ctx: Arc<ExecutorContext<A>>,
}
impl<A: DatabaseAdapter + SupportsMutations> MutationRunner<A> {
pub(in super::super) const fn new(ctx: Arc<ExecutorContext<A>>) -> Self {
Self { ctx }
}
pub(in super::super) async fn execute_mutation(
&self,
mutation_name: &str,
variables: Option<&serde_json::Value>,
selections: &[FieldSelection],
) -> Result<serde_json::Value> {
execute_mutation_impl(&self.ctx, mutation_name, variables, None, selections, &[]).await
}
}
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())
}
pub(in super::super) async fn execute_mutation_impl<A: DatabaseAdapter>(
ctx: &ExecutorContext<A>,
mutation_name: &str,
variables: Option<&serde_json::Value>,
security_ctx: Option<&SecurityContext>,
selections: &[FieldSelection],
inline_arguments: &[crate::graphql::GraphQLArgument],
) -> Result<serde_json::Value> {
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::authorizer::OperationKind::Mutation, mutation_name.to_string())];
crate::security::authorizer::enforce_authz(
authorizer.as_ref(),
security_ctx,
&ops,
variables,
)?;
}
if let Some(required_role) = mutation_def.requires_role.as_deref() {
let has_role = security_ctx.is_some_and(|c| c.roles.iter().any(|r| r == required_role));
if !has_role {
return Err(FraiseQLError::Validation {
message: format!("Mutation '{mutation_name}' not found in schema"),
path: None,
});
}
}
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 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 mut direct_columns: Vec<String> = Vec::new();
let mut direct_inject_columns: Vec<String> = Vec::new();
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));
direct_columns.push(crate::utils::to_snake_case(&field.name));
}
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())
}
}));
direct_columns.extend(mutation_def.arguments.iter().map(|arg| arg.name.clone()));
}
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_inject_value(param_name, source, sec_ctx)?);
direct_inject_columns.push(param_name.clone());
}
}
let outcome = if matches!(ctx.adapter.mutation_strategy(), MutationStrategy::DirectSql) {
if pass_input_as_single_jsonb || direct_columns.is_empty() {
return Err(FraiseQLError::Unsupported {
message: format!(
"Mutation '{mutation_name}': direct-SQL adapters (e.g. SQLite) require flat \
positional input columns and do not support Update / single-JSONB input \
styles. Use PostgreSQL, MySQL, or SQL Server for those mutations."
),
});
}
let (operation, table) = match &mutation_def.operation {
MutationOperation::Insert { table } => (DirectMutationOp::Insert, table.as_str()),
MutationOperation::Delete { table } => (DirectMutationOp::Delete, table.as_str()),
MutationOperation::Update { .. } | MutationOperation::Custom => {
return Err(FraiseQLError::Unsupported {
message: format!(
"Mutation '{mutation_name}': direct-SQL adapters (e.g. SQLite) support \
Insert and Delete mutations only; Update and custom / stored-procedure \
mutations require PostgreSQL, MySQL, or SQL Server."
),
});
},
};
let direct_ctx = DirectMutationContext {
operation,
table,
columns: &direct_columns,
values: &args,
inject_columns: &direct_inject_columns,
return_type: &mutation_def.return_type,
};
let rows = ctx.adapter.execute_direct_mutation(&direct_ctx).await?;
let row_value = rows.into_iter().next().ok_or_else(|| FraiseQLError::Validation {
message: format!("Mutation '{mutation_name}': direct mutation affected no rows"),
path: None,
})?;
let direct_obj = row_value.as_object().ok_or_else(|| FraiseQLError::Validation {
message: format!(
"Mutation '{mutation_name}': direct mutation result was not a JSON object"
),
path: None,
})?;
let mut response = serde_json::Map::new();
response.insert("succeeded".to_string(), serde_json::Value::Bool(true));
response.insert("state_changed".to_string(), serde_json::Value::Bool(true));
if let Some(entity) = direct_obj.get("entity") {
response.insert("entity".to_string(), entity.clone());
}
response.insert(
"entity_type".to_string(),
direct_obj
.get("entity_type")
.cloned()
.unwrap_or_else(|| serde_json::Value::String(mutation_def.return_type.clone())),
);
if let Some(id) = direct_obj
.get("entity_id")
.and_then(serde_json::Value::as_str)
.filter(|id| uuid::Uuid::parse_str(id).is_ok())
{
response.insert("entity_id".to_string(), serde_json::Value::String(id.to_string()));
}
let row_map: std::collections::HashMap<String, serde_json::Value> =
response.into_iter().collect();
parse_mutation_row(&row_map)?
} else {
let resolved_session_vars = {
let sv = &ctx.schema.session_variables;
match security_ctx {
Some(sec_ctx) if !sv.variables.is_empty() || sv.inject_started_at => {
crate::runtime::executor::security::resolve_session_variables(sv, sec_ctx)
},
_ => Vec::new(),
}
};
let session_pairs: Vec<(&str, &str)> =
resolved_session_vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let modification_type = mutation_def.operation.kind_str().to_uppercase();
let write_changelog = ctx.config.changelog_enabled && mutation_def.changelog;
let tenant_uuid = security_ctx
.and_then(|c| c.tenant_id.as_ref())
.and_then(|t| uuid::Uuid::parse_str(t.as_str()).ok());
let trace_id = security_ctx.and_then(SecurityContext::trace_id);
let trace_context_json = security_ctx
.and_then(SecurityContext::trace_context)
.map(serde_json::Value::to_string);
let actor_type = security_ctx.map(|c| c.actor_type().as_str());
let acting_for = security_ctx.and_then(SecurityContext::acting_for);
let changelog = write_changelog.then(|| {
ChangeLogWrite::new(&mutation_def.return_type, &modification_type)
.with_tenant_id(tenant_uuid)
.with_trace_id(trace_id)
.with_schema_version(Some(&ctx.schema_version))
.with_trace_context(trace_context_json.as_deref())
.with_actor_type(actor_type)
.with_acting_for(acting_for)
.with_pre_image(mutation_def.changelog_pre_image)
});
let rows = ctx
.adapter
.execute_function_call_with_changelog(
sql_source,
&args,
&session_pairs,
changelog.as_ref(),
)
.await?;
let row = rows.into_iter().next().ok_or_else(|| FraiseQLError::Validation {
message: format!("Mutation '{mutation_name}': function returned no rows"),
path: None,
})?;
parse_mutation_row(&row)?
};
if matches!(outcome, MutationOutcome::Success { .. })
&& !mutation_def.invalidates_fact_tables.is_empty()
{
ctx.adapter
.bump_fact_table_versions(&mutation_def.invalidates_fact_tables)
.await?;
}
if let MutationOutcome::Success {
entity_type,
entity_id,
..
} = &outcome
{
if let (Some(etype), Some(eid)) = (entity_type.as_deref(), entity_id.as_deref()) {
ctx.adapter.invalidate_by_entity(etype, eid).await?;
if let Some(ref rc) = ctx.response_cache {
let inferred_view = ctx
.schema
.types
.iter()
.find(|t| t.name == etype)
.filter(|t| !t.sql_source.as_str().is_empty())
.map(|t| t.sql_source.to_string());
if let Some(view) = inferred_view {
let _ = rc.invalidate_views(&[ViewName::from(view)]);
}
}
}
if entity_id.is_none() || !mutation_def.invalidates_views.is_empty() {
let views_to_invalidate: Vec<ViewName> = if mutation_def.invalidates_views.is_empty() {
ctx.schema
.types
.iter()
.find(|t| t.name == mutation_def.return_type)
.filter(|t| !t.sql_source.as_str().is_empty())
.map(|t| ViewName::from(t.sql_source.as_str()))
.into_iter()
.collect()
} else {
mutation_def.invalidates_views.iter().map(ViewName::from).collect()
};
if !views_to_invalidate.is_empty() {
if entity_id.is_none() {
ctx.adapter.invalidate_list_queries(&views_to_invalidate).await?;
} else {
ctx.adapter.invalidate_views(&views_to_invalidate).await?;
}
if let Some(ref rc) = ctx.response_cache {
let _ = rc.invalidate_views(&views_to_invalidate);
}
}
}
}
let mutation_return_type = mutation_def.return_type.clone();
let mutation_name_owned = mutation_name.to_string();
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 result_json = match outcome {
MutationOutcome::Success {
entity,
entity_type,
cascade,
updated_fields,
..
} => {
let typename = entity_type
.or_else(|| {
ctx.schema
.find_union(&mutation_return_type)
.and_then(|u| {
u.member_types
.iter()
.find(|t| ctx.schema.find_type(t).is_none_or(|td| !td.is_error))
})
.cloned()
})
.unwrap_or_else(|| mutation_return_type.clone());
let mut projected = project_entity(&entity, &typename, selections, &ctx.schema);
enforce_mutation_field_authz(
ctx,
security_ctx,
&typename,
selections,
&entity,
&mut projected,
)?;
if let Some(cascade_json) = cascade {
if let serde_json::Value::Object(ref mut map) = projected {
map.insert("cascade".to_string(), cascade_json);
}
}
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(),
),
);
}
}
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()));
let source = serde_json::Value::Object(source_map);
let error_type = entity_type
.as_deref()
.and_then(|name| ctx.schema.find_type(name))
.filter(|td| td.is_error)
.or_else(|| {
ctx.schema.find_union(&mutation_return_type).and_then(|u| {
u.member_types.iter().find_map(|t| {
let td = ctx.schema.find_type(t)?;
if td.is_error { Some(td) } else { None }
})
})
});
let mut result = if let Some(td) = error_type {
project_entity(&source, td.name.as_str(), selections, &ctx.schema)
} 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.clone()),
);
}
serde_json::Value::Object(map)
};
if let Some(td) = error_type {
enforce_mutation_field_authz(
ctx,
security_ctx,
td.name.as_str(),
selections,
&source,
&mut result,
)?;
}
if let serde_json::Value::Object(ref mut map) = result {
map.insert("status".to_string(), serde_json::Value::String(status.to_string()));
}
result
},
};
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"
);
}
let response = ResultProjector::wrap_in_data_envelope(result_json, &mutation_name_owned);
Ok(response)
}
#[cfg(test)]
mod tests;