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}