use std::{collections::HashMap, sync::Arc};
use fraiseql_db::ViewName;
use super::super::{context::ExecutorContext, resolve_inject_value};
use crate::{
db::traits::{DatabaseAdapter, SupportsMutations},
error::{FraiseQLError, Result},
runtime::{
FieldMapping, ProjectionMapper, ResultProjector, build_field_mappings_from_type,
mutation_result::{MutationOutcome, parse_mutation_row},
suggest_similar,
},
schema::MutationOperation,
security::SecurityContext,
};
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>,
type_selections: &HashMap<String, Vec<String>>,
) -> Result<serde_json::Value> {
execute_mutation_impl(&self.ctx, mutation_name, variables, None, type_selections).await
}
}
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>,
type_selections: &HashMap<String, Vec<String>>,
) -> 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,
}
})?;
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 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 input_type_name =
if mutation_def.arguments.len() == 1 && mutation_def.arguments[0].name == "input" {
match &mutation_def.arguments[0].arg_type {
crate::schema::FieldType::Input(name) => Some(name.as_str()),
_ => None,
}
} else {
None
};
let is_update = matches!(&mutation_def.operation, MutationOperation::Update { .. });
if is_update && input_type_name.is_some() {
let input_obj = vars_obj.and_then(|obj| obj.get("input")).and_then(|v| v.as_object());
if let Some(obj) = input_obj {
args.push(serde_json::Value::Object(obj.clone()));
} else if !mutation_def.arguments[0].nullable {
missing_required.push("input");
}
} 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 {
for field in &input_type.fields {
let value = input_obj.get(&field.name).cloned();
args.push(value.unwrap_or(serde_json::Value::Null));
}
} 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_inject_value(param_name, source, sec_ctx)?);
}
}
{
let sv = &ctx.schema.session_variables;
if !sv.variables.is_empty() || sv.inject_started_at {
if let Some(sec_ctx) = security_ctx {
let vars =
crate::runtime::executor::security::resolve_session_variables(sv, sec_ctx);
let pairs: Vec<(&str, &str)> =
vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
ctx.adapter.set_session_variables(&pairs).await?;
}
}
}
let rows = ctx.adapter.execute_function_call(sql_source, &args).await?;
let row = rows.into_iter().next().ok_or_else(|| FraiseQLError::Validation {
message: format!("Mutation '{mutation_name}': function returned no rows"),
path: None,
})?;
let outcome = 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 selection_for_type = |type_name: &str| -> Option<Vec<String>> {
if type_selections.is_empty() {
return None;
}
let common = type_selections.get("");
let specific = type_selections.get(type_name);
match (common, specific) {
(None, None) => None,
(Some(c), None) => Some(c.clone()),
(None, Some(s)) => Some(s.clone()),
(Some(c), Some(s)) => {
let mut merged = c.clone();
merged.extend(s.iter().cloned());
Some(merged)
},
}
};
let result_json = match outcome {
MutationOutcome::Success {
entity,
entity_type,
cascade,
..
} => {
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 requested = selection_for_type(&typename);
let mappings: Vec<FieldMapping> = match &requested {
Some(fields) => fields.iter().map(|f| FieldMapping::simple(f.clone())).collect(),
None => {
entity
.as_object()
.map(|m| m.keys().map(|k| FieldMapping::simple(k.clone())).collect())
.unwrap_or_default()
},
};
let mapper = ProjectionMapper::with_mappings(mappings).with_typename(&typename);
let obj = entity.as_object().cloned().unwrap_or_default();
let mut projected = mapper.project_json_object(&obj)?;
if let Some(cascade_json) = cascade {
if let serde_json::Value::Object(ref mut map) = projected {
map.insert("cascade".to_string(), cascade_json);
}
}
projected
},
MutationOutcome::Error {
error_class,
metadata,
..
} => {
let status = error_class.as_str();
let error_type = 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 }
})
});
match error_type {
Some(td) => {
let requested = selection_for_type(td.name.as_str());
let requested_slice = requested.as_deref();
let mut visited = std::collections::HashSet::new();
let mappings = build_field_mappings_from_type(
&td.fields,
&ctx.schema,
requested_slice,
&mut visited,
);
let mapper = ProjectionMapper::with_mappings(mappings)
.with_typename(td.name.to_string());
let obj = metadata.as_object().cloned().unwrap_or_default();
let mut result = mapper.project_json_object(&obj)?;
if let serde_json::Value::Object(ref mut map) = result {
map.insert(
"status".to_string(),
serde_json::Value::String(status.to_string()),
);
}
result
},
None => {
serde_json::json!({ "__typename": mutation_return_type, "status": status })
},
}
},
};
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;