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}