Skip to main content

fraiseql_core/schema/compiled/
schema_domain.rs

1//! Domain-specific methods for [`CompiledSchema`].
2//!
3//! Fact table management, observers, federation metadata, security configuration,
4//! RLS, role scopes, tenancy, SDL generation, and schema validation.
5
6#[cfg(feature = "federation")]
7use std::collections::HashMap;
8use std::fmt::Write as _;
9
10use super::schema::{CURRENT_FRAISEQL_VERSION, CompiledSchema};
11use crate::{
12    compiler::fact_table::FactTableMetadata,
13    schema::{
14        observer_types::ObserverDefinition,
15        security_config::{RoleDefinition, SecurityConfig},
16    },
17};
18
19impl CompiledSchema {
20    /// Refuse a compiled schema this build did not produce (#1304).
21    ///
22    /// The compiled schema is a build artifact of the fraiseql release that
23    /// wrote it, so the only artifact whose meaning this runtime can vouch for
24    /// is its own build's. The check is an equality, deliberately: a runtime
25    /// that decided whether another build's artifact were close enough would be
26    /// making the judgement #1303 got wrong, silently, on the operator's behalf.
27    ///
28    /// An artifact carrying no stamp is refused too. It is the *older* case —
29    /// nothing this project has shipped since v2.1 omits the field — and
30    /// accepting it, as the format-version check it replaces did, left the
31    /// guard admitting the stalest artifacts while refusing merely-stale ones.
32    ///
33    /// # Errors
34    ///
35    /// Returns an operator-facing message naming both builds and the recompile,
36    /// unless the artifact was produced by this one.
37    pub fn validate_producer_version(&self) -> Result<(), String> {
38        match self.fraiseql_version.get() {
39            Some(v) if v == CURRENT_FRAISEQL_VERSION => Ok(()),
40            Some(v) => Err(format!(
41                "Compiled schema was produced by fraiseql {v}, but this runtime is fraiseql \
42                 {CURRENT_FRAISEQL_VERSION}. A compiled schema is a build artifact of the \
43                 release that produced it: this runtime reads fields {v} never wrote, and \
44                 reads their absence as a setting rather than as a gap. Recompile the schema \
45                 with fraiseql-cli {CURRENT_FRAISEQL_VERSION}."
46            )),
47            None => Err(format!(
48                "Compiled schema carries no `fraiseql_version` stamp, so the build that \
49                 produced it cannot be established; this runtime is fraiseql \
50                 {CURRENT_FRAISEQL_VERSION}. The stamp was introduced in fraiseql 2.15.0, so \
51                 an unstamped artifact was written by an earlier release or by hand — either \
52                 way this runtime cannot vouch for what its fields, and its missing fields, \
53                 mean here. Recompile it with fraiseql-cli {CURRENT_FRAISEQL_VERSION}."
54            )),
55        }
56    }
57
58    /// Register fact table metadata.
59    ///
60    /// # Arguments
61    ///
62    /// * `table_name` - Fact table name (e.g., `tf_sales`)
63    /// * `metadata` - Typed `FactTableMetadata`
64    pub fn add_fact_table(&mut self, table_name: String, metadata: FactTableMetadata) {
65        self.fact_tables.insert(table_name, metadata);
66    }
67
68    /// Get fact table metadata by name.
69    ///
70    /// # Arguments
71    ///
72    /// * `name` - Fact table name
73    ///
74    /// # Returns
75    ///
76    /// Fact table metadata if found
77    #[must_use]
78    pub fn get_fact_table(&self, name: &str) -> Option<&FactTableMetadata> {
79        self.fact_tables.get(name)
80    }
81
82    /// List all fact table names.
83    ///
84    /// # Returns
85    ///
86    /// Vector of fact table names
87    #[must_use]
88    pub fn list_fact_tables(&self) -> Vec<&str> {
89        self.fact_tables.keys().map(String::as_str).collect()
90    }
91
92    /// Check if schema contains any fact tables.
93    #[must_use]
94    pub fn has_fact_tables(&self) -> bool {
95        !self.fact_tables.is_empty()
96    }
97
98    /// Find an observer definition by name.
99    #[must_use]
100    pub fn find_observer(&self, name: &str) -> Option<&ObserverDefinition> {
101        self.observers.iter().find(|o| o.name == name)
102    }
103
104    /// Get all observers for a specific entity type.
105    #[must_use]
106    pub fn find_observers_for_entity(&self, entity: &str) -> Vec<&ObserverDefinition> {
107        self.observers.iter().filter(|o| o.entity == entity).collect()
108    }
109
110    /// Get all observers for a specific event type (INSERT, UPDATE, DELETE).
111    #[must_use]
112    pub fn find_observers_for_event(&self, event: &str) -> Vec<&ObserverDefinition> {
113        self.observers.iter().filter(|o| o.event == event).collect()
114    }
115
116    /// Check if schema contains any observers.
117    #[must_use]
118    pub const fn has_observers(&self) -> bool {
119        !self.observers.is_empty()
120    }
121
122    /// Get total number of observers.
123    #[must_use]
124    pub const fn observer_count(&self) -> usize {
125        self.observers.len()
126    }
127
128    /// Get federation metadata from schema.
129    ///
130    /// # Returns
131    ///
132    /// Federation metadata if configured in schema
133    #[cfg(feature = "federation")]
134    #[must_use]
135    pub fn federation_metadata(&self) -> Option<crate::federation::FederationMetadata> {
136        self.federation.as_ref().filter(|fed| fed.enabled).map(|fed| {
137            use crate::federation::types::{
138                FederatedType, FieldFederationDirectives, KeyDirective,
139            };
140
141            // Entities carry an `@key` (and, for an extended entity, `extend type` +
142            // `@external` on the borrowed key/fields). Per-field directives are
143            // rebuilt from the entity's `external_fields` / `shareable_fields` so the
144            // SDL renderer can append `@external` / `@shareable` to each field line.
145            let mut types: Vec<FederatedType> = fed
146                .entities
147                .iter()
148                .map(|e| {
149                    let mut field_directives: HashMap<String, FieldFederationDirectives> =
150                        HashMap::new();
151                    for f in &e.external_fields {
152                        field_directives.entry(f.clone()).or_default().external = true;
153                    }
154                    for f in &e.shareable_fields {
155                        field_directives.entry(f.clone()).or_default().shareable = true;
156                    }
157                    FederatedType {
158                        name: e.name.clone(),
159                        keys: vec![KeyDirective {
160                            fields:     e.key_fields.clone(),
161                            resolvable: true,
162                        }],
163                        is_extends: e.extends,
164                        external_fields: e.external_fields.clone(),
165                        shareable_fields: e.shareable_fields.clone(),
166                        inaccessible_fields: Vec::new(),
167                        field_directives,
168                        type_shareable: false,
169                    }
170                })
171                .collect();
172
173            // Non-entity `@shareable` value types (e.g. a shared `MutationError`):
174            // no `@key`, never a member of the `_Entity` union — they only receive a
175            // type-level `@shareable` so both subgraphs can define the identical type
176            // without an `INVALID_FIELD_SHARING` composition error.
177            for name in &fed.shareable_types {
178                types.push(FederatedType {
179                    name:                name.clone(),
180                    keys:                Vec::new(),
181                    is_extends:          false,
182                    external_fields:     Vec::new(),
183                    shareable_fields:    Vec::new(),
184                    inaccessible_fields: Vec::new(),
185                    field_directives:    HashMap::new(),
186                    type_shareable:      true,
187                });
188            }
189
190            crate::federation::FederationMetadata {
191                enabled: fed.enabled,
192                version: fed.version.clone().unwrap_or_else(|| "v2".to_string()),
193                types,
194            }
195        })
196    }
197
198    /// Build the per-entity-type backing source map (`typename` →
199    /// [`EntitySource`](crate::federation::EntitySource)) the federation
200    /// `_entities` resolver reads from instead of guessing `lower(typename)`
201    /// (#504/#507).
202    ///
203    /// Two sources, query-wins:
204    ///
205    /// 1. **Query-sourced** (owned entities): the backing relation rides on the root query that
206    ///    returns the type, keyed by `return_type`, first-wins — the same query→type binding the
207    ///    Relay `node` path uses. The query's `jsonb_column` (which the compiler defaults to
208    ///    `"data"`) drives jsonb projection.
209    /// 2. **Type-sourced fallback** (#507): an owner-split `extend type … @key` entity resolved in
210    ///    a subgraph that does not own it exposes no root query, so there is nothing in (1) to
211    ///    source its relation from. Its relation instead rides on the type-level `sql_source` the
212    ///    compiler carries from the authoring SDK. This only fills gaps — a query-sourced entry
213    ///    always wins.
214    ///
215    /// Both sources read the entity's `jsonb_column` the same way: a non-empty column selects
216    /// jsonb-projection mode (`<col>->'<field>'`), an empty one selects flat-column mode (bare
217    /// columns). The compiler defaults both a query's and an extends type's `jsonb_column` to the
218    /// standard `"data"` view shape, so a flat-column entity must be authored with an explicit
219    /// empty `jsonb_column`.
220    #[cfg(feature = "federation")]
221    #[must_use]
222    pub fn entity_sources(&self) -> HashMap<String, crate::federation::EntitySource> {
223        use crate::federation::EntitySource;
224
225        let mut sources: HashMap<String, EntitySource> = HashMap::new();
226
227        // (1) Query-sourced — owned entities. First-wins per return_type.
228        for q in &self.queries {
229            if let Some(relation) = &q.sql_source {
230                sources.entry(q.return_type.clone()).or_insert_with(|| EntitySource {
231                    relation:     relation.clone(),
232                    jsonb_column: (!q.jsonb_column.is_empty()).then(|| q.jsonb_column.clone()),
233                });
234            }
235        }
236
237        // (2) Type-sourced fallback — owner-split `extend type` entities (#507).
238        // Skipped for owned types (their type-level sql_source is empty) and never
239        // overrides a query-sourced entry. The empty-jsonb-column → flat-mode rule
240        // mirrors the query path above, so flat-column extends entities resolve too.
241        for t in &self.types {
242            if t.sql_source.as_str().is_empty() {
243                continue;
244            }
245            sources.entry(t.name.to_string()).or_insert_with(|| EntitySource {
246                relation:     t.sql_source.to_string(),
247                jsonb_column: (!t.jsonb_column.is_empty()).then(|| t.jsonb_column.clone()),
248            });
249        }
250
251        sources
252    }
253
254    /// The SDL a federated subgraph serves as `_service { sdl }`: Apollo Federation v2
255    /// directives over the schema's types (#1427).
256    ///
257    /// The executor's `_service` arm and `fraiseql federation sdl` both call this, so what
258    /// a server serves and what the CLI prints from the same artifact cannot differ.
259    ///
260    /// # Errors
261    ///
262    /// [`FraiseQLError::Validation`](crate::error::FraiseQLError::Validation) when the
263    /// schema has no enabled `[federation]` section.
264    #[cfg(feature = "federation")]
265    pub fn federation_service_sdl(&self) -> crate::error::Result<String> {
266        let metadata =
267            self.federation_metadata()
268                .ok_or_else(|| crate::error::FraiseQLError::Validation {
269                    message: "federation is not enabled in this schema: set `[federation] enabled \
270                          = true` and recompile"
271                        .to_string(),
272                    path:    None,
273                })?;
274        Ok(crate::federation::generate_service_sdl(&self.raw_schema(), &metadata))
275    }
276
277    /// Stub federation metadata when federation feature is disabled.
278    #[cfg(not(feature = "federation"))]
279    #[must_use]
280    pub const fn federation_metadata(&self) -> Option<()> {
281        None
282    }
283
284    /// Get security configuration from schema.
285    ///
286    /// # Returns
287    ///
288    /// Security configuration if present (includes role definitions)
289    #[must_use]
290    pub const fn security_config(&self) -> Option<&SecurityConfig> {
291        self.security.as_ref()
292    }
293
294    /// Returns `true` if this schema declares a multi-tenant deployment.
295    ///
296    /// Multi-tenant schemas require Row-Level Security (RLS) to be active whenever
297    /// query result caching is enabled. Without RLS, all tenants sharing the same
298    /// query parameters would receive the same cached response.
299    ///
300    /// True when `[security] multi_tenant = true` **or** a non-`none`
301    /// `[tenancy] mode`. The second clause is load-bearing: `tenancy.mode` is the
302    /// knob operators actually set, and reading only `security.multi_tenant` — which
303    /// no compile path could produce — left both dependent gates permanently off
304    /// (#758).
305    #[must_use]
306    pub fn is_multi_tenant(&self) -> bool {
307        self.security
308            .as_ref()
309            .is_some_and(|s| s.multi_tenant || s.tenancy.mode != crate::schema::TenancyMode::None)
310    }
311
312    /// Returns the tenancy isolation mode configured for this schema.
313    ///
314    /// Defaults to `TenancyMode::None` when no security or tenancy configuration
315    /// is present, meaning single-tenant operation with no isolation machinery.
316    #[must_use]
317    pub fn tenancy_mode(&self) -> crate::schema::TenancyMode {
318        self.security
319            .as_ref()
320            .map_or(crate::schema::TenancyMode::None, |s| s.tenancy.mode)
321    }
322
323    /// Returns the tenancy configuration, if present.
324    ///
325    /// Returns `None` when no security configuration exists. Returns the
326    /// default `TenancyConfig` (mode=none) when security exists but tenancy
327    /// is not explicitly configured.
328    #[must_use]
329    pub fn tenancy_config(&self) -> Option<&crate::schema::TenancyConfig> {
330        self.security.as_ref().map(|s| &s.tenancy)
331    }
332
333    /// The JWT claim that names a request's tenant: `[fraiseql.tenancy] tenant_claim`,
334    /// or [`DEFAULT_TENANT_CLAIM`](crate::schema::security_config::DEFAULT_TENANT_CLAIM)
335    /// when the schema declares none.
336    ///
337    /// The one source for every reader of "which claim is the tenant" (#1388): the
338    /// compiler bakes it into row-mode `jwt:<claim>` injects, and the server derives
339    /// `SecurityContext::tenant_id` from it.
340    #[must_use]
341    pub fn tenant_claim(&self) -> &str {
342        self.tenancy_config()
343            .map_or(crate::schema::security_config::DEFAULT_TENANT_CLAIM, |t| {
344                t.tenant_claim.as_str()
345            })
346    }
347
348    /// Find a role definition by name.
349    ///
350    /// # Arguments
351    ///
352    /// * `role_name` - Name of the role to find
353    ///
354    /// # Returns
355    ///
356    /// Role definition if found
357    #[must_use]
358    pub fn find_role(&self, role_name: &str) -> Option<RoleDefinition> {
359        self.security.as_ref().and_then(|config| config.find_role(role_name).cloned())
360    }
361
362    /// Get scopes for a role.
363    ///
364    /// # Arguments
365    ///
366    /// * `role_name` - Name of the role
367    ///
368    /// # Returns
369    ///
370    /// Vector of scopes granted to the role
371    #[must_use]
372    pub fn get_role_scopes(&self, role_name: &str) -> Vec<String> {
373        self.security
374            .as_ref()
375            .map(|config| config.get_role_scopes(role_name))
376            .unwrap_or_default()
377    }
378
379    /// Check if a role has a specific scope.
380    ///
381    /// # Arguments
382    ///
383    /// * `role_name` - Name of the role
384    /// * `scope` - Scope to check for
385    ///
386    /// # Returns
387    ///
388    /// true if role has the scope, false otherwise
389    #[must_use]
390    pub fn role_has_scope(&self, role_name: &str, scope: &str) -> bool {
391        self.security
392            .as_ref()
393            .is_some_and(|config| config.role_has_scope(role_name, scope))
394    }
395
396    /// Returns `true` if this schema declares that database Row-Level Security
397    /// isolates its data (`[security.rls] enabled = true`).
398    ///
399    /// Used at server startup to validate that caching is safe for multi-tenant
400    /// deployments, and to decide whether to verify the declaration against the
401    /// live database catalog.
402    ///
403    /// This is a *declaration*, not a proof: FraiseQL does not author RLS policies,
404    /// so nothing in the compiled schema can establish that they exist. That is what
405    /// [`CachedDatabaseAdapter::validate_rls_active`] checks against the real
406    /// database (#762).
407    ///
408    /// It previously counted `security.additional["policies"]` — authorization
409    /// policies, not RLS, and a section #612 made a hard compile error — so it
410    /// answered `false` for every schema any supported workflow could produce
411    /// (#758).
412    ///
413    /// [`CachedDatabaseAdapter::validate_rls_active`]: crate::cache::CachedDatabaseAdapter::validate_rls_active
414    ///
415    /// # Example
416    ///
417    /// ```
418    /// use fraiseql_core::schema::CompiledSchema;
419    ///
420    /// let schema = CompiledSchema::default();
421    /// assert!(!schema.has_rls_configured());
422    /// ```
423    #[must_use]
424    pub fn has_rls_configured(&self) -> bool {
425        self.security.as_ref().is_some_and(|s| s.rls.enabled)
426    }
427
428    /// Get raw GraphQL schema SDL.
429    ///
430    /// # Returns
431    ///
432    /// Raw schema string if available, otherwise generates from type definitions,
433    /// including the root `Query`/`Mutation` types.
434    ///
435    /// Root operations are stored in [`self.queries`](Self::queries) /
436    /// [`self.mutations`](Self::mutations) rather than as `Query`/`Mutation` object
437    /// types in [`self.types`](Self::types), so they are rendered here explicitly.
438    /// Omitting them produces an SDL that advertises no root fields — which makes the
439    /// federation `_service` SDL (built from this output) fail gateway composition
440    /// with `NO_QUERIES`.
441    #[must_use]
442    pub fn raw_schema(&self) -> String {
443        self.schema_sdl.clone().unwrap_or_else(|| {
444            // Generate basic SDL from type definitions if not provided
445            let mut sdl = String::new();
446
447            // Non-built-in scalar declarations. The rendered operations and fields
448            // reference custom and standard-but-non-built-in scalars (`DateTime`,
449            // `JSON`, `Decimal`, rich scalars, …); a gateway composing the subgraph
450            // reports `Unknown type` for any it isn't declared.
451            for name in self.referenced_scalars() {
452                let _ = writeln!(sdl, "scalar {name}");
453            }
454            if !self.enums.is_empty()
455                || !self.interfaces.is_empty()
456                || !self.input_types.is_empty()
457                || !self.unions.is_empty()
458                || !self.types.is_empty()
459            {
460                sdl.push('\n');
461            }
462
463            // Enum types
464            for enum_def in &self.enums {
465                let _ = writeln!(sdl, "enum {} {{", enum_def.name);
466                for value in &enum_def.values {
467                    let _ = writeln!(sdl, "  {}", value.name);
468                }
469                sdl.push_str("}\n\n");
470            }
471
472            // Interface types
473            for iface in &self.interfaces {
474                let _ = writeln!(sdl, "interface {} {{", iface.name);
475                for field in &iface.fields {
476                    let _ = writeln!(sdl, "  {}: {}", field.name, field.field_type);
477                }
478                sdl.push_str("}\n\n");
479            }
480
481            // Input object types (`field_type` is a pre-rendered GraphQL string;
482            // normalise the trailing non-null marker against the `nullable` flag).
483            for input in &self.input_types {
484                let _ = writeln!(sdl, "input {} {{", input.name);
485                for field in &input.fields {
486                    let base = field.field_type.trim_end_matches('!');
487                    let non_null = if field.nullable { "" } else { "!" };
488                    let _ = writeln!(sdl, "  {}: {base}{non_null}", field.name);
489                }
490                sdl.push_str("}\n\n");
491            }
492
493            // Union types (covers synthesized mutation result unions)
494            for union_def in &self.unions {
495                let _ = writeln!(
496                    sdl,
497                    "union {} = {}",
498                    union_def.name,
499                    union_def.member_types.join(" | ")
500                );
501            }
502            if !self.unions.is_empty() {
503                sdl.push('\n');
504            }
505
506            // Add output/object types
507            for type_def in &self.types {
508                let _ = writeln!(sdl, "type {} {{", type_def.name);
509                for field in &type_def.fields {
510                    let _ = writeln!(sdl, "  {}: {}", field.name, field.field_type);
511                }
512                sdl.push_str("}\n\n");
513            }
514
515            // Root Query type (rendered from `self.queries`, never present in `types`)
516            if !self.queries.is_empty() {
517                sdl.push_str("type Query {\n");
518                for q in &self.queries {
519                    let _ = writeln!(
520                        sdl,
521                        "  {}",
522                        render_operation_field(
523                            &q.name,
524                            &q.graphql_arguments(self),
525                            &q.return_type,
526                            q.returns_list,
527                            q.nullable,
528                        )
529                    );
530                }
531                sdl.push_str("}\n\n");
532            }
533
534            // Root Mutation type (rendered from `self.mutations`). Mutation payloads
535            // are single, non-null values, so they render as `Name(args): Return!`.
536            if !self.mutations.is_empty() {
537                sdl.push_str("type Mutation {\n");
538                for m in &self.mutations {
539                    let _ = writeln!(
540                        sdl,
541                        "  {}",
542                        render_operation_field(&m.name, &m.arguments, &m.return_type, false, false)
543                    );
544                }
545                sdl.push_str("}\n\n");
546            }
547
548            sdl
549        })
550    }
551
552    /// Collect the non-built-in scalar type names the schema references, so
553    /// [`raw_schema`](Self::raw_schema) can declare each one (`scalar Name`) and the
554    /// SDL is type-complete.
555    ///
556    /// A referenced type is treated as a scalar to declare when it is neither a
557    /// built-in GraphQL scalar nor a type the schema defines as an object, enum,
558    /// input, interface, or union. Names are collected **exactly as the fields render
559    /// them** (the verbatim leaf of each field/argument type and each operation return
560    /// type) so the declaration and the reference always agree — declaring a canonical
561    /// alias (`DateTime`) while a field renders `datetime` would leave the reference
562    /// dangling (`Unknown type datetime`). The custom-scalar registry is also included.
563    /// The federation `_Any`/`_Entity`/`_Service`/`_FieldSet` built-ins (supplied by the
564    /// federation layer) are excluded.
565    pub(crate) fn referenced_scalars(&self) -> Vec<String> {
566        use std::collections::{BTreeSet, HashSet};
567
568        const BUILTINS: [&str; 5] = ["String", "Int", "Float", "Boolean", "ID"];
569        const FED_BUILTINS: [&str; 4] = ["_Any", "_Entity", "_Service", "_FieldSet"];
570
571        // Names the schema defines as composite types — never re-declared as scalars.
572        let mut defined: HashSet<&str> = HashSet::new();
573        for t in &self.types {
574            defined.insert(t.name.as_str());
575        }
576        for e in &self.enums {
577            defined.insert(e.name.as_str());
578        }
579        for i in &self.input_types {
580            defined.insert(i.name.as_str());
581        }
582        for i in &self.interfaces {
583            defined.insert(i.name.as_str());
584        }
585        for u in &self.unions {
586            defined.insert(u.name.as_str());
587        }
588
589        // Every type reference, collected as the verbatim leaf name fields render.
590        let mut referenced: BTreeSet<String> = BTreeSet::new();
591        let add = |rendered: &str, set: &mut BTreeSet<String>| {
592            let leaf = leaf_type_name(rendered);
593            if !leaf.is_empty() {
594                set.insert(leaf);
595            }
596        };
597        for type_def in &self.types {
598            for field in &type_def.fields {
599                add(&field.field_type.to_string(), &mut referenced);
600            }
601        }
602        for iface in &self.interfaces {
603            for field in &iface.fields {
604                add(&field.field_type.to_string(), &mut referenced);
605            }
606        }
607        for query in &self.queries {
608            // Walk the *rendered* arguments so any scalar synthesized for an
609            // `auto_params` query (notably `JSON` for `where`/`orderBy`) is
610            // declared — `render_operation_field` renders this same list.
611            for arg in &query.graphql_arguments(self) {
612                add(&arg.arg_type.to_string(), &mut referenced);
613            }
614            add(&query.return_type, &mut referenced);
615        }
616        for mutation in &self.mutations {
617            for arg in &mutation.arguments {
618                add(&arg.arg_type.to_string(), &mut referenced);
619            }
620            add(&mutation.return_type, &mut referenced);
621        }
622        for input in &self.input_types {
623            for field in &input.fields {
624                add(&field.field_type, &mut referenced);
625            }
626        }
627        for (name, _) in self.custom_scalars.list_all() {
628            referenced.insert(name);
629        }
630
631        referenced
632            .into_iter()
633            .filter(|name| {
634                !defined.contains(name.as_str())
635                    && !BUILTINS.contains(&name.as_str())
636                    && !FED_BUILTINS.contains(&name.as_str())
637            })
638            .collect()
639    }
640}
641
642/// Render a root operation as a GraphQL SDL field: `name(arg: T!, …): Return`.
643///
644/// `return_type` is a bare type name; list-ness and nullability are applied here so
645/// the rendered signature matches GraphQL conventions (`[User!]!`, `User`, `User!`).
646fn render_operation_field(
647    name: &str,
648    arguments: &[crate::schema::ArgumentDefinition],
649    return_type: &str,
650    returns_list: bool,
651    nullable: bool,
652) -> String {
653    let non_null = if nullable { "" } else { "!" };
654    let ret = if returns_list {
655        format!("[{return_type}!]{non_null}")
656    } else {
657        format!("{return_type}{non_null}")
658    };
659    if arguments.is_empty() {
660        return format!("{name}: {ret}");
661    }
662    let args = arguments
663        .iter()
664        .map(|a| format!("{}: {}{}", a.name, a.arg_type, if a.nullable { "" } else { "!" }))
665        .collect::<Vec<_>>()
666        .join(", ");
667    format!("{name}({args}): {ret}")
668}
669
670/// Strip GraphQL list and non-null markers from a rendered type string, leaving the
671/// bare leaf type name: `[User!]!` → `User`, `datetime` → `datetime`.
672fn leaf_type_name(rendered: &str) -> String {
673    rendered
674        .chars()
675        .filter(|c| !matches!(c, '[' | ']' | '!'))
676        .collect::<String>()
677        .trim()
678        .to_string()
679}