fraiseql_cli/schema/intermediate/operations.rs
1//! Query/mutation structs: `IntermediateQuery`, `IntermediateMutation`,
2//! `IntermediateArgument`, `IntermediateAutoParams`, `IntermediateQueryDefaults`.
3
4use fraiseql_core::schema::InputStyle;
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8use super::types::IntermediateDeprecation;
9
10/// Argument definition in intermediate format
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct IntermediateArgument {
13 /// Argument name
14 pub name: String,
15
16 /// Argument type name
17 ///
18 /// **Language-agnostic**: Uses "type", not "`arg_type`"
19 #[serde(rename = "type")]
20 pub arg_type: String,
21
22 /// Is argument optional?
23 pub nullable: bool,
24
25 /// Default value (JSON)
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub default: Option<serde_json::Value>,
28
29 /// Deprecation info (from @deprecated directive)
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub deprecated: Option<IntermediateDeprecation>,
32}
33
34/// Query definition in intermediate format
35#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
36pub struct IntermediateQuery {
37 /// Query name (e.g., "users")
38 pub name: String,
39
40 /// Return type name (e.g., "User")
41 pub return_type: String,
42
43 /// Returns a list?
44 #[serde(default)]
45 pub returns_list: bool,
46
47 /// Result is nullable?
48 #[serde(default)]
49 pub nullable: bool,
50
51 /// Query arguments
52 #[serde(default)]
53 pub arguments: Vec<IntermediateArgument>,
54
55 /// Query description (from docstring)
56 #[serde(skip_serializing_if = "Option::is_none")]
57 pub description: Option<String>,
58
59 /// SQL source (table/view name)
60 #[serde(skip_serializing_if = "Option::is_none")]
61 pub sql_source: Option<String>,
62
63 /// Auto-generated parameters config
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub auto_params: Option<IntermediateAutoParams>,
66
67 /// Deprecation info (from @deprecated directive)
68 #[serde(skip_serializing_if = "Option::is_none")]
69 pub deprecated: Option<IntermediateDeprecation>,
70
71 /// JSONB column name for extracting data (e.g., "data")
72 /// Used for tv_* (denormalized JSONB tables) pattern
73 #[serde(skip_serializing_if = "Option::is_none")]
74 pub jsonb_column: Option<String>,
75
76 /// Whether this is a Relay connection query.
77 /// When true, the compiler wraps results in `{ edges: [{ node, cursor }], pageInfo }`
78 /// and generates `first`/`after`/`last`/`before` arguments instead of `limit`/`offset`.
79 /// Requires `returns_list = true` and `sql_source` to be set.
80 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
81 pub relay: bool,
82
83 /// Server-injected parameters: SQL column name → source expression (e.g. `"jwt:org_id"`).
84 /// Not exposed as GraphQL arguments.
85 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
86 pub inject: IndexMap<String, String>,
87
88 /// Per-query result cache TTL in seconds. Overrides the global cache TTL for this query.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub cache_ttl_seconds: Option<u64>,
91
92 /// Additional database views this query reads beyond the primary `sql_source`.
93 ///
94 /// Used for correct cache invalidation when a query JOINs or reads multiple views.
95 /// Each entry is validated as a safe SQL identifier at schema compile time.
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub additional_views: Vec<String>,
98
99 /// Role required to execute this query and see it in introspection.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub requires_role: Option<String>,
102
103 /// Relay cursor column type: `"uuid"` for UUID PKs, `"int64"` (or absent) for bigint PKs.
104 /// Only meaningful when `relay = true`.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub relay_cursor_type: Option<String>,
107}
108
109/// Mutation definition in intermediate format
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct IntermediateMutation {
112 /// Mutation name (e.g., "createUser")
113 pub name: String,
114
115 /// Return type name (e.g., "User")
116 pub return_type: String,
117
118 /// Returns a list?
119 #[serde(default)]
120 pub returns_list: bool,
121
122 /// Result is nullable?
123 #[serde(default)]
124 pub nullable: bool,
125
126 /// Mutation arguments
127 #[serde(default)]
128 pub arguments: Vec<IntermediateArgument>,
129
130 /// Mutation description (from docstring)
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub description: Option<String>,
133
134 /// SQL source (function name)
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub sql_source: Option<String>,
137
138 /// Operation type (CREATE, UPDATE, DELETE, CUSTOM)
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub operation: Option<String>,
141
142 /// Deprecation info (from @deprecated directive)
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub deprecated: Option<IntermediateDeprecation>,
145
146 /// Server-injected parameters: SQL parameter name → source expression (e.g. `"jwt:org_id"`).
147 /// Not exposed as GraphQL arguments.
148 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
149 pub inject: IndexMap<String, String>,
150
151 /// Role required to execute this mutation and see it in introspection.
152 ///
153 /// Mirrors [`IntermediateQuery::requires_role`]. Enforced at runtime with the same
154 /// enumeration-hiding "not found" response as the query gate — a principal lacking
155 /// the role cannot distinguish a forbidden mutation from a nonexistent one.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub requires_role: Option<String>,
158
159 /// Fact tables whose version counter should be bumped after this mutation succeeds.
160 ///
161 /// Used for correct invalidation of analytic/aggregate cache entries.
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub invalidates_fact_tables: Vec<String>,
164
165 /// View names whose cached query results should be invalidated after this
166 /// mutation succeeds.
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub invalidates_views: Vec<String>,
169
170 /// Whether this mutation writes a Change-Spine change-log row (default `true`).
171 ///
172 /// Set `false` (via the authoring SDK's `@fraiseql.mutation(changelog=False)`)
173 /// to opt a single mutation out of the in-transaction change-log outbox while
174 /// the rest of the schema keeps logging. Defaults to `true` so a schema
175 /// authored before this field existed keeps logging.
176 #[serde(default = "default_changelog")]
177 pub changelog: bool,
178
179 /// How the GraphQL `input` argument is passed to the SQL function:
180 /// `"flatten"` (positional columns, the default) or `"jsonb"` (the whole
181 /// input as one `jsonb` arg). See [`InputStyle`].
182 ///
183 /// Orthogonal to [`operation`](IntermediateMutation::operation): set
184 /// `"jsonb"` (via the authoring SDK's
185 /// `@fraiseql.mutation(input_style="jsonb")`) so a backend using the
186 /// single-`jsonb`-wrapper convention can register the real DML verb and
187 /// still receive the whole input as one argument. Defaults to `"flatten"`,
188 /// byte-identical to a schema authored before this field existed.
189 #[serde(default, skip_serializing_if = "InputStyle::is_flatten")]
190 pub input_style: InputStyle,
191
192 /// Whether a successful, state-changing run of this mutation also records the
193 /// changed entity's pre-image (before-state) into the Change-Spine
194 /// `object_data_before` column, sourced from an optional `entity_before` on the
195 /// mutation's `app.mutation_response`.
196 ///
197 /// Set `true` (via the authoring SDK's
198 /// `@fraiseql.mutation(changelog_pre_image=True)` or TOML
199 /// `changelog_pre_image = true`) to opt an audit-sensitive mutation into an
200 /// inline Debezium-style `{before, after}`. Defaults to `false`,
201 /// byte-identical to a schema authored before this field existed.
202 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
203 pub changelog_pre_image: bool,
204
205 /// Whether this mutation exposes the typed graphql-cascade `cascade` field
206 /// on its success payload.
207 ///
208 /// Set `true` via the authoring SDK's `@fraiseql.type(crud=True,
209 /// cascade=True)` / `@fraiseql.mutation(cascade=True)`, or TOML
210 /// `cascade = true`. Defaults to `false`, byte-identical to a schema
211 /// authored before this field existed. Before this field, the compiler
212 /// silently dropped the SDK flag.
213 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
214 pub cascade: bool,
215}
216
217impl Default for IntermediateMutation {
218 /// Hand-written so `changelog` defaults to `true` (matching the serde default),
219 /// not `bool`'s `false`. A `#[derive(Default)]` would silently opt
220 /// `..Default::default()`-built mutations out of the change log.
221 fn default() -> Self {
222 Self {
223 name: String::new(),
224 return_type: String::new(),
225 returns_list: false,
226 nullable: false,
227 arguments: Vec::new(),
228 description: None,
229 sql_source: None,
230 operation: None,
231 deprecated: None,
232 inject: IndexMap::new(),
233 requires_role: None,
234 invalidates_fact_tables: Vec::new(),
235 invalidates_views: Vec::new(),
236 changelog: default_changelog(),
237 input_style: InputStyle::Flatten,
238 changelog_pre_image: false,
239 cascade: false,
240 }
241 }
242}
243
244/// Serde default for [`IntermediateMutation::changelog`]: log by default (opt-out).
245const fn default_changelog() -> bool {
246 true
247}
248
249/// Auto-params configuration in intermediate format.
250///
251/// Each field is `Option<bool>`: `None` means "not specified — inherit from
252/// `[query_defaults]`"; `Some(v)` means explicitly set by the authoring-language decorator.
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct IntermediateAutoParams {
255 /// Enable automatic limit parameter (None = inherit from query_defaults)
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub limit: Option<bool>,
258 /// Enable automatic offset parameter (None = inherit from query_defaults)
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub offset: Option<bool>,
261 /// Enable automatic where clause parameter (None = inherit from query_defaults)
262 #[serde(rename = "where", default, skip_serializing_if = "Option::is_none")]
263 pub where_clause: Option<bool>,
264 /// Enable automatic order_by parameter (None = inherit from query_defaults)
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub order_by: Option<bool>,
267}
268
269/// Global auto-param defaults for list queries (injected from TOML by the merger).
270///
271/// Never present in `schema.json` — set only at compile time via `[query_defaults]`
272/// in `fraiseql.toml`.
273///
274/// The `Default` implementation returns all-`true`, matching the historical behaviour
275/// when no `[query_defaults]` section is present in TOML.
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
277pub struct IntermediateQueryDefaults {
278 /// Default for `where` parameter
279 pub where_clause: bool,
280 /// Default for `order_by` parameter
281 pub order_by: bool,
282 /// Default for `limit` parameter
283 pub limit: bool,
284 /// Default for `offset` parameter
285 pub offset: bool,
286}
287
288impl Default for IntermediateQueryDefaults {
289 fn default() -> Self {
290 Self {
291 where_clause: true,
292 order_by: true,
293 limit: true,
294 offset: true,
295 }
296 }
297}