fraiseql_core/runtime/executor/mutation.rs
1//! Mutation execution — thin wrappers on `Executor`.
2//!
3//! The core mutation logic lives in
4//! [`runners::mutation::execute_mutation_impl`](super::runners::mutation::execute_mutation_impl).
5//! This module contains:
6//!
7//! - The typed public API ([`Executor::execute_mutation`] and friends), which a transport uses when
8//! it already holds structured arguments.
9//! - The internal dispatch entry point for a client document (`Executor::execute_mutation_query`).
10//! - Convenience wrappers used by the REST transport ([`execute_mutation_with_security`],
11//! [`execute_mutation_batch`], [`execute_bulk_by_ids`]).
12//!
13//! None of them carries a capability bound. All converge on `execute_mutation_impl`, whose
14//! step 0 resolves the executor's write slot — see [`Executor::new`] for where the
15//! compile-time half of that decision is made.
16
17use super::{Executor, runners};
18use crate::{
19 error::{FraiseQLError, Result},
20 graphql::FieldSelection,
21 security::SecurityContext,
22};
23
24/// The typed write entries.
25///
26/// These used to sit on an `impl<A: DatabaseAdapter + SupportsMutations>` block, and the
27/// bound was described as the thing keeping a read-only adapter out. It kept out an
28/// adapter without the *marker*; it could not speak for `supports_mutations()`, the
29/// runtime gate `execute_function_call` is keyed on, and these entries skipped that check
30/// on the strength of the bound. An adapter carrying one and not the other was dispatched.
31///
32/// The question is now asked once, at step 0 of the chokepoint, against the write handle
33/// the executor holds. The compile-time refusal did not disappear: it lives on
34/// `Executor::new`, which is bounded on `Writer` — implementing its one method is the
35/// capability (ruling AA 6) — and is where the `compile_fail` pair lives. An adapter that
36/// does not write cannot be built into a write-capable executor.
37impl Executor {
38 /// Construct a mutation runner on demand.
39 ///
40 /// Zero-cost: `Arc::clone` is one atomic increment, no allocation.
41 fn mutation_runner(&self) -> runners::mutation::MutationRunner {
42 runners::mutation::MutationRunner::new(std::sync::Arc::clone(&self.ctx))
43 }
44
45 /// Execute a GraphQL mutation directly, by name.
46 ///
47 /// Unlike `execute()`, which accepts a raw GraphQL string, this takes the mutation
48 /// name and variables directly. Capability is settled by the executor's write slot:
49 /// an executor built by [`Executor::read_only`] holds no write handle and refuses here
50 /// before any other gate runs.
51 ///
52 /// # Arguments
53 ///
54 /// * `mutation_name` - The GraphQL mutation field name (e.g. `"createUser"`)
55 /// * `variables` - Optional JSON object of GraphQL variable values
56 /// * `selections` - The result selection set (inline fragments intact) used to project the
57 /// response; pass `&[]` for no field filtering.
58 ///
59 /// # Returns
60 ///
61 /// A JSON-encoded GraphQL response value on success.
62 ///
63 /// # Errors
64 ///
65 /// Same as `execute_mutation_query`, minus the adapter
66 /// capability check.
67 pub async fn execute_mutation(
68 &self,
69 mutation_name: &str,
70 variables: Option<&serde_json::Value>,
71 selections: WriteSelections<'_>,
72 ) -> Result<serde_json::Value> {
73 self.mutation_runner()
74 .execute_mutation(mutation_name, variables, selections)
75 .await
76 }
77
78 /// Execute a mutation **with a principal**, binding arguments by name from
79 /// `variables` (#1330).
80 ///
81 /// This is [`execute_mutation`](Self::execute_mutation) plus the caller's
82 /// identity, and it is the entry a non-GraphQL transport should use when it
83 /// already holds structured arguments. It converges at
84 /// `execute_mutation_impl` like every other write, so `requires_role`,
85 /// `requires_actor`, the `Authorizer`, argument validation, `before:mutation`
86 /// and the change-log write all run.
87 ///
88 /// [`execute_mutation_with_security`](Self::execute_mutation_with_security) is
89 /// the same call with the selection set derived from the mutation's return type
90 /// rather than supplied by the caller — the entry a transport uses when it has no
91 /// selection set of its own. It used to reach the engine by formatting a GraphQL
92 /// document out of the arguments, a round-trip through text that could not
93 /// represent every JSON value faithfully; that is fixed (#1331).
94 ///
95 /// # Errors
96 ///
97 /// Same as [`execute_mutation`](Self::execute_mutation), plus the refusals the
98 /// gates raise for this principal.
99 ///
100 /// Returns the projection **and** the parsed `mutation_response` envelope: a
101 /// transport whose own wire format is that envelope cannot reconstruct
102 /// `entity_id` or a failure message from the projection alone.
103 pub async fn execute_mutation_as(
104 &self,
105 mutation_name: &str,
106 variables: Option<&serde_json::Value>,
107 security_context: Option<&SecurityContext>,
108 selections: WriteSelections<'_>,
109 ) -> Result<crate::runtime::MutationExecution> {
110 self.execute_mutation_detailed(
111 mutation_name,
112 mutation_name,
113 variables,
114 security_context,
115 selections,
116 &[],
117 )
118 .await
119 }
120
121 /// Execute a mutation for a transport that holds **structured arguments**, with an
122 /// optional principal.
123 ///
124 /// Both arms of the REST write come here — authenticated and anonymous — so both are
125 /// projected through the same selection set and face the same gates (#1352).
126 ///
127 /// This used to reach the engine by **formatting a GraphQL document** out of its
128 /// arguments: `format!("{k}: {v}")` renders a `serde_json::Value` through `Display`,
129 /// which emits JSON, and JSON quotes object keys where GraphQL does not. Any argument
130 /// that was — or contained — an object produced a document the parser refused, so an
131 /// authenticated REST write carrying a nested body failed outright, and under the
132 /// JSONB `data`-column model a nested object is the ordinary body shape (#1331).
133 /// Arguments are bound as **values** now; nothing round-trips through text.
134 ///
135 /// # Errors
136 ///
137 /// Returns `FraiseQLError::Database` if the adapter returns an error.
138 /// Returns `FraiseQLError::Validation` if inject params require a missing security context.
139 pub async fn execute_mutation_with_security(
140 &self,
141 mutation_name: &str,
142 arguments: &serde_json::Value,
143 security_context: Option<&SecurityContext>,
144 ) -> crate::error::Result<serde_json::Value> {
145 let selections = mutation_return_selections(self.schema(), mutation_name);
146 self.execute_mutation_detailed(
147 mutation_name,
148 mutation_name,
149 Some(arguments),
150 security_context,
151 WriteSelections::new(&selections)?,
152 &[],
153 )
154 .await
155 .map(|execution| execution.data)
156 }
157
158 /// Execute a batch of mutations (for REST bulk insert).
159 ///
160 /// Executes each mutation individually and collects results into a `BulkResult`.
161 ///
162 /// # Errors
163 ///
164 /// Returns the first error encountered during batch execution.
165 pub async fn execute_mutation_batch(
166 &self,
167 mutation_name: &str,
168 items: &[serde_json::Value],
169 security_context: Option<&SecurityContext>,
170 ) -> crate::error::Result<crate::runtime::BulkResult> {
171 let mut entities = Vec::with_capacity(items.len());
172 for item in items {
173 let result = self
174 .execute_mutation_with_security(mutation_name, item, security_context)
175 .await?;
176 entities.push(result);
177 }
178 Ok(crate::runtime::BulkResult {
179 affected_rows: entities.len() as u64,
180 entities: Some(entities),
181 })
182 }
183
184 /// Execute a mutation once per identified row — the engine behind a collection-level
185 /// `PATCH`/`DELETE`.
186 ///
187 /// `ids` are the primary-key values the caller's filter selected; each is merged into
188 /// the request body under `id_field` so the mutation function receives the row it is
189 /// meant to act on. `affected_rows` is the number of mutations that actually ran.
190 ///
191 /// This replaces `execute_bulk_by_filter`, which ran the filter query, **discarded
192 /// the matched rows**, invoked the mutation exactly once with the body and no row
193 /// identity, and then reported `affected_rows` as the *filter's* row count — a
194 /// fabricated success on a write path (`#913`). Its `_id_field` and `_max_affected`
195 /// parameters were both unused.
196 ///
197 /// Row selection and the `max_affected` cap now live in the caller (the REST bulk
198 /// handler), where the filter guard and the HTTP status for "too many rows" belong.
199 /// Keeping them there is deliberate: this function can no longer run without a
200 /// caller having decided which rows it applies to.
201 ///
202 /// # Errors
203 ///
204 /// Returns whatever the underlying mutation returns; the first failure aborts and
205 /// propagates, so a partially-applied bulk reports the error rather than a count.
206 pub async fn execute_bulk_by_ids(
207 &self,
208 mutation_name: &str,
209 id_field: &str,
210 ids: &[serde_json::Value],
211 body: Option<&serde_json::Value>,
212 security_context: Option<&SecurityContext>,
213 ) -> crate::error::Result<crate::runtime::BulkResult> {
214 let mut entities = Vec::with_capacity(ids.len());
215
216 for id in ids {
217 let mut args = body.and_then(|b| b.as_object().cloned()).unwrap_or_default();
218 // The row identity wins over anything the client put in the body under the
219 // same key: a bulk request must not be able to redirect a per-row mutation.
220 args.insert(id_field.to_string(), id.clone());
221
222 let result = self
223 .execute_mutation_with_security(
224 mutation_name,
225 &serde_json::Value::Object(args),
226 security_context,
227 )
228 .await?;
229 entities.push(result);
230 }
231
232 Ok(crate::runtime::BulkResult {
233 affected_rows: u64::try_from(entities.len()).unwrap_or(u64::MAX),
234 entities: Some(entities),
235 })
236 }
237}
238
239impl Executor {
240 /// Execute a GraphQL mutation by calling the configured database function.
241 ///
242 /// This is the **runtime-guarded** entry point called from [`execute_internal`] when the
243 /// query is classified as a mutation. It delegates to the shared
244 /// [`execute_mutation_impl`](runners::mutation::execute_mutation_impl) function, which
245 /// refuses unless the executor holds a write handle.
246 ///
247 /// # Errors
248 ///
249 /// * [`FraiseQLError::Validation`] — the adapter does not support mutations, mutation name not
250 /// found in the compiled schema, no `sql_source` configured, or the database function
251 /// returned no rows.
252 /// * [`FraiseQLError::Database`] — the adapter's `execute_write` returned an error.
253 ///
254 /// # Example
255 ///
256 /// ```no_run
257 /// // Requires: a live database adapter implementing `Writer`.
258 /// // See: tests/integration/ for runnable examples.
259 /// # use fraiseql_core::db::postgres::PostgresAdapter;
260 /// # use fraiseql_core::graphql::FieldSelection;
261 /// # use fraiseql_core::schema::CompiledSchema;
262 /// # use fraiseql_core::runtime::{Executor, WriteSelections};
263 /// # use std::sync::Arc;
264 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
265 /// # let schema: CompiledSchema = panic!("example");
266 /// # let adapter = PostgresAdapter::new("postgresql://localhost/mydb").await?;
267 /// # let executor = Executor::new(schema, Arc::new(adapter));
268 /// let vars = serde_json::json!({ "name": "Alice", "email": "alice@example.com" });
269 ///
270 /// // The selection set is required and cannot be empty: an empty one reads as
271 /// // "no field filtering", which returns the stored entity whole and skips
272 /// // field-level authorization.
273 /// let fields = vec![FieldSelection {
274 /// name: "id".to_string(),
275 /// alias: None,
276 /// arguments: vec![],
277 /// nested_fields: vec![],
278 /// directives: vec![],
279 /// }];
280 /// let selections = WriteSelections::new(&fields)?;
281 ///
282 /// // Returns {"data":{"createUser":{"id":"..."}}}
283 /// // or {"data":{"createUser":{"__typename":"UserAlreadyExistsError", "email":"..."}}}
284 /// let result = executor.execute_mutation("createUser", Some(&vars), selections).await?;
285 /// # Ok(())
286 /// # }
287 /// ```
288 pub(super) async fn execute_mutation_query(
289 &self,
290 mutation_name: &str,
291 response_key: &str,
292 variables: Option<&serde_json::Value>,
293 security_context: Option<&SecurityContext>,
294 selections: &[FieldSelection],
295 inline_arguments: &[crate::graphql::GraphQLArgument],
296 ) -> Result<serde_json::Value> {
297 // The capability question is asked at step 0 of `execute_mutation_impl`, which
298 // every path below reaches — including the § 5.3.3 refusal underneath, which is a
299 // *validation* answer and must not depend on write capability to be given. The
300 // check that used to stand here was the document path's own copy of a decision the
301 // typed entries were meanwhile not making at all; there is one copy now.
302 // The one place a *client document's* selection set becomes a write's.
303 //
304 // § 5.3.3 (#1357) runs **here, before the conversion**, not only at step 1e
305 // of the chokepoint. `WriteSelections::new` would otherwise refuse the empty
306 // set first, and its message is the backstop's — "a write needs a selection
307 // set" — where § 5.3.3's names the offending type and is the answer the
308 // GraphQL spec gives. Constructing the type ahead of the validator made the
309 // better diagnosis unreachable on the one path that can actually produce the
310 // shape from a client document.
311 //
312 // An unknown mutation is left to `execute_mutation_impl`, which has the
313 // did-you-mean suggestion for it.
314 if let Some(def) = self.schema().find_mutation(mutation_name) {
315 crate::graphql::validate_leaf_field_selections(
316 self.schema(),
317 &def.return_type,
318 selections,
319 )?;
320 }
321
322 // Every mutation's return type is composite (#1358) and § 5.3.3 has just
323 // adjudicated the set, so a compiled schema cannot deliver an empty one here.
324 // Both of those are compiler- and validator-side, though, and
325 // `schema.compiled.json` can be hand-authored — so this is where a schema
326 // that skipped them fails closed rather than projecting the entity whole.
327 self.execute_mutation_detailed(
328 mutation_name,
329 response_key,
330 variables,
331 security_context,
332 WriteSelections::new(selections)?,
333 inline_arguments,
334 )
335 .await
336 .map(|execution| execution.data)
337 }
338
339 /// [`execute_mutation_query`](Self::execute_mutation_query), keeping the parsed
340 /// `mutation_response` envelope alongside the projection (#1330).
341 pub(super) async fn execute_mutation_detailed(
342 &self,
343 mutation_name: &str,
344 response_key: &str,
345 variables: Option<&serde_json::Value>,
346 security_context: Option<&SecurityContext>,
347 selections: WriteSelections<'_>,
348 inline_arguments: &[crate::graphql::GraphQLArgument],
349 ) -> Result<runners::mutation::MutationExecution> {
350 runners::mutation::execute_mutation_impl(
351 &self.ctx,
352 mutation_name,
353 response_key,
354 variables,
355 security_context,
356 selections,
357 inline_arguments,
358 )
359 .await
360 }
361}
362
363/// A write's result selection set, **guaranteed non-empty**.
364///
365/// # Why this is a type and not a `&[FieldSelection]`
366///
367/// An empty selection set is the *permissive* shape at the write entries, not a
368/// neutral one. It is the input to two security decisions at once:
369///
370/// * [`project_entity`](crate::runtime::project_entity) filters the returned entity to it — and an
371/// empty slice means "no field filtering", so the whole stored entity is returned;
372/// * `selection_set_selects_gated_field` decides whether the #423 field authorizer runs at all —
373/// and it is false for an empty slice, so the authorizer takes **zero calls**.
374///
375/// So `&[]` is not a value a write entry should be able to receive by accident.
376/// REST's anonymous arm passed one (#1352); gRPC reached the same slice a different
377/// way, through `unwrap_or_default()`; and an ordinary `mutation { createUser }`
378/// carried one in from a client document (#1357). Three transports, three spellings,
379/// one shape — which is what a type, rather than a fourth grep gate, is for.
380///
381/// # Why "non-empty" is sound
382///
383/// Every mutation's return type is composite (#1358), and GraphQL § 5.3.3 refuses a
384/// composite field named without a selection set (#1357), so a document that reaches
385/// a write entry always carries one. A transport with no document of its own uses
386/// [`mutation_return_selections`], which never returns empty.
387///
388/// [`new`](Self::new) is still fallible rather than an assertion: the compiler is
389/// only a gate for schemas that go *through* the compiler, and
390/// `schema.compiled.json` can be hand-authored. This is the runtime backstop for
391/// one that declares a leaf-returning mutation anyway.
392#[derive(Clone, Copy, Debug)]
393pub struct WriteSelections<'a>(&'a [FieldSelection]);
394
395impl<'a> WriteSelections<'a> {
396 /// Adopt a selection set for a write.
397 ///
398 /// # Errors
399 ///
400 /// Returns [`FraiseQLError::Validation`] when `selections` is empty. See the type
401 /// docs for why that is a refusal rather than a permissive default.
402 pub fn new(selections: &'a [FieldSelection]) -> Result<Self> {
403 if selections.is_empty() {
404 return Err(FraiseQLError::Validation {
405 message: "A write needs a selection set: an empty one is read as \"no field \
406 filtering\", which returns the stored entity whole and skips \
407 field-level authorization."
408 .to_string(),
409 path: None,
410 });
411 }
412 Ok(Self(selections))
413 }
414
415 /// The selection set, for the projector and the field authorizer.
416 #[must_use]
417 pub const fn as_slice(self) -> &'a [FieldSelection] {
418 self.0
419 }
420}
421
422/// A minimal non-empty selection set, for tests that do not exercise projection.
423///
424/// `__typename` is valid on every composite type and is never policy-gated, so it is
425/// the smallest thing a write entry can legitimately be handed. Test-only on purpose:
426/// production code either has a client's selection set or derives one from the return
427/// type, and a public constructor for "the smallest set that compiles" would be a
428/// third answer for a transport to reach for.
429#[cfg(test)]
430pub fn any_write_selections() -> WriteSelections<'static> {
431 static SET: std::sync::OnceLock<Vec<FieldSelection>> = std::sync::OnceLock::new();
432 let set = SET.get_or_init(|| {
433 vec![FieldSelection {
434 name: "__typename".to_string(),
435 alias: None,
436 arguments: vec![],
437 nested_fields: vec![],
438 directives: vec![],
439 }]
440 });
441 WriteSelections::new(set).expect("a one-field selection set is not empty")
442}
443
444/// The selection set a transport with **no selection set of its own** projects a write
445/// through: every scalar field the mutation's declared return type names.
446///
447/// ⚠ **Never empty, and scalars only.** Both are load-bearing, not stylistic.
448///
449/// An empty selection set is the *permissive* shape rather than a neutral one:
450/// [`project_entity`](crate::runtime::project_entity) returns the whole entity
451/// unfiltered, and `selection_set_selects_gated_field` is false for `&[]`, so the #423
452/// field authorizer short-circuits with **zero calls**. The REST anonymous write arm
453/// passed `&[]`, and so served policy-gated fields to unauthenticated callers that an
454/// authenticated caller is refused (#1352).
455///
456/// Scalars only for the same reason one layer down: `project_field_value` returns a
457/// *sub-selection-less object* verbatim, and `selection_field_has_gated_descendant` is
458/// false when `nested_fields` is empty — so naming an object field without expanding it
459/// would hand back whatever gated field is nested inside it.
460///
461/// The field list was built as **text** until #1331. Before that it was the literal
462/// `status entity_id message` — the `app.mutation_response` envelope's field names rather
463/// than the return type's — so every REST mutation answered `{"data":{"createItem":{}}}`,
464/// a 201 naming no field the caller could read: the write-path twin of #886. Falling back
465/// to those names when the return type is unknown keeps a schema that genuinely returns
466/// the envelope working, and keeps this function's promise never to return an empty set.
467#[must_use]
468pub fn mutation_return_selections(
469 schema: &crate::schema::CompiledSchema,
470 mutation_name: &str,
471) -> Vec<FieldSelection> {
472 fn named(name: &str) -> FieldSelection {
473 FieldSelection {
474 name: name.to_string(),
475 alias: None,
476 arguments: vec![],
477 nested_fields: vec![],
478 directives: vec![],
479 }
480 }
481
482 schema
483 .find_mutation(mutation_name)
484 .and_then(|m| schema.find_type(&m.return_type))
485 .map(|t| {
486 t.fields
487 .iter()
488 .filter(|f| f.field_type.is_scalar())
489 .map(|f| named(f.output_name()))
490 .collect::<Vec<_>>()
491 })
492 .filter(|s| !s.is_empty())
493 .unwrap_or_else(|| vec![named("status"), named("entity_id"), named("message")])
494}