Skip to main content

fraiseql_cli/schema/intermediate/
operations.rs

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