Skip to main content

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}