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}