Skip to main content

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}