Skip to main content

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}