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}