fraiseql_cli/config/toml_schema/operations.rs
1//! Query and mutation operation definitions for TOML schema.
2
3use serde::{Deserialize, Serialize};
4
5use super::types::ArgumentDefinition;
6
7/// What the compiler does about the order a paginated read's pages are cut in
8/// (#1303).
9///
10/// An offset page is a slice of a *sequence*, and a read with no `ORDER BY` is not
11/// a sequence — two pages of the same relation can overlap and skip rows, under a
12/// `200`. The deployment-wide answer is here; a query overrides it with its own
13/// `pagination_order`.
14///
15/// # Why all three are compile-time
16///
17/// #1303 framed the strict posture as a request-time `400`, because it framed the
18/// ordering as a request property. It is not one: since the order is recorded per
19/// query in the compiled schema, every posture resolves into what the compiler
20/// writes there, and a second switch at runtime would be a second place the answer
21/// lives — free to disagree with the artifact that is supposed to *be* the answer.
22/// Refusing at compile time is also strictly earlier than refusing at request time.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
24#[serde(rename_all = "snake_case", deny_unknown_fields)]
25pub enum PaginationPosture {
26 /// Every paginating query gets the entity identity, and an author may declare
27 /// `pagination_order = "none"` to keep a self-ordering view's own `ORDER BY`.
28 ///
29 /// The default, and the founder decision of 2026-09-08: the boilerplate of a
30 /// must-declare rule is a permanent tax on every paginated query written from
31 /// now on, while the identity default's cost is bounded, measured (1.7–7.7×
32 /// on unordered paginated reads) and recoverable by one declaration in the
33 /// minority of cases where it is wrong.
34 #[default]
35 Identity,
36 /// As [`Identity`](Self::Identity), and `pagination_order = "none"` is refused.
37 ///
38 /// #1303's option 1, kept as a posture rather than a default: in this
39 /// deployment every paginated read has a total order and there are no
40 /// exceptions, including the self-ordering-view one. The refusal is a compile
41 /// error naming the query, not a `400` a client discovers.
42 Refuse,
43 /// Derive nothing: a query is ordered only where its author declared a column.
44 ///
45 /// The pre-#1303 behaviour, kept because this release changes what an
46 /// unordered paginated read returns and an operator may need the old answer
47 /// while their clients catch up. It restores the defect with it — which is why
48 /// the compile warns for every query it leaves unordered.
49 Allow,
50}
51
52/// Global defaults for list-query auto-params.
53///
54/// Applied when a per-query `auto_params` does not specify a given flag.
55/// Relay queries and single-item queries are never affected.
56///
57/// ```toml
58/// [query_defaults]
59/// where = true
60/// order_by = true
61/// limit = false # e.g. Relay-first project
62/// offset = false
63/// pagination_order = "identity"
64/// ```
65#[derive(Debug, Clone, Deserialize, Serialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct QueryDefaults {
68 /// Enable automatic `where` filter parameter (default: true)
69 #[serde(rename = "where", default = "default_true")]
70 pub where_clause: bool,
71 /// Enable automatic `order_by` parameter (default: true)
72 #[serde(default = "default_true")]
73 pub order_by: bool,
74 /// Enable automatic `limit` parameter (default: true)
75 #[serde(default = "default_true")]
76 pub limit: bool,
77 /// Enable automatic `offset` parameter (default: true)
78 #[serde(default = "default_true")]
79 pub offset: bool,
80 /// What the compiler does about the order paginated reads are cut in (#1303).
81 ///
82 /// ```toml
83 /// [query_defaults]
84 /// pagination_order = "identity" # default | "refuse" | "allow"
85 /// ```
86 #[serde(default)]
87 pub pagination_order: PaginationPosture,
88}
89
90impl Default for QueryDefaults {
91 fn default() -> Self {
92 Self {
93 where_clause: true,
94 order_by: true,
95 limit: true,
96 offset: true,
97 pagination_order: PaginationPosture::Identity,
98 }
99 }
100}
101
102fn default_true() -> bool {
103 true
104}
105
106/// Schema metadata
107#[derive(Debug, Clone, Deserialize, Serialize)]
108#[serde(default, deny_unknown_fields)]
109pub struct SchemaMetadata {
110 /// Schema name
111 pub name: String,
112 /// Schema version
113 pub version: String,
114 /// Optional schema description
115 pub description: Option<String>,
116 /// Target database. Only `postgresql` is accepted: MySQL, SQLite and SQL Server
117 /// were removed in v2.15.0 (#374), and [`Self::validate_database_target`] refuses
118 /// them rather than compiling PostgreSQL SQL under a target the engine cannot serve.
119 pub database_target: String,
120}
121
122impl SchemaMetadata {
123 /// Refuse a `database_target` other than `postgresql` (see
124 /// `config::validate_database_target`, shared with `[project] database_target`).
125 ///
126 /// # Errors
127 ///
128 /// Returns an error naming the removed backends and `docs/database-compatibility.md`
129 /// when the target is anything but `postgresql` (compared case-insensitively).
130 pub fn validate_database_target(&self) -> anyhow::Result<()> {
131 crate::config::validate_database_target("[schema]", &self.database_target)
132 }
133}
134
135impl Default for SchemaMetadata {
136 fn default() -> Self {
137 Self {
138 name: "myapp".to_string(),
139 version: "1.0.0".to_string(),
140 description: None,
141 database_target: "postgresql".to_string(),
142 }
143 }
144}
145
146/// Query definition in TOML
147#[derive(Debug, Clone, Deserialize, Serialize)]
148#[serde(default, deny_unknown_fields)]
149pub struct QueryDefinition {
150 /// Return type name
151 pub return_type: String,
152 /// Whether query returns an array
153 #[serde(default)]
154 pub return_array: bool,
155 /// SQL source for the query
156 pub sql_source: String,
157 /// Query description
158 pub description: Option<String>,
159 /// Query arguments
160 pub args: Vec<ArgumentDefinition>,
161}
162
163impl Default for QueryDefinition {
164 fn default() -> Self {
165 Self {
166 return_type: "String".to_string(),
167 return_array: false,
168 sql_source: "v_entity".to_string(),
169 description: None,
170 args: vec![],
171 }
172 }
173}
174
175/// Mutation definition in TOML
176#[derive(Debug, Clone, Deserialize, Serialize)]
177#[serde(default, deny_unknown_fields)]
178pub struct MutationDefinition {
179 /// Return type name
180 pub return_type: String,
181 /// SQL function or procedure source.
182 ///
183 /// When absent, the compiler resolves the function name from the `[crud]`
184 /// naming config using the `operation` and the entity name derived from
185 /// `return_type`. A compile error is emitted if both are missing.
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub sql_source: Option<String>,
188 /// Operation type (CREATE, UPDATE, DELETE)
189 pub operation: String,
190 /// How the GraphQL `input` argument is passed to the SQL function:
191 /// `"flatten"` (positional columns, the default) or `"jsonb"` (the whole
192 /// input as one `jsonb` arg).
193 ///
194 /// Orthogonal to `operation`: set `"jsonb"` so a backend using the
195 /// single-`jsonb`-wrapper convention can register the real DML verb and
196 /// still receive the whole input as one argument — letting the Change Spine
197 /// record the true `modification_type`. Defaults to `"flatten"`.
198 #[serde(default = "default_input_style")]
199 pub input_style: String,
200 /// Whether a successful, state-changing run of this mutation also records the
201 /// changed entity's pre-image (before-state) into the Change-Spine
202 /// `object_data_before` column, sourced from an optional `entity_before` on the
203 /// mutation's `app.mutation_response`.
204 ///
205 /// Set `true` to opt an audit-sensitive mutation into an inline Debezium-style
206 /// `{before, after}` on the single change event. Defaults to `false`,
207 /// byte-identical to a schema authored before this field existed.
208 #[serde(default)]
209 pub changelog_pre_image: bool,
210 /// Whether this mutation exposes the typed graphql-cascade `cascade` field
211 /// on its success payload.
212 ///
213 /// Set `true` to expose a typed, selection-gated `cascade` (mutation
214 /// responses carrying all affected entities per the graphql-cascade spec),
215 /// projected to camelCase and field-authorized per entity. Defaults to
216 /// `false`, byte-identical to a schema authored before this field existed.
217 #[serde(default)]
218 pub cascade: bool,
219 /// Mutation description
220 pub description: Option<String>,
221 /// Mutation arguments
222 pub args: Vec<ArgumentDefinition>,
223}
224
225impl Default for MutationDefinition {
226 fn default() -> Self {
227 Self {
228 return_type: "String".to_string(),
229 sql_source: None,
230 operation: "CREATE".to_string(),
231 input_style: default_input_style(),
232 changelog_pre_image: false,
233 cascade: false,
234 description: None,
235 args: vec![],
236 }
237 }
238}
239
240/// Serde default for [`MutationDefinition::input_style`]: positional flatten.
241fn default_input_style() -> String {
242 "flatten".to_string()
243}