Skip to main content

fraiseql_core/runtime/executor/
mutation.rs

1//! Mutation execution.
2
3use super::{Executor, resolve_inject_value};
4use crate::{
5    db::traits::{DatabaseAdapter, SupportsMutations},
6    error::{FraiseQLError, Result},
7    runtime::{
8        ResultProjector,
9        mutation_result::{MutationOutcome, parse_mutation_row, populate_error_fields},
10        suggest_similar,
11    },
12    security::SecurityContext,
13};
14
15/// Compile-time enforcement: `SqliteAdapter` must NOT implement `SupportsMutations`.
16///
17/// Calling `execute_mutation` on an `Executor<SqliteAdapter>` must not compile
18/// because `SqliteAdapter` does not implement the `SupportsMutations` marker trait.
19///
20/// ```compile_fail
21/// use fraiseql_core::runtime::Executor;
22/// use fraiseql_core::db::sqlite::SqliteAdapter;
23/// use fraiseql_core::schema::CompiledSchema;
24/// use std::sync::Arc;
25/// async fn _wont_compile() {
26///     let adapter = Arc::new(SqliteAdapter::new_in_memory().await.unwrap());
27///     let executor = Executor::new(CompiledSchema::new(), adapter);
28///     executor.execute_mutation("createUser", None).await.unwrap();
29/// }
30/// ```
31impl<A: DatabaseAdapter + SupportsMutations> Executor<A> {
32    /// Execute a GraphQL mutation directly, with compile-time capability enforcement.
33    ///
34    /// Unlike `execute()` (which accepts raw GraphQL strings and performs a runtime
35    /// `supports_mutations()` check), this method is only available on adapters that
36    /// implement [`SupportsMutations`].  The capability is enforced at **compile time**:
37    /// attempting to call this method with `SqliteAdapter` results in a compiler error.
38    ///
39    /// # Arguments
40    ///
41    /// * `mutation_name` - The GraphQL mutation field name (e.g. `"createUser"`)
42    /// * `variables` - Optional JSON object of GraphQL variable values
43    ///
44    /// # Returns
45    ///
46    /// A JSON-encoded GraphQL response string on success.
47    ///
48    /// # Errors
49    ///
50    /// Same as `execute_mutation_query`, minus the adapter capability check.
51    pub async fn execute_mutation(
52        &self,
53        mutation_name: &str,
54        variables: Option<&serde_json::Value>,
55        selection_fields: &[String],
56    ) -> Result<String> {
57        // No runtime supports_mutations() check: the SupportsMutations bound
58        // guarantees at compile time that this adapter supports mutations.
59        self.execute_mutation_query_with_security(mutation_name, variables, None, selection_fields).await
60    }
61}
62
63impl<A: DatabaseAdapter> Executor<A> {
64    /// Execute a GraphQL mutation by calling the configured database function.
65    ///
66    /// Looks up the `MutationDefinition` in the compiled schema, calls
67    /// `execute_function_call` on the database adapter, parses the returned
68    /// `mutation_response` row, and builds a GraphQL response containing either the
69    /// success entity or a populated error-type object (when the function returns a
70    /// `"failed:*"` / `"conflict:*"` / `"error"` status).
71    ///
72    /// This is the **unauthenticated** variant. It delegates to
73    /// `execute_mutation_query_with_security` with `security_ctx = None`, which means
74    /// any `inject` params on the mutation definition will cause a
75    /// [`FraiseQLError::Validation`] error at runtime (inject requires a security
76    /// context).
77    ///
78    /// # Arguments
79    ///
80    /// * `mutation_name` - The GraphQL mutation field name (e.g. `"createUser"`)
81    /// * `variables` - Optional JSON object of GraphQL variable values
82    ///
83    /// # Returns
84    ///
85    /// A JSON-encoded GraphQL response string on success.
86    ///
87    /// # Errors
88    ///
89    /// * [`FraiseQLError::Validation`] — mutation name not found in the compiled schema
90    /// * [`FraiseQLError::Validation`] — mutation definition has no `sql_source` configured
91    /// * [`FraiseQLError::Validation`] — mutation requires `inject` params (needs security ctx)
92    /// * [`FraiseQLError::Validation`] — the database function returned no rows
93    /// * [`FraiseQLError::Database`] — the adapter's `execute_function_call` returned an error
94    ///
95    /// # Example
96    ///
97    /// ```no_run
98    /// // Requires: live database adapter with SupportsMutations implementation.
99    /// // See: tests/integration/ for runnable examples.
100    /// # use fraiseql_core::db::postgres::PostgresAdapter;
101    /// # use fraiseql_core::schema::CompiledSchema;
102    /// # use fraiseql_core::runtime::Executor;
103    /// # use std::sync::Arc;
104    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
105    /// # let schema: CompiledSchema = panic!("example");
106    /// # let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
107    /// # let executor = Executor::new(schema, Arc::new(adapter));
108    /// let vars = serde_json::json!({ "name": "Alice", "email": "alice@example.com" });
109    /// let fields = vec!["id".to_string(), "name".to_string()];
110    /// // Returns {"data":{"createUser":{"id":"...", "name":"Alice"}}}
111    /// // or      {"data":{"createUser":{"__typename":"UserAlreadyExistsError", "email":"..."}}}
112    /// let result = executor.execute_mutation("createUser", Some(&vars), &fields).await?;
113    /// # Ok(())
114    /// # }
115    /// ```
116    pub(super) async fn execute_mutation_query(
117        &self,
118        mutation_name: &str,
119        variables: Option<&serde_json::Value>,
120        selection_fields: &[String],
121    ) -> Result<String> {
122        // Runtime guard: verify this adapter supports mutations.
123        // Note: this is a runtime check, not compile-time enforcement.
124        // The common execute() entry point accepts raw GraphQL strings and
125        // determines the operation type at runtime, which precludes compile-time
126        // mutation gating. A future API revision (separate execute_mutation() method)
127        // would move this to a compile-time bound (see roadmap.md).
128        if !self.adapter.supports_mutations() {
129            return Err(FraiseQLError::Validation {
130                message: format!(
131                    "Mutation '{mutation_name}' cannot be executed: the configured database \
132                     adapter does not support mutations. Use PostgresAdapter, MySqlAdapter, \
133                     or SqlServerAdapter for mutation operations."
134                ),
135                path:    None,
136            });
137        }
138        self.execute_mutation_query_with_security(mutation_name, variables, None, selection_fields).await
139    }
140
141    /// Internal implementation shared by `execute_mutation_query` and the
142    /// security-aware path in `execute_with_security_internal`.
143    ///
144    /// Callers provide an optional [`SecurityContext`]:
145    /// - `None` — unauthenticated path; mutations with `inject` params will fail.
146    /// - `Some(ctx)` — authenticated path; `inject` param values are resolved from `ctx`'s JWT
147    ///   claims and appended to the positional argument list after the client-supplied variables.
148    ///
149    /// # Arguments
150    ///
151    /// * `mutation_name` - The GraphQL mutation field name (e.g. `"deletePost"`)
152    /// * `variables` - Optional JSON object of client-supplied variable values
153    /// * `security_ctx` - Optional authenticated user context; required when the mutation
154    ///   definition has one or more `inject` params
155    ///
156    /// # Errors
157    ///
158    /// * [`FraiseQLError::Validation`] — mutation not found, no `sql_source`, missing security
159    ///   context for `inject` params, or database function returned no rows.
160    /// * [`FraiseQLError::Database`] — the adapter's `execute_function_call` failed.
161    pub(super) async fn execute_mutation_query_with_security(
162        &self,
163        mutation_name: &str,
164        variables: Option<&serde_json::Value>,
165        security_ctx: Option<&SecurityContext>,
166        selection_fields: &[String],
167    ) -> Result<String> {
168        // 1. Locate the mutation definition
169        let mutation_def = self.schema.find_mutation(mutation_name).ok_or_else(|| {
170            let candidates: Vec<&str> =
171                self.schema.mutations.iter().map(|m| m.name.as_str()).collect();
172            let suggestion = suggest_similar(mutation_name, &candidates);
173            let message = match suggestion.as_slice() {
174                [s] => {
175                    format!("Mutation '{mutation_name}' not found in schema. Did you mean '{s}'?")
176                },
177                [a, b] => format!(
178                    "Mutation '{mutation_name}' not found in schema. Did you mean '{a}' or \
179                         '{b}'?"
180                ),
181                [a, b, c, ..] => format!(
182                    "Mutation '{mutation_name}' not found in schema. Did you mean '{a}', \
183                         '{b}', or '{c}'?"
184                ),
185                _ => format!("Mutation '{mutation_name}' not found in schema"),
186            };
187            FraiseQLError::Validation {
188                message,
189                path: None,
190            }
191        })?;
192
193        // 2. Require a sql_source (PostgreSQL function name).
194        //
195        // Fall back to the operation's table field when sql_source is absent.
196        // The CLI compiler stores the SQL function name in both places
197        // (sql_source and operation.{Insert|Update|Delete}.table), but older or
198        // alternate compilation paths (e.g. fraiseql-core's own codegen) may only
199        // populate operation.table and leave sql_source as None.
200        let sql_source_owned: String;
201        let sql_source: &str = if let Some(src) = mutation_def.sql_source.as_deref() {
202            src
203        } else {
204            use crate::schema::MutationOperation;
205            match &mutation_def.operation {
206                MutationOperation::Insert { table }
207                | MutationOperation::Update { table }
208                | MutationOperation::Delete { table }
209                    if !table.is_empty() =>
210                {
211                    sql_source_owned = table.clone();
212                    &sql_source_owned
213                },
214                _ => {
215                    return Err(FraiseQLError::Validation {
216                        message: format!("Mutation '{mutation_name}' has no sql_source configured"),
217                        path:    None,
218                    });
219                },
220            }
221        };
222
223        // 3. Build positional args Vec from variables in ArgumentDefinition order. Validate that
224        //    every required (non-nullable, no default) argument is present.
225        let vars_obj = variables.and_then(|v| v.as_object());
226
227        let mut missing_required: Vec<&str> = Vec::new();
228        let total_args = mutation_def.arguments.len() + mutation_def.inject_params.len();
229        let mut args: Vec<serde_json::Value> = Vec::with_capacity(total_args);
230        args.extend(mutation_def.arguments.iter().map(|arg| {
231            let value = vars_obj.and_then(|obj| obj.get(&arg.name)).cloned();
232            if let Some(v) = value {
233                v
234            } else {
235                if !arg.nullable && arg.default_value.is_none() {
236                    missing_required.push(&arg.name);
237                }
238                arg.default_value.as_ref().map_or(serde_json::Value::Null, |v| v.to_json())
239            }
240        }));
241
242        if !missing_required.is_empty() {
243            return Err(FraiseQLError::Validation {
244                message: format!(
245                    "Mutation '{mutation_name}' is missing required argument(s): {}",
246                    missing_required.join(", ")
247                ),
248                path:    None,
249            });
250        }
251
252        // 3a. Append server-injected parameters (after client args, in injection order).
253        //
254        // CONTRACT: inject params are always the *last* positional parameters of the SQL
255        // function, in the order they appear in `inject_params` (insertion-ordered IndexMap).
256        // The SQL function signature in the database MUST declare injected parameters after
257        // all client-supplied parameters. Violating this order silently passes inject values
258        // to the wrong SQL parameters. The CLI compiler (`fraiseql-cli compile`) validates
259        // inject key names and source syntax when producing `schema.compiled.json`, but
260        // cannot verify SQL function arity — that remains a developer responsibility.
261        if !mutation_def.inject_params.is_empty() {
262            let ctx = security_ctx.ok_or_else(|| FraiseQLError::Validation {
263                message: format!(
264                    "Mutation '{}' requires inject params but no security context is available \
265                     (unauthenticated request)",
266                    mutation_name
267                ),
268                path:    None,
269            })?;
270            for (param_name, source) in &mutation_def.inject_params {
271                args.push(resolve_inject_value(param_name, source, ctx)?);
272            }
273        }
274
275        // 4. Call the database function
276        let rows = self.adapter.execute_function_call(sql_source, &args).await?;
277
278        // 5. Expect at least one row
279        let row = rows.into_iter().next().ok_or_else(|| FraiseQLError::Validation {
280            message: format!("Mutation '{mutation_name}': function returned no rows"),
281            path:    None,
282        })?;
283
284        // 6. Parse the mutation_response row
285        let outcome = parse_mutation_row(&row)?;
286
287        // 6a. Bump fact table versions after a successful mutation.
288        //
289        // This invalidates cached aggregation results for any fact tables listed
290        // in `MutationDefinition.invalidates_fact_tables`.  We bump versions on
291        // Success only — an Error outcome means no data was written, so caches
292        // remain valid.  Non-cached adapters return Ok(()) from the default trait
293        // implementation (no-op); only `CachedDatabaseAdapter` performs actual work.
294        if matches!(outcome, MutationOutcome::Success { .. })
295            && !mutation_def.invalidates_fact_tables.is_empty()
296        {
297            self.adapter
298                .bump_fact_table_versions(&mutation_def.invalidates_fact_tables)
299                .await?;
300        }
301
302        // Invalidate query result cache for views/entities touched by this mutation.
303        //
304        // Strategy:
305        // - UPDATE/DELETE with entity_id: entity-aware eviction only (precise, no false positives).
306        //   Evicts only the cache entries that actually contain the mutated entity UUID.
307        // - CREATE or explicit invalidates_views: view-level flush. For CREATE the new entity isn't
308        //   in any existing cache entry, so entity-aware is a no-op. View-level ensures list
309        //   queries return the new row.
310        // - No entity_id and no views declared: infer view from return type (backward-compat).
311        if let MutationOutcome::Success {
312            entity_type,
313            entity_id,
314            ..
315        } = &outcome
316        {
317            // Entity-aware path: precise eviction for UPDATE/DELETE.
318            if let (Some(etype), Some(eid)) = (entity_type.as_deref(), entity_id.as_deref()) {
319                self.adapter.invalidate_by_entity(etype, eid).await?;
320            }
321
322            // View-level path: needed when entity_id is absent (CREATE) or when the developer
323            // explicitly declared invalidates_views to also refresh list queries.
324            if entity_id.is_none() || !mutation_def.invalidates_views.is_empty() {
325                let views_to_invalidate = if mutation_def.invalidates_views.is_empty() {
326                    self.schema
327                        .types
328                        .iter()
329                        .find(|t| t.name == mutation_def.return_type)
330                        .filter(|t| !t.sql_source.as_str().is_empty())
331                        .map(|t| t.sql_source.to_string())
332                        .into_iter()
333                        .collect::<Vec<_>>()
334                } else {
335                    mutation_def.invalidates_views.clone()
336                };
337                if !views_to_invalidate.is_empty() {
338                    if entity_id.is_none() {
339                        // CREATE: the new entity is absent from all existing cache entries,
340                        // so point-lookup entries for other entities remain valid.  Only
341                        // list queries need eviction (the new row must appear in results).
342                        self.adapter.invalidate_list_queries(&views_to_invalidate).await?;
343                    } else {
344                        // Developer-declared invalidates_views on an UPDATE/DELETE: honour
345                        // the explicit annotation with a full view sweep.
346                        self.adapter.invalidate_views(&views_to_invalidate).await?;
347                    }
348                }
349            }
350        }
351
352        // Clone name and return_type to avoid borrow issues after schema lookups
353        let mutation_return_type = mutation_def.return_type.clone();
354        let mutation_name_owned = mutation_name.to_string();
355
356        let result_json = match outcome {
357            MutationOutcome::Success {
358                entity,
359                entity_type,
360                ..
361            } => {
362                // Determine the GraphQL __typename
363                let typename = entity_type
364                    .or_else(|| {
365                        // Fall back to first non-error union member
366                        self.schema
367                            .find_union(&mutation_return_type)
368                            .and_then(|u| {
369                                u.member_types.iter().find(|t| {
370                                    self.schema.find_type(t).is_none_or(|td| !td.is_error)
371                                })
372                            })
373                            .cloned()
374                    })
375                    .unwrap_or_else(|| mutation_return_type.clone());
376
377                let mut obj = entity.as_object().cloned().unwrap_or_default();
378                obj.insert("__typename".to_string(), serde_json::Value::String(typename));
379
380                // Apply selection set filtering: only include requested fields (plus __typename)
381                if !selection_fields.is_empty() {
382                    obj.retain(|k, _| k == "__typename" || selection_fields.contains(k));
383                }
384
385                serde_json::Value::Object(obj)
386            },
387            MutationOutcome::Error {
388                status, metadata, ..
389            } => {
390                // Find the matching error type from the return union
391                let error_type = self.schema.find_union(&mutation_return_type).and_then(|u| {
392                    u.member_types.iter().find_map(|t| {
393                        let td = self.schema.find_type(t)?;
394                        if td.is_error { Some(td) } else { None }
395                    })
396                });
397
398                match error_type {
399                    Some(td) => {
400                        let mut fields = populate_error_fields(&td.fields, &metadata);
401                        fields.insert(
402                            "__typename".to_string(),
403                            serde_json::Value::String(td.name.to_string()),
404                        );
405                        // Include status so the client can act on it
406                        fields.insert("status".to_string(), serde_json::Value::String(status));
407                        serde_json::Value::Object(fields)
408                    },
409                    None => {
410                        // No error type defined: surface the status as a plain object
411                        serde_json::json!({ "__typename": mutation_return_type, "status": status })
412                    },
413                }
414            },
415        };
416
417        let response = ResultProjector::wrap_in_data_envelope(result_json, &mutation_name_owned);
418        Ok(serde_json::to_string(&response)?)
419    }
420}