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 each default the operation does not already have, and return the keys added.
199 ///
200 /// A default is skipped when the operation:
201 ///
202 /// - names the same parameter — absent-only, never overwriting: an operation that names a
203 /// parameter has made a more specific decision than a project-wide default;
204 /// - already injects the same **source** under another name (#1385) — a mutation whose function
205 /// takes the tenant as `p_tenant_id` already receives it, and a second argument carrying the
206 /// same claim would only break the function's arity;
207 /// - lists the key in `exclude_inject_defaults` (#1383) — global reference data (a country
208 /// list, a catalogue) has no tenant to filter on, and the default is the fail-closed shape
209 /// only if dropping it is expressible next to the operation.
210 ///
211 /// The returned keys are the default-supplied parameters — what the compiler names when
212 /// one of them does not fit the operation's function.
213 ///
214 /// # Errors
215 ///
216 /// An exclusion that no default supplies (stale, or a typo that would silently leave the
217 /// default applied), or one the operation also declares itself (a contradiction).
218 pub fn apply_to(
219 defaults: &IndexMap<String, String>,
220 inject: &mut IndexMap<String, String>,
221 excluded: &[String],
222 operation: &str,
223 ) -> anyhow::Result<Vec<String>> {
224 for key in excluded {
225 if inject.contains_key(key) {
226 anyhow::bail!(
227 "Operation '{operation}' both declares inject_params['{key}'] and lists \
228 '{key}' in exclude_inject_defaults. An exclusion drops a project default; \
229 a parameter the operation declares itself is not a default. Keep one."
230 );
231 }
232 if !defaults.contains_key(key) {
233 anyhow::bail!(
234 "Operation '{operation}' lists '{key}' in exclude_inject_defaults, but no \
235 [inject_defaults] entry supplies '{key}' to it. Remove the exclusion, or fix \
236 its spelling — an exclusion that matches nothing leaves every default applied."
237 );
238 }
239 }
240 let mut added = Vec::new();
241 for (key, source) in defaults {
242 let declared = inject.contains_key(key) || inject.values().any(|own| own == source);
243 if declared || excluded.contains(key) {
244 continue;
245 }
246 inject.insert(key.clone(), source.clone());
247 added.push(key.clone());
248 }
249 Ok(added)
250 }
251}
252
253/// Argument definition in intermediate format
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct IntermediateArgument {
257 /// Argument name
258 pub name: String,
259
260 /// Argument type name
261 ///
262 /// **Language-agnostic**: Uses "type", not "`arg_type`"
263 #[serde(rename = "type")]
264 pub arg_type: String,
265
266 /// Is argument optional?
267 pub nullable: bool,
268
269 /// Default value (JSON)
270 #[serde(skip_serializing_if = "Option::is_none")]
271 pub default: Option<serde_json::Value>,
272
273 /// Argument description (from docstring or TOML `description`).
274 ///
275 /// The compiled `ArgumentDefinition` has always had a `description`, and the TOML
276 /// authoring surface has always accepted one — but there was no field here to carry it
277 /// between them, so the converter hard-coded `description: None` and every authored
278 /// argument description was dropped. Found while fixing `#756`; same seam, same shape.
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub description: Option<String>,
281
282 /// Deprecation info (from @deprecated directive)
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub deprecated: Option<IntermediateDeprecation>,
285
286 /// A localized `String` (#1513): written as a label, translations or a locale map, and
287 /// received by the SQL function as the map.
288 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
289 pub localized: bool,
290}
291
292impl From<&crate::config::toml_schema::ArgumentDefinition> for IntermediateArgument {
293 /// Lower a TOML-declared argument into the intermediate form.
294 ///
295 /// **This conversion exists so the key names cannot drift.** `#756` was two
296 /// hand-written `json!` literals emitting `"args"` with a `"required"` flag while this
297 /// struct deserialized `"arguments"` with a `"nullable"` flag. Both sides carried
298 /// `#[serde(default)]`, so the mismatch bound to an empty vector and every
299 /// TOML-declared argument on every operation silently disappeared — compiling a query
300 /// whose declared `id` filter was never applied, and returning rows the author had
301 /// excluded.
302 ///
303 /// Producing the struct and serializing *it* means the wire keys come from the field
304 /// definitions above. There is no second spelling to keep in sync.
305 fn from(arg: &crate::config::toml_schema::ArgumentDefinition) -> Self {
306 Self {
307 name: arg.name.clone(),
308 arg_type: arg.arg_type.clone(),
309 // TOML says `required`; the intermediate and compiled schemas say `nullable`.
310 // The inversion happens here, once.
311 nullable: !arg.required,
312 default: arg.default.clone(),
313 description: arg.description.clone(),
314 deprecated: None,
315 localized: false,
316 }
317 }
318}
319
320/// Query definition in intermediate format
321#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
322#[serde(deny_unknown_fields)]
323pub struct IntermediateQuery {
324 /// Query name (e.g., "users")
325 pub name: String,
326
327 /// Return type name (e.g., "User")
328 pub return_type: String,
329
330 /// Returns a list?
331 #[serde(default)]
332 pub returns_list: bool,
333
334 /// Result is nullable?
335 #[serde(default)]
336 pub nullable: bool,
337
338 /// Query arguments
339 #[serde(default)]
340 pub arguments: Vec<IntermediateArgument>,
341
342 /// Query description (from docstring)
343 #[serde(skip_serializing_if = "Option::is_none")]
344 pub description: Option<String>,
345
346 /// SQL source (table/view name)
347 #[serde(skip_serializing_if = "Option::is_none")]
348 pub sql_source: Option<String>,
349
350 /// Back this root field with a declared function instead of a relation (#1329).
351 ///
352 /// Names an entry in the `functions` section whose trigger is `request:query`.
353 /// Mutually exclusive with [`sql_source`](Self::sql_source) — a field resolves
354 /// one way, and a precedence rule would be one more thing to remember wrong.
355 ///
356 /// Everything that lowers into SQL is refused beside it rather than ignored:
357 /// `relay`, `count`, `pagination_order`, `inject_params`, `rest_stream`,
358 /// `jsonb_column`, a non-default `read_routing`, and an explicitly declared
359 /// `auto_params` flag. Each of those is a setting the compiler would otherwise
360 /// carry into an artifact where nothing reads it, and one of them
361 /// (`inject_params`) is a tenant-scoping control — a silently dropped security
362 /// setting is the failure this seam exists to refuse.
363 ///
364 /// Project-wide `[query_defaults]` are **not** refused, only defaulted away: an
365 /// author who set `limit = true` for the project did not declare it for this
366 /// query, and failing their compile over a default they never wrote here would
367 /// be refusing the wrong statement.
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub function: Option<String>,
370
371 /// Auto-generated parameters config
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub auto_params: Option<IntermediateAutoParams>,
374
375 /// Deprecation info (from @deprecated directive)
376 #[serde(skip_serializing_if = "Option::is_none")]
377 pub deprecated: Option<IntermediateDeprecation>,
378
379 /// JSONB column name for extracting data (e.g., "data")
380 /// Used for tv_* (denormalized JSONB tables) pattern
381 #[serde(skip_serializing_if = "Option::is_none")]
382 pub jsonb_column: Option<String>,
383
384 /// Whether this is a Relay connection query.
385 /// When true, the compiler wraps results in `{ edges: [{ node, cursor }], pageInfo }`
386 /// and generates `first`/`after`/`last`/`before` arguments instead of `limit`/`offset`.
387 /// Requires `returns_list = true` and `sql_source` to be set.
388 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
389 pub relay: bool,
390
391 /// Emit a `<name>Count(where): Int!` sibling for this list query (#938).
392 ///
393 /// Opt-in rather than automatic: the count is a second `SELECT COUNT(*)` that
394 /// scans the whole filtered set, which is a real cost on a hot list endpoint
395 /// and is wasted on every list that is not rendered with page numbers.
396 ///
397 /// Requires `returns_list = true` and `sql_source`. Refused on `relay = true`
398 /// — a connection already carries `totalCount`.
399 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
400 pub count: bool,
401
402 /// Server-injected parameters: SQL column name → source expression (e.g. `"jwt:org_id"`).
403 /// Not exposed as GraphQL arguments.
404 ///
405 /// Wire key is `inject_params`, matching the compiled schema. The two names were
406 /// unified because the split is what let three SDKs emit a key that bound to
407 /// nothing (#806); see the `inject_params_serde` module for the full reasoning.
408 #[serde(
409 default,
410 rename = "inject_params",
411 with = "inject_params_serde",
412 skip_serializing_if = "IndexMap::is_empty"
413 )]
414 pub inject: IndexMap<String, String>,
415
416 /// Project `[inject_defaults]` keys this operation opts out of (#1383).
417 ///
418 /// Reviewable next to the operation it applies to: a catalogue query declares here
419 /// that the tenant default does not apply to it. Each key must name a default that
420 /// would otherwise apply, and must not also be declared in `inject_params`.
421 #[serde(default, skip_serializing_if = "Vec::is_empty")]
422 pub exclude_inject_defaults: Vec<String>,
423
424 /// Where this query's reads may be served from (#957).
425 ///
426 /// `any` (the default) follows server policy; `primary` refuses replicas —
427 /// and the result cache — for reads where staleness is a correctness problem;
428 /// `replica` opts out of the read-your-writes pin and may carry its own
429 /// `max_lag_ms`.
430 ///
431 /// FraiseQL defines the shape and enforces it; an authoring language emits
432 /// it — a `@reads_from(...)` directive is one spelling.
433 #[serde(default, skip_serializing_if = "ReadRouting::is_default")]
434 pub read_routing: ReadRouting,
435
436 /// Per-query result cache TTL in seconds. Overrides the global cache TTL for this query.
437 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub cache_ttl_seconds: Option<u64>,
439
440 /// Additional database views this query reads beyond the primary `sql_source`.
441 ///
442 /// Used for correct cache invalidation when a query JOINs or reads multiple views.
443 /// Each entry is validated as a safe SQL identifier at schema compile time.
444 #[serde(default, skip_serializing_if = "Vec::is_empty")]
445 pub additional_views: Vec<String>,
446
447 /// Role required to execute this query and see it in introspection.
448 #[serde(default, skip_serializing_if = "Option::is_none")]
449 pub requires_role: Option<String>,
450
451 /// Actor classes permitted to execute this query — an allow-list (#966).
452 ///
453 /// Empty means unrestricted. Tokens are the `snake_case` `ActorType` names
454 /// (`human_user`, `service_account`, `ai_agent`, `system_job`); an
455 /// unrecognised one is a compile error rather than a silently-ignored
456 /// restriction, which is the difference between a typo and an open operation.
457 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub requires_actor: Vec<String>,
459
460 /// Relay cursor column type: `"uuid"` for UUID PKs, `"int64"` (or absent) for bigint PKs.
461 /// Only meaningful when `relay = true`.
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub relay_cursor_type: Option<String>,
464
465 /// REST route override for this query. See [`IntermediateRest`].
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub rest: Option<IntermediateRest>,
468
469 /// Allow this query to be exported as a stream over REST (#958).
470 ///
471 /// Separate from [`rest`](Self::rest) rather than a key inside it, because
472 /// that block's `path` is mandatory: nesting the flag there would make every
473 /// author who wants an export also override the route.
474 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
475 pub rest_stream: bool,
476
477 /// Override the order this query's `LIMIT`/`OFFSET` pages are cut in (#1303).
478 ///
479 /// Two spellings, and absence is the third:
480 ///
481 /// * a **column name** — order pages by this column, which must be unique over `sql_source` or
482 /// the pages still overlap;
483 /// * the literal **`"none"`** — emit no default ordering. This is how a view that carries its
484 /// own `ORDER BY` keeps it: the compiler cannot read a view's body, so the opt-out is
485 /// declared rather than inferred, and declaring it also silences the compile warning about
486 /// non-deterministic pages, because the author answered it.
487 /// * **absent** — the compiler derives the entity identity, which is what almost every query
488 /// wants.
489 ///
490 /// `"none"` is therefore not usable as a column name here. A relation column
491 /// actually called `none` must be ordered by through the SQL view instead.
492 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub pagination_order: Option<String>,
494}
495
496/// Mutation definition in intermediate format
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(deny_unknown_fields)]
499pub struct IntermediateMutation {
500 /// Mutation name (e.g., "createUser")
501 pub name: String,
502
503 /// Return type name (e.g., "User")
504 pub return_type: String,
505
506 /// Returns a list?
507 #[serde(default)]
508 pub returns_list: bool,
509
510 /// Result is nullable?
511 #[serde(default)]
512 pub nullable: bool,
513
514 /// Mutation arguments
515 #[serde(default)]
516 pub arguments: Vec<IntermediateArgument>,
517
518 /// Mutation description (from docstring)
519 #[serde(skip_serializing_if = "Option::is_none")]
520 pub description: Option<String>,
521
522 /// SQL source (function name)
523 #[serde(skip_serializing_if = "Option::is_none")]
524 pub sql_source: Option<String>,
525
526 /// Operation type (CREATE, UPDATE, DELETE, CUSTOM)
527 #[serde(skip_serializing_if = "Option::is_none")]
528 pub operation: Option<String>,
529
530 /// Deprecation info (from @deprecated directive)
531 #[serde(skip_serializing_if = "Option::is_none")]
532 pub deprecated: Option<IntermediateDeprecation>,
533
534 /// Server-injected parameters: SQL parameter name → source expression (e.g. `"jwt:org_id"`).
535 /// Not exposed as GraphQL arguments.
536 ///
537 /// Wire key is `inject_params`, matching the compiled schema. The two names were
538 /// unified because the split is what let three SDKs emit a key that bound to
539 /// nothing (#806); see the `inject_params_serde` module for the full reasoning.
540 #[serde(
541 default,
542 rename = "inject_params",
543 with = "inject_params_serde",
544 skip_serializing_if = "IndexMap::is_empty"
545 )]
546 pub inject: IndexMap<String, String>,
547
548 /// Project `[inject_defaults]` keys this operation opts out of (#1383).
549 ///
550 /// Reviewable next to the operation it applies to: a catalogue query declares here
551 /// that the tenant default does not apply to it. Each key must name a default that
552 /// would otherwise apply, and must not also be declared in `inject_params`.
553 #[serde(default, skip_serializing_if = "Vec::is_empty")]
554 pub exclude_inject_defaults: Vec<String>,
555
556 /// Role required to execute this mutation and see it in introspection.
557 ///
558 /// Mirrors [`IntermediateQuery::requires_role`]. Enforced at runtime with the same
559 /// enumeration-hiding "not found" response as the query gate — a principal lacking
560 /// the role cannot distinguish a forbidden mutation from a nonexistent one.
561 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub requires_role: Option<String>,
563
564 /// Actor classes permitted to execute this mutation — an allow-list (#966).
565 ///
566 /// Mirrors [`IntermediateQuery::requires_actor`].
567 #[serde(default, skip_serializing_if = "Vec::is_empty")]
568 pub requires_actor: Vec<String>,
569
570 /// Fact tables whose version counter should be bumped after this mutation succeeds.
571 ///
572 /// Used for correct invalidation of analytic/aggregate cache entries.
573 #[serde(default, skip_serializing_if = "Vec::is_empty")]
574 pub invalidates_fact_tables: Vec<String>,
575
576 /// View names whose cached query results should be invalidated after this
577 /// mutation succeeds.
578 #[serde(default, skip_serializing_if = "Vec::is_empty")]
579 pub invalidates_views: Vec<String>,
580
581 /// Whether this mutation writes a Change-Spine change-log row (default `true`).
582 ///
583 /// Set `false` (via the authoring SDK's `@fraiseql.mutation(changelog=False)`)
584 /// to opt a single mutation out of the in-transaction change-log outbox while
585 /// the rest of the schema keeps logging. Defaults to `true` so a schema
586 /// authored before this field existed keeps logging.
587 #[serde(default = "default_changelog")]
588 pub changelog: bool,
589
590 /// How the GraphQL `input` argument is passed to the SQL function:
591 /// `"flatten"` (positional columns, the default) or `"jsonb"` (the whole
592 /// input as one `jsonb` arg). See [`InputStyle`].
593 ///
594 /// Orthogonal to [`operation`](IntermediateMutation::operation): set
595 /// `"jsonb"` (via the authoring SDK's
596 /// `@fraiseql.mutation(input_style="jsonb")`) so a backend using the
597 /// single-`jsonb`-wrapper convention can register the real DML verb and
598 /// still receive the whole input as one argument. Defaults to `"flatten"`,
599 /// byte-identical to a schema authored before this field existed.
600 #[serde(default, skip_serializing_if = "InputStyle::is_flatten")]
601 pub input_style: InputStyle,
602
603 /// Whether a successful, state-changing run of this mutation also records the
604 /// changed entity's pre-image (before-state) into the Change-Spine
605 /// `object_data_before` column, sourced from an optional `entity_before` on the
606 /// mutation's `app.mutation_response`.
607 ///
608 /// Set `true` (via the authoring SDK's
609 /// `@fraiseql.mutation(changelog_pre_image=True)` or TOML
610 /// `changelog_pre_image = true`) to opt an audit-sensitive mutation into an
611 /// inline Debezium-style `{before, after}`. Defaults to `false`,
612 /// byte-identical to a schema authored before this field existed.
613 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
614 pub changelog_pre_image: bool,
615
616 /// Whether this mutation exposes the typed graphql-cascade `cascade` field
617 /// on its success payload.
618 ///
619 /// Set `true` via the authoring SDK's `@fraiseql.type(crud=True,
620 /// cascade=True)` / `@fraiseql.mutation(cascade=True)`, or TOML
621 /// `cascade = true`. Defaults to `false`, byte-identical to a schema
622 /// authored before this field existed. Before this field, the compiler
623 /// silently dropped the SDK flag.
624 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
625 pub cascade: bool,
626
627 /// Typed facts about the operation, served on the cascade payload (#1397): the
628 /// function returns them in its response row's `result jsonb` column. Only on a `cascade`
629 /// mutation, only leaf types (a scalar or an enum).
630 #[serde(default, skip_serializing_if = "Vec::is_empty")]
631 pub success_fields: Vec<super::IntermediateField>,
632
633 /// Where the cascade comes from (#1391): `"function"` (the default) or `"pg_tviews"`,
634 /// which also merges the TVIEW rows `pg_tviews` reports the transaction changed. Only on a
635 /// `cascade` mutation.
636 #[serde(
637 default,
638 skip_serializing_if = "fraiseql_core::schema::CascadeSource::is_function"
639 )]
640 pub cascade_source: fraiseql_core::schema::CascadeSource,
641
642 /// REST route override for this mutation. See [`IntermediateRest`].
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub rest: Option<IntermediateRest>,
645}
646
647impl Default for IntermediateMutation {
648 /// Hand-written so `changelog` defaults to `true` (matching the serde default),
649 /// not `bool`'s `false`. A `#[derive(Default)]` would silently opt
650 /// `..Default::default()`-built mutations out of the change log.
651 fn default() -> Self {
652 Self {
653 name: String::new(),
654 return_type: String::new(),
655 returns_list: false,
656 nullable: false,
657 arguments: Vec::new(),
658 description: None,
659 sql_source: None,
660 operation: None,
661 deprecated: None,
662 inject: IndexMap::new(),
663 exclude_inject_defaults: Vec::new(),
664 requires_actor: Vec::new(),
665 requires_role: None,
666 invalidates_fact_tables: Vec::new(),
667 invalidates_views: Vec::new(),
668 changelog: default_changelog(),
669 input_style: InputStyle::Flatten,
670 changelog_pre_image: false,
671 cascade: false,
672 success_fields: Vec::new(),
673 cascade_source: fraiseql_core::schema::CascadeSource::Function,
674 rest: None,
675 }
676 }
677}
678
679/// Serde default for [`IntermediateMutation::changelog`]: log by default (opt-out).
680const fn default_changelog() -> bool {
681 true
682}
683
684/// Auto-params configuration in intermediate format.
685///
686/// Each field is `Option<bool>`: `None` means "not specified — inherit from
687/// `[query_defaults]`"; `Some(v)` means explicitly set by the authoring-language decorator.
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(deny_unknown_fields)]
690pub struct IntermediateAutoParams {
691 /// Enable automatic limit parameter (None = inherit from query_defaults)
692 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub limit: Option<bool>,
694 /// Enable automatic offset parameter (None = inherit from query_defaults)
695 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub offset: Option<bool>,
697 /// Enable automatic where clause parameter (None = inherit from query_defaults)
698 #[serde(rename = "where", default, skip_serializing_if = "Option::is_none")]
699 pub where_clause: Option<bool>,
700 /// Enable automatic order_by parameter (None = inherit from query_defaults)
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 pub order_by: Option<bool>,
703}
704
705/// Global auto-param defaults for list queries (injected from TOML by the merger).
706///
707/// Never present in `schema.json` — set only at compile time via `[query_defaults]`
708/// in `fraiseql.toml`.
709///
710/// The `Default` implementation returns all-`true`, matching the historical behaviour
711/// when no `[query_defaults]` section is present in TOML.
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713#[serde(deny_unknown_fields)]
714pub struct IntermediateQueryDefaults {
715 /// Default for `where` parameter
716 pub where_clause: bool,
717 /// Default for `order_by` parameter
718 pub order_by: bool,
719 /// Default for `limit` parameter
720 pub limit: bool,
721 /// Default for `offset` parameter
722 pub offset: bool,
723 /// Deployment-wide posture on the order paginated pages are cut in (#1303).
724 pub pagination_order: crate::config::toml_schema::PaginationPosture,
725}
726
727impl Default for IntermediateQueryDefaults {
728 fn default() -> Self {
729 Self {
730 where_clause: true,
731 order_by: true,
732 limit: true,
733 offset: true,
734 pagination_order: crate::config::toml_schema::PaginationPosture::Identity,
735 }
736 }
737}