Skip to main content

fraiseql_cli/schema/intermediate/
operations.rs

1//! Query/mutation structs: `IntermediateQuery`, `IntermediateMutation`,
2//! `IntermediateArgument`, `IntermediateAutoParams`, `IntermediateQueryDefaults`.
3
4use fraiseql_core::{db::types::ReadRouting, schema::InputStyle};
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7
8use super::types::IntermediateDeprecation;
9
10/// Serde representation of the server-injected parameter map.
11///
12/// **The wire key is `inject_params` and it matches the compiled schema's key.** That is
13/// the fix for #806, not a cosmetic choice: the intermediate format used to call this
14/// `inject` while the compiled artifact called it `inject_params`, so every SDK author
15/// who looked at a compiled schema to learn the name got it wrong. `TypeScript`, Go and
16/// Java all did, independently, and `#[serde(default)]` with no `deny_unknown_fields`
17/// turned each of their injection maps into an empty default — compiling a query with no
18/// tenant predicate and printing `✓ Schema compiled successfully`. Two names for one
19/// concept is what made that mistake available; one name retires it.
20///
21/// Both value shapes are accepted, because both are unambiguous and both are already in
22/// use:
23///
24/// * nested — `{"tenant_id": {"source": "jwt", "claim": "org_id"}}`, what every SDK emits
25/// * flat — `{"tenant_id": "jwt:org_id"}`, which is far pleasanter to hand-author
26///
27/// Serialization always emits the nested form, so a round-tripped intermediate schema is
28/// byte-comparable with SDK output.
29pub(super) mod inject_params_serde {
30    use indexmap::IndexMap;
31    use serde::{
32        Deserialize, Deserializer, Serializer,
33        de::Error as _,
34        ser::{Error as _, SerializeMap as _},
35    };
36
37    #[derive(Deserialize)]
38    #[serde(untagged)]
39    enum Spec {
40        /// `"jwt:org_id"`
41        Flat(String),
42        /// `{"source": "jwt", "claim": "org_id"}`
43        Nested { source: String, claim: String },
44    }
45
46    /// Deserialize either accepted shape into the internal `"<source>:<claim>"` form.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error for a flat value with no `<source>:` prefix. A malformed source
51    /// must not become a silently-unfiltered query — the same reasoning as the key.
52    pub fn deserialize<'de, D>(deserializer: D) -> Result<IndexMap<String, String>, D::Error>
53    where
54        D: Deserializer<'de>,
55    {
56        IndexMap::<String, Spec>::deserialize(deserializer)?
57            .into_iter()
58            .map(|(param, spec)| match spec {
59                Spec::Flat(source) if source.contains(':') => Ok((param, source)),
60                Spec::Flat(source) => Err(D::Error::custom(format!(
61                    "inject_params['{param}'] must be \"<source>:<claim>\" (for example \
62                     \"jwt:org_id\") or {{\"source\": \"jwt\", \"claim\": \"org_id\"}}, got \
63                     \"{source}\""
64                ))),
65                Spec::Nested { source, claim } => Ok((param, format!("{source}:{claim}"))),
66            })
67            .collect()
68    }
69
70    /// Serialize as the nested form every SDK emits.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if an entry is not in `"<source>:<claim>"` form, which
75    /// [`deserialize`] guarantees but a direct struct construction does not.
76    pub fn serialize<S>(map: &IndexMap<String, String>, serializer: S) -> Result<S::Ok, S::Error>
77    where
78        S: Serializer,
79    {
80        let mut out = serializer.serialize_map(Some(map.len()))?;
81        for (param, spec) in map {
82            let (source, claim) = spec.split_once(':').ok_or_else(|| {
83                S::Error::custom(format!(
84                    "inject_params['{param}'] is not in \"<source>:<claim>\" form: \"{spec}\""
85                ))
86            })?;
87            out.serialize_entry(param, &serde_json::json!({"source": source, "claim": claim}))?;
88        }
89        out.end()
90    }
91}
92
93/// Per-operation REST route override, in the shape every SDK emits.
94///
95/// The wire form is `"rest": {"path": "/api/v1/orders", "method": "GET"}` — verified
96/// identical across the Python, Go, PHP, C#, Java, Elixir, F# and Rust SDKs. It had no
97/// consumer: neither [`IntermediateQuery`] nor [`IntermediateMutation`] declared the
98/// field and the intermediate schema has no `deny_unknown_fields`, so serde discarded
99/// the block and the converter hardcoded `rest_path`/`rest_method` to `None`. An author
100/// setting `rest_path` got a clean compile and a route that 404s — and `detect_conflicts`
101/// told operators to "use `rest_path` override to resolve" a route collision, advice that
102/// could not work (#846).
103///
104/// `deny_unknown_fields` here is deliberate. This block exists precisely because a
105/// silently-dropped key shipped for two releases; a typo inside it must fail the compile
106/// rather than quietly degrade to a path-only override.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct IntermediateRest {
110    /// Route path, replacing the derived one (e.g. `/api/v1/orders`).
111    pub path: String,
112
113    /// HTTP method. Absent means the derived default for the operation kind —
114    /// `GET` for a query, `POST` for a mutation.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub method: Option<String>,
117}
118
119/// Project-wide default injected parameters, applied to operations that do not name them.
120///
121/// Seven SDKs — Python, Go, Java, PHP, C#, Elixir and F# — ship a `ConfigLoader` that
122/// parses `[inject_defaults]` from `fraiseql.toml` and emits this block as a top-level key.
123/// The compiler had never heard of it: `grep -rn inject_defaults crates/` returned nothing,
124/// so `fraiseql compile` accepted the key, exited 0, printed `✓ Schema compiled
125/// successfully`, and produced operations carrying no injection at all. An operator who
126/// wrote `[inject_defaults] tenant_id = "jwt:tenant_id"` to stamp every operation with the
127/// caller's tenant got a clean compile and **not one compiled operation with a tenant
128/// predicate** (#847). Seven separate implementations of a feature that had never done
129/// anything.
130///
131/// ## Ordering, and why it is not the one the issue suggested
132///
133/// `#847` proposed merging these in *before* `[fraiseql.tenancy]` validation. That is
134/// wrong, and the two features would have become mutually exclusive: tenancy auto-injects
135/// the annotated field when an operation's `inject_params` is empty and **fails the
136/// compile** when it is non-empty but lacks the field. A default supplying any unrelated
137/// key (`read_scope`, the example in the Python SDK's own docstring) would therefore have
138/// turned every tenancy-annotated query into a compile error.
139///
140/// Defaults are applied by the converter, which runs after tenancy validation. Precedence,
141/// most specific first:
142///
143/// 1. the operation's own `inject_params`
144/// 2. `[fraiseql.tenancy]` auto-injection
145/// 3. `[inject_defaults].queries` / `.mutations`
146/// 4. `[inject_defaults].base`
147#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct IntermediateInjectDefaults {
150    /// Applied to queries **and** mutations.
151    #[serde(
152        default,
153        with = "inject_params_serde",
154        skip_serializing_if = "IndexMap::is_empty"
155    )]
156    pub base: IndexMap<String, String>,
157
158    /// Applied to queries only, overriding a `base` entry of the same name.
159    #[serde(
160        default,
161        with = "inject_params_serde",
162        skip_serializing_if = "IndexMap::is_empty"
163    )]
164    pub queries: IndexMap<String, String>,
165
166    /// Applied to mutations only, overriding a `base` entry of the same name.
167    #[serde(
168        default,
169        with = "inject_params_serde",
170        skip_serializing_if = "IndexMap::is_empty"
171    )]
172    pub mutations: IndexMap<String, String>,
173}
174
175impl IntermediateInjectDefaults {
176    /// The defaults that apply to a query: `base`, then `queries` overriding it.
177    pub fn for_queries(&self) -> IndexMap<String, String> {
178        Self::layer(&self.base, &self.queries)
179    }
180
181    /// The defaults that apply to a mutation: `base`, then `mutations` overriding it.
182    pub fn for_mutations(&self) -> IndexMap<String, String> {
183        Self::layer(&self.base, &self.mutations)
184    }
185
186    /// `base` with `specific` layered on top.
187    fn layer(
188        base: &IndexMap<String, String>,
189        specific: &IndexMap<String, String>,
190    ) -> IndexMap<String, String> {
191        let mut merged = base.clone();
192        for (key, source) in specific {
193            merged.insert(key.clone(), source.clone());
194        }
195        merged
196    }
197
198    /// Fill in any default the operation does not already declare.
199    ///
200    /// Absent-only, never overwriting: an operation that names a parameter has made a more
201    /// specific decision than a project-wide default, and silently replacing it would be
202    /// the same class of defect one level up.
203    pub fn apply_to(defaults: &IndexMap<String, String>, inject: &mut IndexMap<String, String>) {
204        for (key, source) in defaults {
205            if !inject.contains_key(key) {
206                inject.insert(key.clone(), source.clone());
207            }
208        }
209    }
210}
211
212/// Argument definition in intermediate format
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(deny_unknown_fields)]
215pub struct IntermediateArgument {
216    /// Argument name
217    pub name: String,
218
219    /// Argument type name
220    ///
221    /// **Language-agnostic**: Uses "type", not "`arg_type`"
222    #[serde(rename = "type")]
223    pub arg_type: String,
224
225    /// Is argument optional?
226    pub nullable: bool,
227
228    /// Default value (JSON)
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub default: Option<serde_json::Value>,
231
232    /// Argument description (from docstring or TOML `description`).
233    ///
234    /// The compiled `ArgumentDefinition` has always had a `description`, and the TOML
235    /// authoring surface has always accepted one — but there was no field here to carry it
236    /// between them, so the converter hard-coded `description: None` and every authored
237    /// argument description was dropped. Found while fixing `#756`; same seam, same shape.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub description: Option<String>,
240
241    /// Deprecation info (from @deprecated directive)
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub deprecated: Option<IntermediateDeprecation>,
244}
245
246impl From<&crate::config::toml_schema::ArgumentDefinition> for IntermediateArgument {
247    /// Lower a TOML-declared argument into the intermediate form.
248    ///
249    /// **This conversion exists so the key names cannot drift.** `#756` was two
250    /// hand-written `json!` literals emitting `"args"` with a `"required"` flag while this
251    /// struct deserialized `"arguments"` with a `"nullable"` flag. Both sides carried
252    /// `#[serde(default)]`, so the mismatch bound to an empty vector and every
253    /// TOML-declared argument on every operation silently disappeared — compiling a query
254    /// whose declared `id` filter was never applied, and returning rows the author had
255    /// excluded.
256    ///
257    /// Producing the struct and serializing *it* means the wire keys come from the field
258    /// definitions above. There is no second spelling to keep in sync.
259    fn from(arg: &crate::config::toml_schema::ArgumentDefinition) -> Self {
260        Self {
261            name:        arg.name.clone(),
262            arg_type:    arg.arg_type.clone(),
263            // TOML says `required`; the intermediate and compiled schemas say `nullable`.
264            // The inversion happens here, once.
265            nullable:    !arg.required,
266            default:     arg.default.clone(),
267            description: arg.description.clone(),
268            deprecated:  None,
269        }
270    }
271}
272
273/// Query definition in intermediate format
274#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
275#[serde(deny_unknown_fields)]
276pub struct IntermediateQuery {
277    /// Query name (e.g., "users")
278    pub name: String,
279
280    /// Return type name (e.g., "User")
281    pub return_type: String,
282
283    /// Returns a list?
284    #[serde(default)]
285    pub returns_list: bool,
286
287    /// Result is nullable?
288    #[serde(default)]
289    pub nullable: bool,
290
291    /// Query arguments
292    #[serde(default)]
293    pub arguments: Vec<IntermediateArgument>,
294
295    /// Query description (from docstring)
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub description: Option<String>,
298
299    /// SQL source (table/view name)
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub sql_source: Option<String>,
302
303    /// Back this root field with a declared function instead of a relation (#1329).
304    ///
305    /// Names an entry in the `functions` section whose trigger is `request:query`.
306    /// Mutually exclusive with [`sql_source`](Self::sql_source) — a field resolves
307    /// one way, and a precedence rule would be one more thing to remember wrong.
308    ///
309    /// Everything that lowers into SQL is refused beside it rather than ignored:
310    /// `relay`, `count`, `pagination_order`, `inject_params`, `rest_stream`,
311    /// `jsonb_column`, a non-default `read_routing`, and an explicitly declared
312    /// `auto_params` flag. Each of those is a setting the compiler would otherwise
313    /// carry into an artifact where nothing reads it, and one of them
314    /// (`inject_params`) is a tenant-scoping control — a silently dropped security
315    /// setting is the failure this seam exists to refuse.
316    ///
317    /// Project-wide `[query_defaults]` are **not** refused, only defaulted away: an
318    /// author who set `limit = true` for the project did not declare it for this
319    /// query, and failing their compile over a default they never wrote here would
320    /// be refusing the wrong statement.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub function: Option<String>,
323
324    /// Auto-generated parameters config
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub auto_params: Option<IntermediateAutoParams>,
327
328    /// Deprecation info (from @deprecated directive)
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub deprecated: Option<IntermediateDeprecation>,
331
332    /// JSONB column name for extracting data (e.g., "data")
333    /// Used for tv_* (denormalized JSONB tables) pattern
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub jsonb_column: Option<String>,
336
337    /// Whether this is a Relay connection query.
338    /// When true, the compiler wraps results in `{ edges: [{ node, cursor }], pageInfo }`
339    /// and generates `first`/`after`/`last`/`before` arguments instead of `limit`/`offset`.
340    /// Requires `returns_list = true` and `sql_source` to be set.
341    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
342    pub relay: bool,
343
344    /// Emit a `<name>Count(where): Int!` sibling for this list query (#938).
345    ///
346    /// Opt-in rather than automatic: the count is a second `SELECT COUNT(*)` that
347    /// scans the whole filtered set, which is a real cost on a hot list endpoint
348    /// and is wasted on every list that is not rendered with page numbers.
349    ///
350    /// Requires `returns_list = true` and `sql_source`. Refused on `relay = true`
351    /// — a connection already carries `totalCount`.
352    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
353    pub count: bool,
354
355    /// Server-injected parameters: SQL column name → source expression (e.g. `"jwt:org_id"`).
356    /// Not exposed as GraphQL arguments.
357    ///
358    /// Wire key is `inject_params`, matching the compiled schema. The two names were
359    /// unified because the split is what let three SDKs emit a key that bound to
360    /// nothing (#806); see the `inject_params_serde` module for the full reasoning.
361    #[serde(
362        default,
363        rename = "inject_params",
364        with = "inject_params_serde",
365        skip_serializing_if = "IndexMap::is_empty"
366    )]
367    pub inject: IndexMap<String, String>,
368
369    /// Where this query's reads may be served from (#957).
370    ///
371    /// `any` (the default) follows server policy; `primary` refuses replicas —
372    /// and the result cache — for reads where staleness is a correctness problem;
373    /// `replica` opts out of the read-your-writes pin and may carry its own
374    /// `max_lag_ms`.
375    ///
376    /// FraiseQL defines the shape and enforces it; an authoring language emits
377    /// it — a `@reads_from(...)` directive is one spelling.
378    #[serde(default, skip_serializing_if = "ReadRouting::is_default")]
379    pub read_routing: ReadRouting,
380
381    /// Per-query result cache TTL in seconds. Overrides the global cache TTL for this query.
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub cache_ttl_seconds: Option<u64>,
384
385    /// Additional database views this query reads beyond the primary `sql_source`.
386    ///
387    /// Used for correct cache invalidation when a query JOINs or reads multiple views.
388    /// Each entry is validated as a safe SQL identifier at schema compile time.
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub additional_views: Vec<String>,
391
392    /// Role required to execute this query and see it in introspection.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub requires_role: Option<String>,
395
396    /// Actor classes permitted to execute this query — an allow-list (#966).
397    ///
398    /// Empty means unrestricted. Tokens are the `snake_case` `ActorType` names
399    /// (`human_user`, `service_account`, `ai_agent`, `system_job`); an
400    /// unrecognised one is a compile error rather than a silently-ignored
401    /// restriction, which is the difference between a typo and an open operation.
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub requires_actor: Vec<String>,
404
405    /// Relay cursor column type: `"uuid"` for UUID PKs, `"int64"` (or absent) for bigint PKs.
406    /// Only meaningful when `relay = true`.
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub relay_cursor_type: Option<String>,
409
410    /// REST route override for this query. See [`IntermediateRest`].
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub rest: Option<IntermediateRest>,
413
414    /// Allow this query to be exported as a stream over REST (#958).
415    ///
416    /// Separate from [`rest`](Self::rest) rather than a key inside it, because
417    /// that block's `path` is mandatory: nesting the flag there would make every
418    /// author who wants an export also override the route.
419    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
420    pub rest_stream: bool,
421
422    /// Override the order this query's `LIMIT`/`OFFSET` pages are cut in (#1303).
423    ///
424    /// Two spellings, and absence is the third:
425    ///
426    /// * a **column name** — order pages by this column, which must be unique over `sql_source` or
427    ///   the pages still overlap;
428    /// * the literal **`"none"`** — emit no default ordering. This is how a view that carries its
429    ///   own `ORDER BY` keeps it: the compiler cannot read a view's body, so the opt-out is
430    ///   declared rather than inferred, and declaring it also silences the compile warning about
431    ///   non-deterministic pages, because the author answered it.
432    /// * **absent** — the compiler derives the entity identity, which is what almost every query
433    ///   wants.
434    ///
435    /// `"none"` is therefore not usable as a column name here. A relation column
436    /// actually called `none` must be ordered by through the SQL view instead.
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub pagination_order: Option<String>,
439}
440
441/// Mutation definition in intermediate format
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(deny_unknown_fields)]
444pub struct IntermediateMutation {
445    /// Mutation name (e.g., "createUser")
446    pub name: String,
447
448    /// Return type name (e.g., "User")
449    pub return_type: String,
450
451    /// Returns a list?
452    #[serde(default)]
453    pub returns_list: bool,
454
455    /// Result is nullable?
456    #[serde(default)]
457    pub nullable: bool,
458
459    /// Mutation arguments
460    #[serde(default)]
461    pub arguments: Vec<IntermediateArgument>,
462
463    /// Mutation description (from docstring)
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub description: Option<String>,
466
467    /// SQL source (function name)
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub sql_source: Option<String>,
470
471    /// Operation type (CREATE, UPDATE, DELETE, CUSTOM)
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub operation: Option<String>,
474
475    /// Deprecation info (from @deprecated directive)
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub deprecated: Option<IntermediateDeprecation>,
478
479    /// Server-injected parameters: SQL parameter name → source expression (e.g. `"jwt:org_id"`).
480    /// Not exposed as GraphQL arguments.
481    ///
482    /// Wire key is `inject_params`, matching the compiled schema. The two names were
483    /// unified because the split is what let three SDKs emit a key that bound to
484    /// nothing (#806); see the `inject_params_serde` module for the full reasoning.
485    #[serde(
486        default,
487        rename = "inject_params",
488        with = "inject_params_serde",
489        skip_serializing_if = "IndexMap::is_empty"
490    )]
491    pub inject: IndexMap<String, String>,
492
493    /// Role required to execute this mutation and see it in introspection.
494    ///
495    /// Mirrors [`IntermediateQuery::requires_role`]. Enforced at runtime with the same
496    /// enumeration-hiding "not found" response as the query gate — a principal lacking
497    /// the role cannot distinguish a forbidden mutation from a nonexistent one.
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub requires_role: Option<String>,
500
501    /// Actor classes permitted to execute this mutation — an allow-list (#966).
502    ///
503    /// Mirrors [`IntermediateQuery::requires_actor`].
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub requires_actor: Vec<String>,
506
507    /// Fact tables whose version counter should be bumped after this mutation succeeds.
508    ///
509    /// Used for correct invalidation of analytic/aggregate cache entries.
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub invalidates_fact_tables: Vec<String>,
512
513    /// View names whose cached query results should be invalidated after this
514    /// mutation succeeds.
515    #[serde(default, skip_serializing_if = "Vec::is_empty")]
516    pub invalidates_views: Vec<String>,
517
518    /// Whether this mutation writes a Change-Spine change-log row (default `true`).
519    ///
520    /// Set `false` (via the authoring SDK's `@fraiseql.mutation(changelog=False)`)
521    /// to opt a single mutation out of the in-transaction change-log outbox while
522    /// the rest of the schema keeps logging. Defaults to `true` so a schema
523    /// authored before this field existed keeps logging.
524    #[serde(default = "default_changelog")]
525    pub changelog: bool,
526
527    /// How the GraphQL `input` argument is passed to the SQL function:
528    /// `"flatten"` (positional columns, the default) or `"jsonb"` (the whole
529    /// input as one `jsonb` arg). See [`InputStyle`].
530    ///
531    /// Orthogonal to [`operation`](IntermediateMutation::operation): set
532    /// `"jsonb"` (via the authoring SDK's
533    /// `@fraiseql.mutation(input_style="jsonb")`) so a backend using the
534    /// single-`jsonb`-wrapper convention can register the real DML verb and
535    /// still receive the whole input as one argument. Defaults to `"flatten"`,
536    /// byte-identical to a schema authored before this field existed.
537    #[serde(default, skip_serializing_if = "InputStyle::is_flatten")]
538    pub input_style: InputStyle,
539
540    /// Whether a successful, state-changing run of this mutation also records the
541    /// changed entity's pre-image (before-state) into the Change-Spine
542    /// `object_data_before` column, sourced from an optional `entity_before` on the
543    /// mutation's `app.mutation_response`.
544    ///
545    /// Set `true` (via the authoring SDK's
546    /// `@fraiseql.mutation(changelog_pre_image=True)` or TOML
547    /// `changelog_pre_image = true`) to opt an audit-sensitive mutation into an
548    /// inline Debezium-style `{before, after}`. Defaults to `false`,
549    /// byte-identical to a schema authored before this field existed.
550    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
551    pub changelog_pre_image: bool,
552
553    /// Whether this mutation exposes the typed graphql-cascade `cascade` field
554    /// on its success payload.
555    ///
556    /// Set `true` via the authoring SDK's `@fraiseql.type(crud=True,
557    /// cascade=True)` / `@fraiseql.mutation(cascade=True)`, or TOML
558    /// `cascade = true`. Defaults to `false`, byte-identical to a schema
559    /// authored before this field existed. Before this field, the compiler
560    /// silently dropped the SDK flag.
561    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
562    pub cascade: bool,
563
564    /// REST route override for this mutation. See [`IntermediateRest`].
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub rest: Option<IntermediateRest>,
567}
568
569impl Default for IntermediateMutation {
570    /// Hand-written so `changelog` defaults to `true` (matching the serde default),
571    /// not `bool`'s `false`. A `#[derive(Default)]` would silently opt
572    /// `..Default::default()`-built mutations out of the change log.
573    fn default() -> Self {
574        Self {
575            name:                    String::new(),
576            return_type:             String::new(),
577            returns_list:            false,
578            nullable:                false,
579            arguments:               Vec::new(),
580            description:             None,
581            sql_source:              None,
582            operation:               None,
583            deprecated:              None,
584            inject:                  IndexMap::new(),
585            requires_actor:          Vec::new(),
586            requires_role:           None,
587            invalidates_fact_tables: Vec::new(),
588            invalidates_views:       Vec::new(),
589            changelog:               default_changelog(),
590            input_style:             InputStyle::Flatten,
591            changelog_pre_image:     false,
592            cascade:                 false,
593            rest:                    None,
594        }
595    }
596}
597
598/// Serde default for [`IntermediateMutation::changelog`]: log by default (opt-out).
599const fn default_changelog() -> bool {
600    true
601}
602
603/// Auto-params configuration in intermediate format.
604///
605/// Each field is `Option<bool>`: `None` means "not specified — inherit from
606/// `[query_defaults]`"; `Some(v)` means explicitly set by the authoring-language decorator.
607#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(deny_unknown_fields)]
609pub struct IntermediateAutoParams {
610    /// Enable automatic limit parameter (None = inherit from query_defaults)
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub limit:        Option<bool>,
613    /// Enable automatic offset parameter (None = inherit from query_defaults)
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub offset:       Option<bool>,
616    /// Enable automatic where clause parameter (None = inherit from query_defaults)
617    #[serde(rename = "where", default, skip_serializing_if = "Option::is_none")]
618    pub where_clause: Option<bool>,
619    /// Enable automatic order_by parameter (None = inherit from query_defaults)
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub order_by:     Option<bool>,
622}
623
624/// Global auto-param defaults for list queries (injected from TOML by the merger).
625///
626/// Never present in `schema.json` — set only at compile time via `[query_defaults]`
627/// in `fraiseql.toml`.
628///
629/// The `Default` implementation returns all-`true`, matching the historical behaviour
630/// when no `[query_defaults]` section is present in TOML.
631#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
632#[serde(deny_unknown_fields)]
633pub struct IntermediateQueryDefaults {
634    /// Default for `where` parameter
635    pub where_clause:     bool,
636    /// Default for `order_by` parameter
637    pub order_by:         bool,
638    /// Default for `limit` parameter
639    pub limit:            bool,
640    /// Default for `offset` parameter
641    pub offset:           bool,
642    /// Deployment-wide posture on the order paginated pages are cut in (#1303).
643    pub pagination_order: crate::config::toml_schema::PaginationPosture,
644}
645
646impl Default for IntermediateQueryDefaults {
647    fn default() -> Self {
648        Self {
649            where_clause:     true,
650            order_by:         true,
651            limit:            true,
652            offset:           true,
653            pagination_order: crate::config::toml_schema::PaginationPosture::Identity,
654        }
655    }
656}