Skip to main content

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 (postgresql, mysql, sqlite, sqlserver)
117    pub database_target: String,
118}
119
120impl Default for SchemaMetadata {
121    fn default() -> Self {
122        Self {
123            name:            "myapp".to_string(),
124            version:         "1.0.0".to_string(),
125            description:     None,
126            database_target: "postgresql".to_string(),
127        }
128    }
129}
130
131/// Query definition in TOML
132#[derive(Debug, Clone, Deserialize, Serialize)]
133#[serde(default, deny_unknown_fields)]
134pub struct QueryDefinition {
135    /// Return type name
136    pub return_type:  String,
137    /// Whether query returns an array
138    #[serde(default)]
139    pub return_array: bool,
140    /// SQL source for the query
141    pub sql_source:   String,
142    /// Query description
143    pub description:  Option<String>,
144    /// Query arguments
145    pub args:         Vec<ArgumentDefinition>,
146}
147
148impl Default for QueryDefinition {
149    fn default() -> Self {
150        Self {
151            return_type:  "String".to_string(),
152            return_array: false,
153            sql_source:   "v_entity".to_string(),
154            description:  None,
155            args:         vec![],
156        }
157    }
158}
159
160/// Mutation definition in TOML
161#[derive(Debug, Clone, Deserialize, Serialize)]
162#[serde(default, deny_unknown_fields)]
163pub struct MutationDefinition {
164    /// Return type name
165    pub return_type:         String,
166    /// SQL function or procedure source.
167    ///
168    /// When absent, the compiler resolves the function name from the `[crud]`
169    /// naming config using the `operation` and the entity name derived from
170    /// `return_type`. A compile error is emitted if both are missing.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub sql_source:          Option<String>,
173    /// Operation type (CREATE, UPDATE, DELETE)
174    pub operation:           String,
175    /// How the GraphQL `input` argument is passed to the SQL function:
176    /// `"flatten"` (positional columns, the default) or `"jsonb"` (the whole
177    /// input as one `jsonb` arg).
178    ///
179    /// Orthogonal to `operation`: set `"jsonb"` so a backend using the
180    /// single-`jsonb`-wrapper convention can register the real DML verb and
181    /// still receive the whole input as one argument — letting the Change Spine
182    /// record the true `modification_type`. Defaults to `"flatten"`.
183    #[serde(default = "default_input_style")]
184    pub input_style:         String,
185    /// Whether a successful, state-changing run of this mutation also records the
186    /// changed entity's pre-image (before-state) into the Change-Spine
187    /// `object_data_before` column, sourced from an optional `entity_before` on the
188    /// mutation's `app.mutation_response`.
189    ///
190    /// Set `true` to opt an audit-sensitive mutation into an inline Debezium-style
191    /// `{before, after}` on the single change event. Defaults to `false`,
192    /// byte-identical to a schema authored before this field existed.
193    #[serde(default)]
194    pub changelog_pre_image: bool,
195    /// Whether this mutation exposes the typed graphql-cascade `cascade` field
196    /// on its success payload.
197    ///
198    /// Set `true` to expose a typed, selection-gated `cascade` (mutation
199    /// responses carrying all affected entities per the graphql-cascade spec),
200    /// projected to camelCase and field-authorized per entity. Defaults to
201    /// `false`, byte-identical to a schema authored before this field existed.
202    #[serde(default)]
203    pub cascade:             bool,
204    /// Mutation description
205    pub description:         Option<String>,
206    /// Mutation arguments
207    pub args:                Vec<ArgumentDefinition>,
208}
209
210impl Default for MutationDefinition {
211    fn default() -> Self {
212        Self {
213            return_type:         "String".to_string(),
214            sql_source:          None,
215            operation:           "CREATE".to_string(),
216            input_style:         default_input_style(),
217            changelog_pre_image: false,
218            cascade:             false,
219            description:         None,
220            args:                vec![],
221        }
222    }
223}
224
225/// Serde default for [`MutationDefinition::input_style`]: positional flatten.
226fn default_input_style() -> String {
227    "flatten".to_string()
228}