Skip to main content

fraiseql_core/schema/compiled/
mutation.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use super::argument::ArgumentDefinition;
5use crate::schema::{field_type::DeprecationInfo, security_config::InjectedParamSource};
6
7/// A mutation definition compiled from `@fraiseql.mutation`.
8///
9/// Mutations are declarative bindings to database functions.
10/// They describe *which function* to call, not arbitrary logic.
11///
12/// # Example
13///
14/// ```
15/// use fraiseql_core::schema::{MutationDefinition, MutationOperation};
16///
17/// let mutation = MutationDefinition::new("createUser", "User");
18/// ```
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct MutationDefinition {
21    /// Mutation name (e.g., "createUser").
22    pub name: String,
23
24    /// Return type name.
25    pub return_type: String,
26
27    /// Input arguments.
28    #[serde(default)]
29    pub arguments: Vec<ArgumentDefinition>,
30
31    /// Description.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub description: Option<String>,
34
35    /// SQL operation type.
36    #[serde(default)]
37    pub operation: MutationOperation,
38
39    /// Deprecation information (from @deprecated directive).
40    /// When set, this mutation is marked as deprecated in the schema.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub deprecation: Option<DeprecationInfo>,
43
44    /// PostgreSQL function name to call for this mutation.
45    ///
46    /// When set, the runtime calls `SELECT * FROM {sql_source}($1, $2, ...)` with the
47    /// mutation arguments in `ArgumentDefinition` order, and parses the result as an
48    /// `app.mutation_response` composite row.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub sql_source: Option<String>,
51
52    /// Server-side parameters injected from JWT claims at runtime.
53    ///
54    /// Keys are SQL parameter names. Values describe where to source the runtime value.
55    /// These params are NOT exposed as GraphQL arguments.
56    ///
57    /// For mutations: injected params are appended to the positional function call args
58    /// **after** client-provided arguments, in map insertion order. The SQL function
59    /// signature must declare the injected parameters last.
60    ///
61    /// Works on PostgreSQL, SQL Server, and MySQL. SQLite has no stored-routine mechanism
62    /// and will return an error if inject is configured on a mutation.
63    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
64    pub inject_params: IndexMap<String, InjectedParamSource>,
65
66    /// Fact tables whose version counter should be bumped after this mutation succeeds.
67    ///
68    /// When the mutation PostgreSQL function returns successfully, the runtime calls
69    /// `SELECT bump_tf_version($1)` for each listed table, incrementing the version used
70    /// in fact-table cache keys. This ensures that analytic/aggregate queries backed by
71    /// `FactTableVersionStrategy::VersionTable` are automatically invalidated.
72    ///
73    /// Each entry must be a valid SQL identifier validated at compile time.
74    ///
75    /// # Example
76    ///
77    /// ```python
78    /// @fraiseql.mutation(
79    ///     sql_source="fn_create_order",
80    ///     invalidates_fact_tables=["tf_sales", "tf_order_count"],
81    /// )
82    /// def create_order(amount: Decimal) -> Order: ...
83    /// ```
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub invalidates_fact_tables: Vec<String>,
86
87    /// View names whose cached query results should be invalidated after this
88    /// mutation succeeds.
89    ///
90    /// When the `CachedDatabaseAdapter` is active, the runtime calls
91    /// `invalidate_views()` with these names, clearing all cache entries that
92    /// read from the specified views.
93    ///
94    /// If empty and the mutation return type has a `sql_source`, the runtime
95    /// infers the primary view from the return type.
96    ///
97    /// Each entry must be a valid SQL identifier validated at compile time.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub invalidates_views: Vec<String>,
100
101    /// Custom REST path override (e.g., `"/users/{id}"`).
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub rest_path: Option<String>,
104
105    /// REST HTTP method override (e.g., `"POST"`, `"PUT"`, `"PATCH"`).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub rest_method: Option<String>,
108
109    /// PostgreSQL upsert function name for `PUT` semantics (insert-or-update).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub upsert_function: Option<String>,
112
113    /// Role required to execute this mutation and see it in introspection.
114    ///
115    /// When set, only users whose `SecurityContext.roles` contains this role can
116    /// discover and execute the mutation. Others receive `"Unknown mutation"`
117    /// (not `FORBIDDEN`) to prevent role enumeration — mirroring
118    /// [`QueryDefinition::requires_role`](super::query::QueryDefinition).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub requires_role: Option<String>,
121
122    /// Whether a successful run of this mutation writes a Change-Spine change-log
123    /// row (default `true`).
124    ///
125    /// Composes as a logical AND with the global
126    /// [`RuntimeConfig.changelog_enabled`](crate::runtime::RuntimeConfig): a row
127    /// is written only when the global switch is on **and** this flag is `true`.
128    /// Set `false` to opt a single mutation out of the in-transaction outbox
129    /// write — e.g. a hot endpoint that need not appear in the Change Spine —
130    /// while leaving the rest of the schema logging. Serde-defaults to `true`, so
131    /// a compiled schema produced before this field existed keeps logging.
132    #[serde(default = "default_changelog")]
133    pub changelog: bool,
134
135    /// How the GraphQL `input` argument is passed to the SQL function:
136    /// [`Flatten`](InputStyle::Flatten) (positional columns, the default) or
137    /// [`Jsonb`](InputStyle::Jsonb) (the whole input as one `jsonb` arg).
138    ///
139    /// Orthogonal to [`operation`](Self::operation): `jsonb` forces the
140    /// single-JSONB-argument path regardless of the DML verb, so an
141    /// `Insert`/`Delete`/`Custom` mutation backed by a single-`jsonb`-wrapper
142    /// function (`fn(input_payload jsonb, …)`) keeps its real verb — and the
143    /// Change Spine records the true `modification_type` — while still
144    /// receiving the whole input as one argument. `Update` mutations always
145    /// take the single-JSONB path whatever this is set to.
146    ///
147    /// Defaults to `flatten`; an absent value is byte-identical to the
148    /// behavior before this field existed (so it adds no compiled-schema bytes
149    /// and does not churn the codegen schema hash).
150    #[serde(default, skip_serializing_if = "InputStyle::is_flatten")]
151    pub input_style: InputStyle,
152
153    /// Whether a successful, state-changing run of this mutation also records the
154    /// changed entity's **pre-image** (before-state) into the Change-Spine
155    /// `object_data_before` column, alongside the after-image it already writes to
156    /// `object_data`. Default `false`.
157    ///
158    /// The pre-image is sourced from an optional `entity_before` on the mutation's
159    /// `app.mutation_response` (the same way the after-image is sourced from
160    /// `entity`); the outbox CTE reads `r.entity_before` **only when this is set**,
161    /// so an opted-in mutation's response type must expose that column.
162    ///
163    /// Opt-in per mutation so the audit cost (extra storage + a backend that
164    /// computes the pre-mutation snapshot) is paid only by the audit-sensitive
165    /// mutations that need an inline Debezium-style `{before, after}` on the single
166    /// event — most consumers react to the after-state, and an update's
167    /// before-state is the previous row's after-state, reconstructable from the
168    /// seq-ordered stream.
169    ///
170    /// Defaults to `false`; an absent value is byte-identical to the behavior
171    /// before this field existed (so it adds no compiled-schema bytes and does not
172    /// churn the codegen schema hash).
173    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
174    pub changelog_pre_image: bool,
175
176    /// Whether this mutation exposes and enforces the typed graphql-cascade
177    /// `cascade` field on its success payload. Default `false`.
178    ///
179    /// When `true`, the runtime surfaces a typed, selection-gated `cascade`
180    /// field — mutation responses carrying all affected entities, per the
181    /// graphql-cascade spec — whose entities are projected to camelCase and run
182    /// through the field-level authorizer (#423), exactly like a queried
183    /// entity. When `false`, no cascade surface exists and any `cascade` the SQL
184    /// function returns is ignored.
185    ///
186    /// Set via the authoring SDK's `@fraiseql.type(crud=True, cascade=True)` (or
187    /// `@fraiseql.mutation(cascade=True)`); before this field existed the
188    /// compiler silently dropped that flag.
189    ///
190    /// Defaults to `false`; an absent value is byte-identical to the behavior
191    /// before this field existed (so it adds no compiled-schema bytes and does
192    /// not churn the codegen schema hash).
193    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
194    pub cascade: bool,
195}
196
197/// Serde default for [`MutationDefinition::changelog`]: log by default (opt-out).
198const fn default_changelog() -> bool {
199    true
200}
201
202/// How a mutation's GraphQL `input` argument is forwarded to its SQL function.
203///
204/// This is **orthogonal** to [`MutationOperation`] (the DML verb). Decoupling
205/// the input-passing style from the verb lets a backend that uses the
206/// single-JSONB wrapper convention
207/// (`fn(input_payload jsonb, …) RETURNS app.mutation_response`) register the
208/// *real* operation (`Insert`/`Delete`/`Custom`) instead of being forced to
209/// `Update` purely to opt into single-JSONB passing — which in turn lets the
210/// Change Spine record the true `modification_type`.
211///
212/// # Example
213///
214/// ```
215/// use fraiseql_core::schema::InputStyle;
216///
217/// assert_eq!(InputStyle::default(), InputStyle::Flatten);
218/// assert!(InputStyle::Flatten.is_flatten());
219/// assert!(!InputStyle::Jsonb.is_flatten());
220/// ```
221#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "lowercase")]
223pub enum InputStyle {
224    /// Flatten the Input type's fields into positional SQL arguments.
225    ///
226    /// The default and today's behavior for `Insert`/`Delete`/`Custom`
227    /// mutations whose `input` is a known Input type.
228    #[default]
229    Flatten,
230
231    /// Forward the entire `input` object as a single `jsonb` argument.
232    ///
233    /// Mirrors how `Update` mutations (and a raw `input: JSON` arg) are passed
234    /// today, including the `#400` acronym-aware input-key recasing on that
235    /// path.
236    Jsonb,
237}
238
239impl InputStyle {
240    /// Whether this is the [`Flatten`](Self::Flatten) default.
241    ///
242    /// Used as the serde `skip_serializing_if` predicate so a `flatten`
243    /// mutation serializes byte-identically to one authored before this field
244    /// existed, leaving the codegen schema hash unchanged.
245    #[must_use]
246    pub const fn is_flatten(&self) -> bool {
247        matches!(self, Self::Flatten)
248    }
249}
250
251impl MutationDefinition {
252    /// Create a new mutation definition.
253    #[must_use]
254    pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
255        Self {
256            name:                    name.into(),
257            return_type:             return_type.into(),
258            arguments:               Vec::new(),
259            description:             None,
260            operation:               MutationOperation::default(),
261            deprecation:             None,
262            sql_source:              None,
263            inject_params:           IndexMap::new(),
264            invalidates_fact_tables: Vec::new(),
265            invalidates_views:       Vec::new(),
266            rest_path:               None,
267            rest_method:             None,
268            upsert_function:         None,
269            requires_role:           None,
270            changelog:               true,
271            input_style:             InputStyle::Flatten,
272            changelog_pre_image:     false,
273            cascade:                 false,
274        }
275    }
276
277    /// Mark this mutation as deprecated.
278    ///
279    /// # Example
280    ///
281    /// ```
282    /// use fraiseql_core::schema::MutationDefinition;
283    ///
284    /// let mutation = MutationDefinition::new("oldCreateUser", "User")
285    ///     .deprecated(Some("Use 'createUser' instead".to_string()));
286    /// assert!(mutation.is_deprecated());
287    /// ```
288    #[must_use]
289    pub fn deprecated(mut self, reason: Option<String>) -> Self {
290        self.deprecation = Some(DeprecationInfo { reason });
291        self
292    }
293
294    /// Check if this mutation is deprecated.
295    #[must_use]
296    pub const fn is_deprecated(&self) -> bool {
297        self.deprecation.is_some()
298    }
299
300    /// Get the deprecation reason if deprecated.
301    #[must_use]
302    pub fn deprecation_reason(&self) -> Option<&str> {
303        self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
304    }
305}
306
307/// Mutation operation types.
308///
309/// This enum describes what kind of database operation a mutation performs.
310#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "PascalCase")]
312#[non_exhaustive]
313pub enum MutationOperation {
314    /// INSERT into a table.
315    Insert {
316        /// Target table name.
317        table: String,
318    },
319
320    /// UPDATE a table.
321    Update {
322        /// Target table name.
323        table: String,
324    },
325
326    /// DELETE from a table.
327    Delete {
328        /// Target table name.
329        table: String,
330    },
331
332    /// Custom mutation (for complex operations).
333    #[default]
334    Custom,
335}
336
337impl MutationOperation {
338    /// Return a lowercase string label for the operation kind.
339    ///
340    /// Used in structured audit log events to identify the DML type.
341    ///
342    /// # Example
343    ///
344    /// ```
345    /// use fraiseql_core::schema::MutationOperation;
346    ///
347    /// assert_eq!(MutationOperation::Insert { table: "users".into() }.kind_str(), "insert");
348    /// assert_eq!(MutationOperation::Update { table: "users".into() }.kind_str(), "update");
349    /// assert_eq!(MutationOperation::Delete { table: "users".into() }.kind_str(), "delete");
350    /// assert_eq!(MutationOperation::Custom.kind_str(), "custom");
351    /// ```
352    #[must_use]
353    pub const fn kind_str(&self) -> &'static str {
354        match self {
355            Self::Insert { .. } => "insert",
356            Self::Update { .. } => "update",
357            Self::Delete { .. } => "delete",
358            Self::Custom => "custom",
359        }
360    }
361}