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