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 /// Actor classes permitted to execute this mutation — an **allow-list** (#966).
123 ///
124 /// Empty (the default) means unrestricted. See
125 /// [`QueryDefinition::requires_actor`](super::query::QueryDefinition::requires_actor)
126 /// for the semantics, which are identical: an allow-list rather than a
127 /// deny-list so a future actor class is refused until an author admits it;
128 /// delegation deliberately not consulted; anonymous refused.
129 ///
130 /// This is where the issue's own example lives — "autonomous agents cannot
131 /// delete a tenant" is `requires_actor = ["human_user"]` on that mutation.
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub requires_actor: Vec<crate::security::ActorType>,
134
135 /// Whether a successful run of this mutation writes a Change-Spine change-log
136 /// row (default `true`).
137 ///
138 /// Composes as a logical AND with the global
139 /// [`RuntimeConfig.changelog_enabled`](crate::runtime::RuntimeConfig): a row
140 /// is written only when the global switch is on **and** this flag is `true`.
141 /// Set `false` to opt a single mutation out of the in-transaction outbox
142 /// write — e.g. a hot endpoint that need not appear in the Change Spine —
143 /// while leaving the rest of the schema logging. Serde-defaults to `true`, so
144 /// a compiled schema produced before this field existed keeps logging.
145 #[serde(default = "default_changelog")]
146 pub changelog: bool,
147
148 /// How the GraphQL `input` argument is passed to the SQL function:
149 /// [`Flatten`](InputStyle::Flatten) (positional columns, the default) or
150 /// [`Jsonb`](InputStyle::Jsonb) (the whole input as one `jsonb` arg).
151 ///
152 /// Orthogonal to [`operation`](Self::operation): `jsonb` forces the
153 /// single-JSONB-argument path regardless of the DML verb, so an
154 /// `Insert`/`Delete`/`Custom` mutation backed by a single-`jsonb`-wrapper
155 /// function (`fn(input_payload jsonb, …)`) keeps its real verb — and the
156 /// Change Spine records the true `modification_type` — while still
157 /// receiving the whole input as one argument. `Update` mutations always
158 /// take the single-JSONB path whatever this is set to.
159 ///
160 /// Defaults to `flatten`; an absent value is byte-identical to the
161 /// behavior before this field existed (so it adds no compiled-schema bytes
162 /// and does not churn the codegen schema hash).
163 #[serde(default, skip_serializing_if = "InputStyle::is_flatten")]
164 pub input_style: InputStyle,
165
166 /// Whether a successful, state-changing run of this mutation also records the
167 /// changed entity's **pre-image** (before-state) into the Change-Spine
168 /// `object_data_before` column, alongside the after-image it already writes to
169 /// `object_data`. Default `false`.
170 ///
171 /// The pre-image is sourced from an optional `entity_before` on the mutation's
172 /// `app.mutation_response` (the same way the after-image is sourced from
173 /// `entity`); the outbox CTE reads `r.entity_before` **only when this is set**,
174 /// so an opted-in mutation's response type must expose that column.
175 ///
176 /// Opt-in per mutation so the audit cost (extra storage + a backend that
177 /// computes the pre-mutation snapshot) is paid only by the audit-sensitive
178 /// mutations that need an inline Debezium-style `{before, after}` on the single
179 /// event — most consumers react to the after-state, and an update's
180 /// before-state is the previous row's after-state, reconstructable from the
181 /// seq-ordered stream.
182 ///
183 /// Defaults to `false`; an absent value is byte-identical to the behavior
184 /// before this field existed (so it adds no compiled-schema bytes and does not
185 /// churn the codegen schema hash).
186 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
187 pub changelog_pre_image: bool,
188
189 /// Whether this mutation exposes and enforces the typed graphql-cascade
190 /// `cascade` field on its success payload. Default `false`.
191 ///
192 /// When `true`, the runtime surfaces a typed, selection-gated `cascade`
193 /// field — mutation responses carrying all affected entities, per the
194 /// graphql-cascade spec — whose entities are projected to camelCase and run
195 /// through the field-level authorizer (#423), exactly like a queried
196 /// entity. When `false`, no cascade surface exists and any `cascade` the SQL
197 /// function returns is ignored.
198 ///
199 /// Set via the authoring SDK's `@fraiseql.type(crud=True, cascade=True)` (or
200 /// `@fraiseql.mutation(cascade=True)`); before this field existed the
201 /// compiler silently dropped that flag.
202 ///
203 /// Defaults to `false`; an absent value is byte-identical to the behavior
204 /// before this field existed (so it adds no compiled-schema bytes and does
205 /// not churn the codegen schema hash).
206 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
207 pub cascade: bool,
208}
209
210/// Serde default for [`MutationDefinition::changelog`]: log by default (opt-out).
211const fn default_changelog() -> bool {
212 true
213}
214
215/// How a mutation's GraphQL `input` argument is forwarded to its SQL function.
216///
217/// This is **orthogonal** to [`MutationOperation`] (the DML verb). Decoupling
218/// the input-passing style from the verb lets a backend that uses the
219/// single-JSONB wrapper convention
220/// (`fn(input_payload jsonb, …) RETURNS app.mutation_response`) register the
221/// *real* operation (`Insert`/`Delete`/`Custom`) instead of being forced to
222/// `Update` purely to opt into single-JSONB passing — which in turn lets the
223/// Change Spine record the true `modification_type`.
224///
225/// # Example
226///
227/// ```
228/// use fraiseql_core::schema::InputStyle;
229///
230/// assert_eq!(InputStyle::default(), InputStyle::Flatten);
231/// assert!(InputStyle::Flatten.is_flatten());
232/// assert!(!InputStyle::Jsonb.is_flatten());
233/// ```
234#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum InputStyle {
237 /// Flatten the Input type's fields into positional SQL arguments.
238 ///
239 /// The default and today's behavior for `Insert`/`Delete`/`Custom`
240 /// mutations whose `input` is a known Input type.
241 #[default]
242 Flatten,
243
244 /// Forward the entire `input` object as a single `jsonb` argument.
245 ///
246 /// Mirrors how `Update` mutations (and a raw `input: JSON` arg) are passed
247 /// today, including the `#400` acronym-aware input-key recasing on that
248 /// path.
249 Jsonb,
250}
251
252impl InputStyle {
253 /// Whether this is the [`Flatten`](Self::Flatten) default.
254 ///
255 /// Used as the serde `skip_serializing_if` predicate so a `flatten`
256 /// mutation serializes byte-identically to one authored before this field
257 /// existed, leaving the codegen schema hash unchanged.
258 #[must_use]
259 pub const fn is_flatten(&self) -> bool {
260 matches!(self, Self::Flatten)
261 }
262}
263
264impl MutationDefinition {
265 /// Create a new mutation definition.
266 #[must_use]
267 pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
268 Self {
269 name: name.into(),
270 return_type: return_type.into(),
271 arguments: Vec::new(),
272 description: None,
273 operation: MutationOperation::default(),
274 deprecation: None,
275 sql_source: None,
276 inject_params: IndexMap::new(),
277 invalidates_fact_tables: Vec::new(),
278 invalidates_views: Vec::new(),
279 rest_path: None,
280 rest_method: None,
281 upsert_function: None,
282 requires_role: None,
283 requires_actor: Vec::new(),
284 changelog: true,
285 input_style: InputStyle::Flatten,
286 changelog_pre_image: false,
287 cascade: false,
288 }
289 }
290
291 /// Mark this mutation as deprecated.
292 ///
293 /// # Example
294 ///
295 /// ```
296 /// use fraiseql_core::schema::MutationDefinition;
297 ///
298 /// let mutation = MutationDefinition::new("oldCreateUser", "User")
299 /// .deprecated(Some("Use 'createUser' instead".to_string()));
300 /// assert!(mutation.is_deprecated());
301 /// ```
302 #[must_use]
303 pub fn deprecated(mut self, reason: Option<String>) -> Self {
304 self.deprecation = Some(DeprecationInfo { reason });
305 self
306 }
307
308 /// Check if this mutation is deprecated.
309 #[must_use]
310 pub const fn is_deprecated(&self) -> bool {
311 self.deprecation.is_some()
312 }
313
314 /// Get the deprecation reason if deprecated.
315 #[must_use]
316 pub fn deprecation_reason(&self) -> Option<&str> {
317 self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
318 }
319}
320
321/// Mutation operation types.
322///
323/// This enum describes what kind of database operation a mutation performs.
324#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "PascalCase")]
326#[non_exhaustive]
327pub enum MutationOperation {
328 /// INSERT into a table.
329 Insert {
330 /// Target table name.
331 table: String,
332 },
333
334 /// UPDATE a table.
335 Update {
336 /// Target table name.
337 table: String,
338 },
339
340 /// DELETE from a table.
341 Delete {
342 /// Target table name.
343 table: String,
344 },
345
346 /// Custom mutation (for complex operations).
347 #[default]
348 Custom,
349}
350
351impl MutationOperation {
352 /// Return a lowercase string label for the operation kind.
353 ///
354 /// Used in structured audit log events to identify the DML type.
355 ///
356 /// # Example
357 ///
358 /// ```
359 /// use fraiseql_core::schema::MutationOperation;
360 ///
361 /// assert_eq!(MutationOperation::Insert { table: "users".into() }.kind_str(), "insert");
362 /// assert_eq!(MutationOperation::Update { table: "users".into() }.kind_str(), "update");
363 /// assert_eq!(MutationOperation::Delete { table: "users".into() }.kind_str(), "delete");
364 /// assert_eq!(MutationOperation::Custom.kind_str(), "custom");
365 /// ```
366 #[must_use]
367 pub const fn kind_str(&self) -> &'static str {
368 match self {
369 Self::Insert { .. } => "insert",
370 Self::Update { .. } => "update",
371 Self::Delete { .. } => "delete",
372 Self::Custom => "custom",
373 }
374 }
375}