fraiseql-core 2.19.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

use super::argument::ArgumentDefinition;
use crate::schema::{
    field_type::{DeprecationInfo, FieldDefinition},
    security_config::InjectedParamSource,
};

/// A mutation definition compiled from `@fraiseql.mutation`.
///
/// Mutations are declarative bindings to database functions.
/// They describe *which function* to call, not arbitrary logic.
///
/// # Example
///
/// ```
/// use fraiseql_core::schema::{MutationDefinition, MutationOperation};
///
/// let mutation = MutationDefinition::new("createUser", "User");
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MutationDefinition {
    /// Mutation name (e.g., "createUser").
    pub name: String,

    /// Return type name.
    pub return_type: String,

    /// Input arguments.
    #[serde(default)]
    pub arguments: Vec<ArgumentDefinition>,

    /// Description.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// SQL operation type.
    #[serde(default)]
    pub operation: MutationOperation,

    /// Deprecation information (from @deprecated directive).
    /// When set, this mutation is marked as deprecated in the schema.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deprecation: Option<DeprecationInfo>,

    /// PostgreSQL function name to call for this mutation.
    ///
    /// When set, the runtime calls `SELECT * FROM {sql_source}($1, $2, ...)` with the
    /// mutation arguments in `ArgumentDefinition` order, and parses the result as an
    /// `app.mutation_response` composite row.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sql_source: Option<String>,

    /// Server-side parameters injected from JWT claims at runtime.
    ///
    /// Keys are SQL parameter names. Values describe where to source the runtime value.
    /// These params are NOT exposed as GraphQL arguments.
    ///
    /// For mutations: injected params are appended to the positional function call args
    /// **after** client-provided arguments, in map insertion order. The SQL function
    /// signature must declare the injected parameters last.
    ///
    /// Works on PostgreSQL, SQL Server, and MySQL. SQLite has no stored-routine mechanism
    /// and will return an error if inject is configured on a mutation.
    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
    pub inject_params: IndexMap<String, InjectedParamSource>,

    /// Fact tables whose version counter should be bumped after this mutation succeeds.
    ///
    /// When the mutation PostgreSQL function returns successfully, the runtime calls
    /// `SELECT bump_tf_version($1)` for each listed table, incrementing the version used
    /// in fact-table cache keys. This ensures that analytic/aggregate queries backed by
    /// `FactTableVersionStrategy::VersionTable` are automatically invalidated.
    ///
    /// Each entry must be a valid SQL identifier validated at compile time.
    ///
    /// # Example
    ///
    /// ```python
    /// @fraiseql.mutation(
    ///     sql_source="fn_create_order",
    ///     invalidates_fact_tables=["tf_sales", "tf_order_count"],
    /// )
    /// def create_order(amount: Decimal) -> Order: ...
    /// ```
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub invalidates_fact_tables: Vec<String>,

    /// View names whose cached query results should be invalidated after this
    /// mutation succeeds.
    ///
    /// When the `CachedDatabaseAdapter` is active, the runtime calls
    /// `invalidate_views()` with these names, clearing all cache entries that
    /// read from the specified views.
    ///
    /// If empty and the mutation return type has a `sql_source`, the runtime
    /// infers the primary view from the return type.
    ///
    /// Each entry must be a valid SQL identifier validated at compile time.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub invalidates_views: Vec<String>,

    /// Custom REST path override (e.g., `"/users/{id}"`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rest_path: Option<String>,

    /// REST HTTP method override (e.g., `"POST"`, `"PUT"`, `"PATCH"`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rest_method: Option<String>,

    /// PostgreSQL upsert function name for `PUT` semantics (insert-or-update).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub upsert_function: Option<String>,

    /// Role required to execute this mutation and see it in introspection.
    ///
    /// When set, only users whose `SecurityContext.roles` contains this role can
    /// discover and execute the mutation. Others receive `"Unknown mutation"`
    /// (not `FORBIDDEN`) to prevent role enumeration — mirroring
    /// [`QueryDefinition::requires_role`](super::query::QueryDefinition).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub requires_role: Option<String>,

    /// Actor classes permitted to execute this mutation — an **allow-list** (#966).
    ///
    /// Empty (the default) means unrestricted. See
    /// [`QueryDefinition::requires_actor`](super::query::QueryDefinition::requires_actor)
    /// for the semantics, which are identical: an allow-list rather than a
    /// deny-list so a future actor class is refused until an author admits it;
    /// delegation deliberately not consulted; anonymous refused.
    ///
    /// This is where the issue's own example lives — "autonomous agents cannot
    /// delete a tenant" is `requires_actor = ["human_user"]` on that mutation.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub requires_actor: Vec<crate::security::ActorType>,

    /// Whether a successful run of this mutation writes a Change-Spine change-log
    /// row (default `true`).
    ///
    /// Composes as a logical AND with the global
    /// [`RuntimeConfig.changelog_enabled`](crate::runtime::RuntimeConfig): a row
    /// is written only when the global switch is on **and** this flag is `true`.
    /// Set `false` to opt a single mutation out of the in-transaction outbox
    /// write — e.g. a hot endpoint that need not appear in the Change Spine —
    /// while leaving the rest of the schema logging. Serde-defaults to `true`, so
    /// a compiled schema produced before this field existed keeps logging.
    #[serde(default = "default_changelog")]
    pub changelog: bool,

    /// How the GraphQL `input` argument is passed to the SQL function:
    /// [`Flatten`](InputStyle::Flatten) (positional columns, the default) or
    /// [`Jsonb`](InputStyle::Jsonb) (the whole input as one `jsonb` arg).
    ///
    /// Orthogonal to [`operation`](Self::operation): `jsonb` forces the
    /// single-JSONB-argument path regardless of the DML verb, so an
    /// `Insert`/`Delete`/`Custom` mutation backed by a single-`jsonb`-wrapper
    /// function (`fn(input_payload jsonb, …)`) keeps its real verb — and the
    /// Change Spine records the true `modification_type` — while still
    /// receiving the whole input as one argument. `Update` mutations always
    /// take the single-JSONB path whatever this is set to.
    ///
    /// Defaults to `flatten`; an absent value is byte-identical to the
    /// behavior before this field existed (so it adds no compiled-schema bytes
    /// and does not churn the codegen schema hash).
    #[serde(default, skip_serializing_if = "InputStyle::is_flatten")]
    pub input_style: InputStyle,

    /// Whether a successful, state-changing run of this mutation also records the
    /// changed entity's **pre-image** (before-state) into the Change-Spine
    /// `object_data_before` column, alongside the after-image it already writes to
    /// `object_data`. Default `false`.
    ///
    /// The pre-image is sourced from an optional `entity_before` on the mutation's
    /// `app.mutation_response` (the same way the after-image is sourced from
    /// `entity`); the outbox CTE reads `r.entity_before` **only when this is set**,
    /// so an opted-in mutation's response type must expose that column.
    ///
    /// Opt-in per mutation so the audit cost (extra storage + a backend that
    /// computes the pre-mutation snapshot) is paid only by the audit-sensitive
    /// mutations that need an inline Debezium-style `{before, after}` on the single
    /// event — most consumers react to the after-state, and an update's
    /// before-state is the previous row's after-state, reconstructable from the
    /// seq-ordered stream.
    ///
    /// Defaults to `false`; an absent value is byte-identical to the behavior
    /// before this field existed (so it adds no compiled-schema bytes and does not
    /// churn the codegen schema hash).
    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
    pub changelog_pre_image: bool,

    /// Whether this mutation exposes and enforces the typed graphql-cascade
    /// `cascade` field on its success payload. Default `false`.
    ///
    /// When `true`, the runtime surfaces a typed, selection-gated `cascade`
    /// field — mutation responses carrying all affected entities, per the
    /// graphql-cascade spec — whose entities are projected to camelCase and run
    /// through the field-level authorizer (#423), exactly like a queried
    /// entity. When `false`, no cascade surface exists and any `cascade` the SQL
    /// function returns is ignored.
    ///
    /// Set via the authoring SDK's `@fraiseql.type(crud=True, cascade=True)` (or
    /// `@fraiseql.mutation(cascade=True)`); before this field existed the
    /// compiler silently dropped that flag.
    ///
    /// Defaults to `false`; an absent value is byte-identical to the behavior
    /// before this field existed (so it adds no compiled-schema bytes and does
    /// not churn the codegen schema hash).
    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
    pub cascade: bool,

    /// Typed facts about the operation itself, served on the cascade payload next to
    /// `entity`, `cascade` and `updatedFields` (#1397): `createOrder`'s
    /// `recoveredItems`. The function returns them in its row's `result jsonb` column, keyed
    /// by each field's stored (`snake_case`) name. Only on a `cascade` mutation, only
    /// leaf types; the compiler refuses anything else.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub success_fields: Vec<FieldDefinition>,

    /// Where a cascade mutation's `cascade` comes from (#1391): the function's own row (the
    /// default), or also the TVIEW rows `pg_tviews` reports the transaction changed.
    #[serde(default, skip_serializing_if = "CascadeSource::is_function")]
    pub cascade_source: CascadeSource,
}

/// Where a cascade mutation's `cascade` comes from (#1391).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CascadeSource {
    /// The `cascade` column of the function's `mutation_response` row.
    #[default]
    Function,
    /// That, merged with the rows `tviews.pg_tviews_flush_and_report()` reports the
    /// transaction changed, called before the commit. The function's own entries win.
    PgTviews,
}

impl CascadeSource {
    /// Whether this is the default, the function's own cascade.
    #[must_use]
    pub const fn is_function(&self) -> bool {
        matches!(self, Self::Function)
    }
}

/// Serde default for [`MutationDefinition::changelog`]: log by default (opt-out).
const fn default_changelog() -> bool {
    true
}

/// How a mutation's GraphQL `input` argument is forwarded to its SQL function.
///
/// This is **orthogonal** to [`MutationOperation`] (the DML verb). Decoupling
/// the input-passing style from the verb lets a backend that uses the
/// single-JSONB wrapper convention
/// (`fn(input_payload jsonb, …) RETURNS app.mutation_response`) register the
/// *real* operation (`Insert`/`Delete`/`Custom`) instead of being forced to
/// `Update` purely to opt into single-JSONB passing — which in turn lets the
/// Change Spine record the true `modification_type`.
///
/// # Example
///
/// ```
/// use fraiseql_core::schema::InputStyle;
///
/// assert_eq!(InputStyle::default(), InputStyle::Flatten);
/// assert!(InputStyle::Flatten.is_flatten());
/// assert!(!InputStyle::Jsonb.is_flatten());
/// ```
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InputStyle {
    /// Flatten the Input type's fields into positional SQL arguments.
    ///
    /// The default and today's behavior for `Insert`/`Delete`/`Custom`
    /// mutations whose `input` is a known Input type.
    #[default]
    Flatten,

    /// Forward the entire `input` object as a single `jsonb` argument.
    ///
    /// Mirrors how `Update` mutations (and a raw `input: JSON` arg) are passed
    /// today, including the `#400` acronym-aware input-key recasing on that
    /// path.
    Jsonb,
}

impl InputStyle {
    /// Whether this is the [`Flatten`](Self::Flatten) default.
    ///
    /// Used as the serde `skip_serializing_if` predicate so a `flatten`
    /// mutation serializes byte-identically to one authored before this field
    /// existed, leaving the codegen schema hash unchanged.
    #[must_use]
    pub const fn is_flatten(&self) -> bool {
        matches!(self, Self::Flatten)
    }
}

impl MutationDefinition {
    /// Create a new mutation definition.
    #[must_use]
    pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
        Self {
            name:                    name.into(),
            return_type:             return_type.into(),
            arguments:               Vec::new(),
            description:             None,
            operation:               MutationOperation::default(),
            deprecation:             None,
            sql_source:              None,
            inject_params:           IndexMap::new(),
            invalidates_fact_tables: Vec::new(),
            invalidates_views:       Vec::new(),
            rest_path:               None,
            rest_method:             None,
            upsert_function:         None,
            requires_role:           None,
            requires_actor:          Vec::new(),
            changelog:               true,
            input_style:             InputStyle::Flatten,
            changelog_pre_image:     false,
            cascade:                 false,
            success_fields:          Vec::new(),
            cascade_source:          CascadeSource::Function,
        }
    }

    /// Mark this mutation as deprecated.
    ///
    /// # Example
    ///
    /// ```
    /// use fraiseql_core::schema::MutationDefinition;
    ///
    /// let mutation = MutationDefinition::new("oldCreateUser", "User")
    ///     .deprecated(Some("Use 'createUser' instead".to_string()));
    /// assert!(mutation.is_deprecated());
    /// ```
    #[must_use]
    pub fn deprecated(mut self, reason: Option<String>) -> Self {
        self.deprecation = Some(DeprecationInfo { reason });
        self
    }

    /// Check if this mutation is deprecated.
    #[must_use]
    pub const fn is_deprecated(&self) -> bool {
        self.deprecation.is_some()
    }

    /// Get the deprecation reason if deprecated.
    #[must_use]
    pub fn deprecation_reason(&self) -> Option<&str> {
        self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
    }
}

/// Mutation operation types.
///
/// This enum describes what kind of database operation a mutation performs.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
#[non_exhaustive]
pub enum MutationOperation {
    /// INSERT into a table.
    Insert {
        /// Target table name.
        table: String,
    },

    /// UPDATE a table.
    Update {
        /// Target table name.
        table: String,
    },

    /// DELETE from a table.
    Delete {
        /// Target table name.
        table: String,
    },

    /// Custom mutation (for complex operations).
    #[default]
    Custom,
}

impl MutationOperation {
    /// Return a lowercase string label for the operation kind.
    ///
    /// Used in structured audit log events to identify the DML type.
    ///
    /// # Example
    ///
    /// ```
    /// use fraiseql_core::schema::MutationOperation;
    ///
    /// assert_eq!(MutationOperation::Insert { table: "users".into() }.kind_str(), "insert");
    /// assert_eq!(MutationOperation::Update { table: "users".into() }.kind_str(), "update");
    /// assert_eq!(MutationOperation::Delete { table: "users".into() }.kind_str(), "delete");
    /// assert_eq!(MutationOperation::Custom.kind_str(), "custom");
    /// ```
    #[must_use]
    pub const fn kind_str(&self) -> &'static str {
        match self {
            Self::Insert { .. } => "insert",
            Self::Update { .. } => "update",
            Self::Delete { .. } => "delete",
            Self::Custom => "custom",
        }
    }
}