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
123impl MutationDefinition {
124    /// Create a new mutation definition.
125    #[must_use]
126    pub fn new(name: impl Into<String>, return_type: impl Into<String>) -> Self {
127        Self {
128            name:                    name.into(),
129            return_type:             return_type.into(),
130            arguments:               Vec::new(),
131            description:             None,
132            operation:               MutationOperation::default(),
133            deprecation:             None,
134            sql_source:              None,
135            inject_params:           IndexMap::new(),
136            invalidates_fact_tables: Vec::new(),
137            invalidates_views:       Vec::new(),
138            rest_path:               None,
139            rest_method:             None,
140            upsert_function:         None,
141            requires_role:           None,
142        }
143    }
144
145    /// Mark this mutation as deprecated.
146    ///
147    /// # Example
148    ///
149    /// ```
150    /// use fraiseql_core::schema::MutationDefinition;
151    ///
152    /// let mutation = MutationDefinition::new("oldCreateUser", "User")
153    ///     .deprecated(Some("Use 'createUser' instead".to_string()));
154    /// assert!(mutation.is_deprecated());
155    /// ```
156    #[must_use]
157    pub fn deprecated(mut self, reason: Option<String>) -> Self {
158        self.deprecation = Some(DeprecationInfo { reason });
159        self
160    }
161
162    /// Check if this mutation is deprecated.
163    #[must_use]
164    pub const fn is_deprecated(&self) -> bool {
165        self.deprecation.is_some()
166    }
167
168    /// Get the deprecation reason if deprecated.
169    #[must_use]
170    pub fn deprecation_reason(&self) -> Option<&str> {
171        self.deprecation.as_ref().and_then(|d| d.reason.as_deref())
172    }
173}
174
175/// Mutation operation types.
176///
177/// This enum describes what kind of database operation a mutation performs.
178#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "PascalCase")]
180#[non_exhaustive]
181pub enum MutationOperation {
182    /// INSERT into a table.
183    Insert {
184        /// Target table name.
185        table: String,
186    },
187
188    /// UPDATE a table.
189    Update {
190        /// Target table name.
191        table: String,
192    },
193
194    /// DELETE from a table.
195    Delete {
196        /// Target table name.
197        table: String,
198    },
199
200    /// Custom mutation (for complex operations).
201    #[default]
202    Custom,
203}
204
205impl MutationOperation {
206    /// Return a lowercase string label for the operation kind.
207    ///
208    /// Used in structured audit log events to identify the DML type.
209    ///
210    /// # Example
211    ///
212    /// ```
213    /// use fraiseql_core::schema::MutationOperation;
214    ///
215    /// assert_eq!(MutationOperation::Insert { table: "users".into() }.kind_str(), "insert");
216    /// assert_eq!(MutationOperation::Update { table: "users".into() }.kind_str(), "update");
217    /// assert_eq!(MutationOperation::Delete { table: "users".into() }.kind_str(), "delete");
218    /// assert_eq!(MutationOperation::Custom.kind_str(), "custom");
219    /// ```
220    #[must_use]
221    pub const fn kind_str(&self) -> &'static str {
222        match self {
223            Self::Insert { .. } => "insert",
224            Self::Update { .. } => "update",
225            Self::Delete { .. } => "delete",
226            Self::Custom => "custom",
227        }
228    }
229}