fraiseql_core/runtime/executor/mutation.rs
1//! Mutation execution.
2
3use std::collections::HashMap;
4
5use super::{Executor, resolve_inject_value};
6use crate::{
7 db::traits::{DatabaseAdapter, SupportsMutations},
8 error::{FraiseQLError, Result},
9 runtime::{
10 FieldMapping, ProjectionMapper, ResultProjector, build_field_mappings_from_type,
11 mutation_result::{MutationOutcome, parse_mutation_row},
12 suggest_similar,
13 },
14 schema::MutationOperation,
15 security::SecurityContext,
16};
17
18/// Compile-time enforcement: `SqliteAdapter` must NOT implement `SupportsMutations`.
19///
20/// Calling `execute_mutation` on an `Executor<SqliteAdapter>` must not compile
21/// because `SqliteAdapter` does not implement the `SupportsMutations` marker trait.
22///
23/// ```compile_fail
24/// use fraiseql_core::runtime::Executor;
25/// use fraiseql_core::db::sqlite::SqliteAdapter;
26/// use fraiseql_core::schema::CompiledSchema;
27/// use std::sync::Arc;
28/// async fn _wont_compile() {
29/// let adapter = Arc::new(SqliteAdapter::new_in_memory().await.unwrap());
30/// let executor = Executor::new(CompiledSchema::new(), adapter);
31/// executor.execute_mutation("createUser", None).await.unwrap();
32/// }
33/// ```
34impl<A: DatabaseAdapter + SupportsMutations> Executor<A> {
35 /// Execute a GraphQL mutation directly, with compile-time capability enforcement.
36 ///
37 /// Unlike `execute()` (which accepts raw GraphQL strings and performs a runtime
38 /// `supports_mutations()` check), this method is only available on adapters that
39 /// implement [`SupportsMutations`]. The capability is enforced at **compile time**:
40 /// attempting to call this method with `SqliteAdapter` results in a compiler error.
41 ///
42 /// # Arguments
43 ///
44 /// * `mutation_name` - The GraphQL mutation field name (e.g. `"createUser"`)
45 /// * `variables` - Optional JSON object of GraphQL variable values
46 ///
47 /// # Returns
48 ///
49 /// A JSON-encoded GraphQL response string on success.
50 ///
51 /// # Errors
52 ///
53 /// Same as `execute_mutation_query`, minus the adapter capability check.
54 pub async fn execute_mutation(
55 &self,
56 mutation_name: &str,
57 variables: Option<&serde_json::Value>,
58 type_selections: &HashMap<String, Vec<String>>,
59 ) -> Result<serde_json::Value> {
60 // No runtime supports_mutations() check: the SupportsMutations bound
61 // guarantees at compile time that this adapter supports mutations.
62 self.execute_mutation_query_with_security(mutation_name, variables, None, type_selections)
63 .await
64 }
65}
66
67impl<A: DatabaseAdapter> Executor<A> {
68 /// Execute a GraphQL mutation by calling the configured database function.
69 ///
70 /// Looks up the `MutationDefinition` in the compiled schema, calls
71 /// `execute_function_call` on the database adapter, parses the returned
72 /// `mutation_response` row, and builds a GraphQL response containing either the
73 /// success entity or a populated error-type object (when the function returns a
74 /// `"failed:*"` / `"conflict:*"` / `"error"` status).
75 ///
76 /// This is the **unauthenticated** variant. It delegates to
77 /// `execute_mutation_query_with_security` with `security_ctx = None`, which means
78 /// any `inject` params on the mutation definition will cause a
79 /// [`FraiseQLError::Validation`] error at runtime (inject requires a security
80 /// context).
81 ///
82 /// # Arguments
83 ///
84 /// * `mutation_name` - The GraphQL mutation field name (e.g. `"createUser"`)
85 /// * `variables` - Optional JSON object of GraphQL variable values
86 ///
87 /// # Returns
88 ///
89 /// A JSON-encoded GraphQL response string on success.
90 ///
91 /// # Errors
92 ///
93 /// * [`FraiseQLError::Validation`] — mutation name not found in the compiled schema
94 /// * [`FraiseQLError::Validation`] — mutation definition has no `sql_source` configured
95 /// * [`FraiseQLError::Validation`] — mutation requires `inject` params (needs security ctx)
96 /// * [`FraiseQLError::Validation`] — the database function returned no rows
97 /// * [`FraiseQLError::Database`] — the adapter's `execute_function_call` returned an error
98 ///
99 /// # Example
100 ///
101 /// ```no_run
102 /// // Requires: live database adapter with SupportsMutations implementation.
103 /// // See: tests/integration/ for runnable examples.
104 /// # use fraiseql_core::db::postgres::PostgresAdapter;
105 /// # use fraiseql_core::schema::CompiledSchema;
106 /// # use fraiseql_core::runtime::Executor;
107 /// # use std::sync::Arc;
108 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
109 /// # let schema: CompiledSchema = panic!("example");
110 /// # let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
111 /// # let executor = Executor::new(schema, Arc::new(adapter));
112 /// let vars = serde_json::json!({ "name": "Alice", "email": "alice@example.com" });
113 /// let selections = std::collections::HashMap::new(); // no filtering
114 /// // Returns {"data":{"createUser":{"id":"...", "name":"Alice"}}}
115 /// // or {"data":{"createUser":{"__typename":"UserAlreadyExistsError", "email":"..."}}}
116 /// let result = executor.execute_mutation("createUser", Some(&vars), &selections).await?;
117 /// # Ok(())
118 /// # }
119 /// ```
120 pub(super) async fn execute_mutation_query(
121 &self,
122 mutation_name: &str,
123 variables: Option<&serde_json::Value>,
124 type_selections: &HashMap<String, Vec<String>>,
125 ) -> Result<serde_json::Value> {
126 // Runtime guard: verify this adapter supports mutations.
127 // Note: this is a runtime check, not compile-time enforcement.
128 // The common execute() entry point accepts raw GraphQL strings and
129 // determines the operation type at runtime, which precludes compile-time
130 // mutation gating. A future API revision (separate execute_mutation() method)
131 // would move this to a compile-time bound (see roadmap.md).
132 if !self.adapter.supports_mutations() {
133 return Err(FraiseQLError::Validation {
134 message: format!(
135 "Mutation '{mutation_name}' cannot be executed: the configured database \
136 adapter does not support mutations. Use PostgresAdapter, MySqlAdapter, \
137 or SqlServerAdapter for mutation operations."
138 ),
139 path: None,
140 });
141 }
142 self.execute_mutation_query_with_security(mutation_name, variables, None, type_selections)
143 .await
144 }
145
146 /// Internal implementation shared by `execute_mutation_query` and the
147 /// security-aware path in `execute_with_security_internal`.
148 ///
149 /// Callers provide an optional [`SecurityContext`]:
150 /// - `None` — unauthenticated path; mutations with `inject` params will fail.
151 /// - `Some(ctx)` — authenticated path; `inject` param values are resolved from `ctx`'s JWT
152 /// claims and appended to the positional argument list after the client-supplied variables.
153 ///
154 /// # Arguments
155 ///
156 /// * `mutation_name` - The GraphQL mutation field name (e.g. `"deletePost"`)
157 /// * `variables` - Optional JSON object of client-supplied variable values
158 /// * `security_ctx` - Optional authenticated user context; required when the mutation
159 /// definition has one or more `inject` params
160 ///
161 /// # Errors
162 ///
163 /// * [`FraiseQLError::Validation`] — mutation not found, no `sql_source`, missing security
164 /// context for `inject` params, or database function returned no rows.
165 /// * [`FraiseQLError::Database`] — the adapter's `execute_function_call` failed.
166 pub(super) async fn execute_mutation_query_with_security(
167 &self,
168 mutation_name: &str,
169 variables: Option<&serde_json::Value>,
170 security_ctx: Option<&SecurityContext>,
171 type_selections: &HashMap<String, Vec<String>>,
172 ) -> Result<serde_json::Value> {
173 // 1. Locate the mutation definition
174 let mutation_def = self.schema.find_mutation(mutation_name).ok_or_else(|| {
175 let display_names: Vec<String> = self
176 .schema
177 .mutations
178 .iter()
179 .map(|m| self.schema.display_name(&m.name))
180 .collect();
181 let candidate_refs: Vec<&str> = display_names.iter().map(String::as_str).collect();
182 let suggestion = suggest_similar(mutation_name, &candidate_refs);
183 let message = match suggestion.as_slice() {
184 [s] => {
185 format!("Mutation '{mutation_name}' not found in schema. Did you mean '{s}'?")
186 },
187 [a, b] => format!(
188 "Mutation '{mutation_name}' not found in schema. Did you mean '{a}' or \
189 '{b}'?"
190 ),
191 [a, b, c, ..] => format!(
192 "Mutation '{mutation_name}' not found in schema. Did you mean '{a}', \
193 '{b}', or '{c}'?"
194 ),
195 _ => format!("Mutation '{mutation_name}' not found in schema"),
196 };
197 FraiseQLError::Validation {
198 message,
199 path: None,
200 }
201 })?;
202
203 // 2. Require a sql_source (PostgreSQL function name).
204 //
205 // Fall back to the operation's table field when sql_source is absent.
206 // The CLI compiler stores the SQL function name in both places
207 // (sql_source and operation.{Insert|Update|Delete}.table), but older or
208 // alternate compilation paths (e.g. fraiseql-core's own codegen) may only
209 // populate operation.table and leave sql_source as None.
210 let sql_source_owned: String;
211 let sql_source: &str = if let Some(src) = mutation_def.sql_source.as_deref() {
212 src
213 } else {
214 match &mutation_def.operation {
215 MutationOperation::Insert { table }
216 | MutationOperation::Update { table }
217 | MutationOperation::Delete { table }
218 if !table.is_empty() =>
219 {
220 sql_source_owned = table.clone();
221 &sql_source_owned
222 },
223 _ => {
224 return Err(FraiseQLError::Validation {
225 message: format!("Mutation '{mutation_name}' has no sql_source configured"),
226 path: None,
227 });
228 },
229 }
230 };
231
232 // 3. Build positional args Vec from variables in ArgumentDefinition order. Validate that
233 // every required (non-nullable, no default) argument is present.
234 //
235 // Input object unwrapping: when the mutation has a single argument named "input"
236 // whose type is an Input type, AND the client sends a JSON object for that argument,
237 // unwrap the object's fields and pass them positionally in the order defined by the
238 // input type's field list. This keeps the SQL function signature flat while letting
239 // the GraphQL API use the standard input object pattern.
240 let vars_obj = variables.and_then(|v| v.as_object());
241
242 let mut missing_required: Vec<&str> = Vec::new();
243 let total_args = mutation_def.arguments.len() + mutation_def.inject_params.len();
244 let mut args: Vec<serde_json::Value> = Vec::with_capacity(total_args);
245
246 // Detect single-input-object pattern
247 let input_type_name =
248 if mutation_def.arguments.len() == 1 && mutation_def.arguments[0].name == "input" {
249 match &mutation_def.arguments[0].arg_type {
250 crate::schema::FieldType::Input(name) => Some(name.as_str()),
251 _ => None,
252 }
253 } else {
254 None
255 };
256
257 // Update mutations pass the entire input object as a single JSONB arg, which
258 // preserves all three field states that typed positional args cannot express:
259 // - key absent → leave the database value unchanged
260 // - key present, null → SET field = NULL
261 // - key present, value → SET field = <value>
262 // SQL update functions use `input_payload ? 'field'` to test key presence.
263 //
264 // Insert / Delete / Custom flatten the Input type fields to positional args as
265 // before (no three-state problem: absent ≡ NULL for creates; deletes need only
266 // the PK).
267 let is_update = matches!(&mutation_def.operation, MutationOperation::Update { .. });
268
269 if is_update && input_type_name.is_some() {
270 // Pass the entire input object as a single JSONB arg.
271 let input_obj = vars_obj.and_then(|obj| obj.get("input")).and_then(|v| v.as_object());
272 if let Some(obj) = input_obj {
273 args.push(serde_json::Value::Object(obj.clone()));
274 } else if !mutation_def.arguments[0].nullable {
275 missing_required.push("input");
276 }
277 } else if let Some(input_type) =
278 input_type_name.and_then(|n| self.schema.find_input_type(n))
279 {
280 // Insert / Delete / Custom: flatten Input type fields to positional typed args.
281 let input_obj = vars_obj.and_then(|obj| obj.get("input")).and_then(|v| v.as_object());
282 if let Some(input_obj) = input_obj {
283 for field in &input_type.fields {
284 let value = input_obj.get(&field.name).cloned();
285 args.push(value.unwrap_or(serde_json::Value::Null));
286 }
287 } else if !mutation_def.arguments[0].nullable {
288 missing_required.push("input");
289 }
290 } else {
291 // Standard argument handling (flat arguments, no input object)
292 args.extend(mutation_def.arguments.iter().map(|arg| {
293 let value = vars_obj.and_then(|obj| obj.get(&arg.name)).cloned();
294 if let Some(v) = value {
295 v
296 } else {
297 if !arg.nullable && arg.default_value.is_none() {
298 missing_required.push(&arg.name);
299 }
300 arg.default_value.as_ref().map_or(serde_json::Value::Null, |v| v.to_json())
301 }
302 }));
303 }
304
305 if !missing_required.is_empty() {
306 return Err(FraiseQLError::Validation {
307 message: format!(
308 "Mutation '{mutation_name}' is missing required argument(s): {}",
309 missing_required.join(", ")
310 ),
311 path: None,
312 });
313 }
314
315 // 3a. Append server-injected parameters (after client args, in injection order).
316 //
317 // CONTRACT: inject params are always the *last* positional parameters of the SQL
318 // function, in the order they appear in `inject_params` (insertion-ordered IndexMap).
319 // The SQL function signature in the database MUST declare injected parameters after
320 // all client-supplied parameters. Violating this order silently passes inject values
321 // to the wrong SQL parameters. The CLI compiler (`fraiseql-cli compile`) validates
322 // inject key names and source syntax when producing `schema.compiled.json`, but
323 // cannot verify SQL function arity — that remains a developer responsibility.
324 if !mutation_def.inject_params.is_empty() {
325 let ctx = security_ctx.ok_or_else(|| FraiseQLError::Validation {
326 message: format!(
327 "Mutation '{}' requires inject params but no security context is available \
328 (unauthenticated request)",
329 mutation_name
330 ),
331 path: None,
332 })?;
333 for (param_name, source) in &mutation_def.inject_params {
334 args.push(resolve_inject_value(param_name, source, ctx)?);
335 }
336 }
337
338 // 3b. Inject session variables (transaction-scoped set_config) when configured.
339 //
340 // Only called when there are variables to inject or inject_started_at is enabled,
341 // and only on the authenticated path (security context present). The no-op default
342 // on non-PostgreSQL adapters means this call is effectively free there.
343 {
344 let sv = &self.schema.session_variables;
345 if !sv.variables.is_empty() || sv.inject_started_at {
346 if let Some(ctx) = security_ctx {
347 let vars =
348 crate::runtime::executor::security::resolve_session_variables(sv, ctx);
349 let pairs: Vec<(&str, &str)> =
350 vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
351 self.adapter.set_session_variables(&pairs).await?;
352 }
353 }
354 }
355
356 // 4. Call the database function
357 let rows = self.adapter.execute_function_call(sql_source, &args).await?;
358
359 // 5. Expect at least one row
360 let row = rows.into_iter().next().ok_or_else(|| FraiseQLError::Validation {
361 message: format!("Mutation '{mutation_name}': function returned no rows"),
362 path: None,
363 })?;
364
365 // 6. Parse the mutation_response row
366 let outcome = parse_mutation_row(&row)?;
367
368 // 6a. Bump fact table versions after a successful mutation.
369 //
370 // This invalidates cached aggregation results for any fact tables listed
371 // in `MutationDefinition.invalidates_fact_tables`. We bump versions on
372 // Success only — an Error outcome means no data was written, so caches
373 // remain valid. Non-cached adapters return Ok(()) from the default trait
374 // implementation (no-op); only `CachedDatabaseAdapter` performs actual work.
375 if matches!(outcome, MutationOutcome::Success { .. })
376 && !mutation_def.invalidates_fact_tables.is_empty()
377 {
378 self.adapter
379 .bump_fact_table_versions(&mutation_def.invalidates_fact_tables)
380 .await?;
381 }
382
383 // Invalidate query result cache for views/entities touched by this mutation.
384 //
385 // Strategy:
386 // - UPDATE/DELETE with entity_id: entity-aware eviction only (precise, no false positives).
387 // Evicts only the cache entries that actually contain the mutated entity UUID.
388 // - CREATE or explicit invalidates_views: view-level flush. For CREATE the new entity isn't
389 // in any existing cache entry, so entity-aware is a no-op. View-level ensures list
390 // queries return the new row.
391 // - No entity_id and no views declared: infer view from return type (backward-compat).
392 if let MutationOutcome::Success {
393 entity_type,
394 entity_id,
395 ..
396 } = &outcome
397 {
398 // Entity-aware path: precise eviction for UPDATE/DELETE.
399 if let (Some(etype), Some(eid)) = (entity_type.as_deref(), entity_id.as_deref()) {
400 self.adapter.invalidate_by_entity(etype, eid).await?;
401
402 // The response cache doesn't have entity-level granularity, so
403 // invalidate by the inferred view for this entity type.
404 if let Some(ref rc) = self.response_cache {
405 let inferred_view = self
406 .schema
407 .types
408 .iter()
409 .find(|t| t.name == etype)
410 .filter(|t| !t.sql_source.as_str().is_empty())
411 .map(|t| t.sql_source.to_string());
412 if let Some(view) = inferred_view {
413 let _ = rc.invalidate_views(&[view]);
414 }
415 }
416 }
417
418 // View-level path: needed when entity_id is absent (CREATE) or when the developer
419 // explicitly declared invalidates_views to also refresh list queries.
420 if entity_id.is_none() || !mutation_def.invalidates_views.is_empty() {
421 let views_to_invalidate = if mutation_def.invalidates_views.is_empty() {
422 self.schema
423 .types
424 .iter()
425 .find(|t| t.name == mutation_def.return_type)
426 .filter(|t| !t.sql_source.as_str().is_empty())
427 .map(|t| t.sql_source.to_string())
428 .into_iter()
429 .collect::<Vec<_>>()
430 } else {
431 mutation_def.invalidates_views.clone()
432 };
433 if !views_to_invalidate.is_empty() {
434 if entity_id.is_none() {
435 // CREATE: the new entity is absent from all existing cache entries,
436 // so point-lookup entries for other entities remain valid. Only
437 // list queries need eviction (the new row must appear in results).
438 self.adapter.invalidate_list_queries(&views_to_invalidate).await?;
439 } else {
440 // Developer-declared invalidates_views on an UPDATE/DELETE: honour
441 // the explicit annotation with a full view sweep.
442 self.adapter.invalidate_views(&views_to_invalidate).await?;
443 }
444 // Also invalidate the response cache for these views
445 if let Some(ref rc) = self.response_cache {
446 let _ = rc.invalidate_views(&views_to_invalidate);
447 }
448 }
449 }
450 }
451
452 // Clone name and return_type to avoid borrow issues after schema lookups
453 let mutation_return_type = mutation_def.return_type.clone();
454 let mutation_name_owned = mutation_name.to_string();
455
456 // Helper: merge common fields (key "") with type-specific fields for selection filtering.
457 let selection_for_type = |type_name: &str| -> Option<Vec<String>> {
458 if type_selections.is_empty() {
459 return None;
460 }
461 let common = type_selections.get("");
462 let specific = type_selections.get(type_name);
463 match (common, specific) {
464 (None, None) => None,
465 (Some(c), None) => Some(c.clone()),
466 (None, Some(s)) => Some(s.clone()),
467 (Some(c), Some(s)) => {
468 let mut merged = c.clone();
469 merged.extend(s.iter().cloned());
470 Some(merged)
471 },
472 }
473 };
474
475 let result_json = match outcome {
476 MutationOutcome::Success {
477 entity,
478 entity_type,
479 cascade,
480 ..
481 } => {
482 // Determine the GraphQL __typename
483 let typename = entity_type
484 .or_else(|| {
485 // Fall back to first non-error union member
486 self.schema
487 .find_union(&mutation_return_type)
488 .and_then(|u| {
489 u.member_types.iter().find(|t| {
490 self.schema.find_type(t).is_none_or(|td| !td.is_error)
491 })
492 })
493 .cloned()
494 })
495 .unwrap_or_else(|| mutation_return_type.clone());
496
497 // Build projection mappings from the selection set.
498 // Success entities use snake_case keys (from DB), so source == output.
499 let requested = selection_for_type(&typename);
500 let mappings: Vec<FieldMapping> = match &requested {
501 Some(fields) => {
502 fields.iter().map(|f| FieldMapping::simple(f.clone())).collect()
503 },
504 None => {
505 // No selection filtering — pass all fields
506 entity
507 .as_object()
508 .map(|m| m.keys().map(|k| FieldMapping::simple(k.clone())).collect())
509 .unwrap_or_default()
510 },
511 };
512
513 let mapper = ProjectionMapper::with_mappings(mappings).with_typename(&typename);
514 let obj = entity.as_object().cloned().unwrap_or_default();
515 let mut projected = mapper.project_json_object(&obj)?;
516
517 // Inject cascade JSONB into the projected object when present.
518 // This surfaces the graphql-cascade wire format
519 // (updated/deleted/invalidations/metadata) to clients without
520 // requiring the DB function to embed it in the entity JSONB itself.
521 if let Some(cascade_json) = cascade {
522 if let serde_json::Value::Object(ref mut map) = projected {
523 map.insert("cascade".to_string(), cascade_json);
524 }
525 }
526
527 projected
528 },
529 MutationOutcome::Error {
530 error_class,
531 metadata,
532 ..
533 } => {
534 let status = error_class.as_str();
535
536 // Find the matching error type from the return union
537 let error_type = self.schema.find_union(&mutation_return_type).and_then(|u| {
538 u.member_types.iter().find_map(|t| {
539 let td = self.schema.find_type(t)?;
540 if td.is_error { Some(td) } else { None }
541 })
542 });
543
544 match error_type {
545 Some(td) => {
546 // Build field mappings from the error type definition, with camelCase
547 // source keys and recursive nested object/array projection (#215).
548 let requested = selection_for_type(td.name.as_str());
549 let requested_slice = requested.as_deref();
550 let mut visited = std::collections::HashSet::new();
551 let mappings = build_field_mappings_from_type(
552 &td.fields,
553 &self.schema,
554 requested_slice,
555 &mut visited,
556 );
557
558 let mapper = ProjectionMapper::with_mappings(mappings)
559 .with_typename(td.name.to_string());
560 let obj = metadata.as_object().cloned().unwrap_or_default();
561 let mut result = mapper.project_json_object(&obj)?;
562
563 // Inject status (not in type definition, but required by clients)
564 if let serde_json::Value::Object(ref mut map) = result {
565 map.insert(
566 "status".to_string(),
567 serde_json::Value::String(status.to_string()),
568 );
569 }
570
571 result
572 },
573 None => {
574 // No error type defined: surface the status as a plain object
575 serde_json::json!({ "__typename": mutation_return_type, "status": status })
576 },
577 }
578 },
579 };
580
581 let response = ResultProjector::wrap_in_data_envelope(result_json, &mutation_name_owned);
582 Ok(response)
583 }
584}