Skip to main content

fraiseql_cli/schema/converter/
mod.rs

1//! Schema Converter
2//!
3//! Converts `IntermediateSchema` (language-agnostic) to `CompiledSchema` (Rust-specific)
4
5mod cascade_types;
6mod directives;
7pub mod functions;
8mod identity;
9mod interface_conformance;
10mod mutation_error_union;
11mod mutations;
12mod queries;
13mod relay;
14mod subscriptions;
15pub(crate) mod tenancy;
16mod types;
17
18#[cfg(test)]
19mod tests;
20
21use std::collections::HashSet;
22
23use anyhow::{Context, Result};
24use fraiseql_core::{
25    compiler::fact_table::{
26        DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
27    },
28    schema::{CompiledSchema, FieldType},
29    validation::CustomTypeRegistry,
30};
31use fraiseql_functions::FunctionsConfig;
32use tracing::{info, warn};
33
34use super::{
35    artifact::CompiledArtifact,
36    intermediate::{IntermediateFactTable, IntermediateInjectDefaults, IntermediateSchema},
37};
38
39/// The directory a project's function modules live in when nothing says otherwise.
40///
41/// The default lives here, in the compiler, rather than on the wire type: every
42/// compiled artifact carries an explicit `module_dir`, so a *hand-written*
43/// `schema.compiled.json` that omits it still fails loudly instead of quietly
44/// resolving somewhere its author did not choose.
45const DEFAULT_FUNCTION_MODULE_DIR: &str = "functions";
46
47/// Converts intermediate format to compiled format
48pub struct SchemaConverter;
49
50/// The author-declared enum, interface, union and custom-scalar names of one schema
51/// (`#923`, extended by `#1018`).
52///
53/// `parse_field_type` matched the twelve builtin scalar names and routed everything else
54/// to `FieldType::Object`, because it is a free function with no view of the document it
55/// is parsing a name from. So `status: OrderStatus`, where the same file declares
56/// `enum OrderStatus`, compiled to `{"Object": "OrderStatus"}` — and
57/// `FieldType::Enum`/`Interface`/`Union` had **no producer at all** for authored schemas;
58/// their only writers were the compiler's own synthesized cascade types.
59///
60/// Everything downstream branches on the variant:
61///
62/// * introspection (`field_resolver`) reports `TypeKind::Object` for an enum-typed field, so an
63///   introspection-driven client (Apollo, Relay, graphql-codegen) is told a scalar enum is an
64///   object with no fields;
65/// * the TypeScript client generator treats `Object` as requiring a nested selection and emits
66///   `status { … }` for a scalar value;
67/// * `--emit-ddl` maps `Object` to `JSONB` rather than to the enum's own Postgres type.
68///
69/// `#1018` is the same defect one variant over: a name declared in `custom_scalars` also
70/// fell through to `Object`, so `FieldType::Scalar` had no producer either.
71///
72/// Held by name rather than by definition because that is all resolution needs, and
73/// because it must be built *before* the type arrays are consumed by conversion.
74#[derive(Debug, Default)]
75pub(crate) struct DeclaredTypeNames {
76    enums:      HashSet<String>,
77    interfaces: HashSet<String>,
78    unions:     HashSet<String>,
79    scalars:    HashSet<String>,
80}
81
82impl DeclaredTypeNames {
83    /// Collect the names an intermediate schema declares.
84    fn collect(schema: &IntermediateSchema) -> Self {
85        Self {
86            enums:      schema.enums.iter().map(|e| e.name.clone()).collect(),
87            interfaces: schema.interfaces.iter().map(|i| i.name.clone()).collect(),
88            unions:     schema.unions.iter().map(|u| u.name.clone()).collect(),
89            scalars:    schema.custom_scalars.iter().flatten().map(|s| s.name.clone()).collect(),
90        }
91    }
92
93    /// Resolve a non-builtin type name to the variant the rest of the codebase branches on.
94    ///
95    /// Falls back to `FieldType::Object` for a name declared nowhere. That fallback is
96    /// deliberate and is **not** the silent drop this fixes: `SchemaValidator` already
97    /// reports an undeclared field type by name, and #724 chose a warning there rather than
98    /// an error because a `--schema-dir` author can legitimately declare a custom scalar in
99    /// a file this pass cannot see. Reversing that decision belongs with #724's reasoning,
100    /// not here.
101    ///
102    /// A name declared in `custom_scalars` resolves to `FieldType::Scalar` (#1018). Without
103    /// it that variant had no producer for an authored schema — the #923 defect, one variant
104    /// over — and three consumers branched on the wrong answer: `--emit-ddl` emitted `JSONB`
105    /// where `TEXT` is correct, introspection reported `OBJECT` for a leaf, and the
106    /// TypeScript generator emitted a sub-selection for a scalar.
107    fn resolve(&self, name: &str) -> FieldType {
108        let owned = || name.to_string();
109        if self.enums.contains(name) {
110            FieldType::Enum(owned())
111        } else if self.interfaces.contains(name) {
112            FieldType::Interface(owned())
113        } else if self.unions.contains(name) {
114            FieldType::Union(owned())
115        } else if self.scalars.contains(name) {
116            FieldType::Scalar(owned())
117        } else {
118            FieldType::Object(owned())
119        }
120    }
121}
122
123/// Optional, default-off behaviours for [`SchemaConverter::convert_with_options`].
124///
125/// Threaded here rather than on [`IntermediateSchema`] so adding an option never
126/// breaks the many full-literal `IntermediateSchema { .. }` constructions, and
127/// the plain [`SchemaConverter::convert`] keeps its historical signature.
128#[derive(Debug, Clone, Default)]
129pub struct ConvertOptions {
130    /// Auto-synthesize a shared `MutationError` type + per-mutation result unions
131    /// and rewrite object-returning mutations to those unions (`[fraiseql.mutations]
132    /// auto_error_union`).
133    pub auto_error_union: bool,
134}
135
136impl SchemaConverter {
137    /// Convert `IntermediateSchema` to `CompiledSchema` with default options.
138    ///
139    /// # Errors
140    ///
141    /// See [`SchemaConverter::convert_with_options`].
142    pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
143        Self::convert_with_options(intermediate, &ConvertOptions::default())
144    }
145
146    /// Convert `IntermediateSchema` into the whole compiled **artifact** — the
147    /// schema plus the platform sections written beside it (#1325).
148    ///
149    /// [`convert_with_options`](Self::convert_with_options) produces only the
150    /// `CompiledSchema`, which is what almost every caller wants. The sections that
151    /// are *siblings* of it in `schema.compiled.json` — currently `functions` — are
152    /// assembled here, so there is exactly one place where an authored section
153    /// becomes a compiled one and exactly one place a probe has to look.
154    ///
155    /// The `[functions]` TOML settings (`module_dir`, `dlq_store`) are paired with
156    /// the authored definitions here; the two halves have one owner each.
157    ///
158    /// # Errors
159    ///
160    /// See [`convert_with_options`](Self::convert_with_options).
161    pub fn convert_artifact(
162        mut intermediate: IntermediateSchema,
163        options: &ConvertOptions,
164    ) -> Result<CompiledArtifact> {
165        let functions = Self::take_functions_section(&mut intermediate)?;
166        let schema = Self::convert_with_options(intermediate, options)?;
167        // Unconditional, including when the project declares no function at all:
168        // a query whose `function = "<name>"` names nothing is exactly the case an
169        // `if let Some(functions)` here would wave through, and it is the one an
170        // author hits by deleting a function and forgetting the query (#1329).
171        functions::validate_against_schema(functions.as_ref(), &schema)?;
172        Ok(CompiledArtifact { schema, functions })
173    }
174
175    /// Pair the authored function declarations with their `[functions]` settings.
176    ///
177    /// Returns `None` when the project declares no functions, so a schema that has
178    /// never heard of them serialises exactly as it did before.
179    ///
180    /// # Errors
181    ///
182    /// Refuses a `[functions]` table configured for functions that are not declared.
183    /// There is nothing to apply the settings to, so carrying them would write a
184    /// section the server reads and cannot act on, and dropping them would be the
185    /// silent-default failure this whole seam exists to remove (#1008 refuses a
186    /// `storage` section on the same grounds).
187    fn take_functions_section(
188        intermediate: &mut IntermediateSchema,
189    ) -> Result<Option<FunctionsConfig>> {
190        let settings = intermediate.functions_config.take().unwrap_or_default();
191        // An empty list is absence, not an empty section, and two producers reach this.
192        // Some SDKs always emit the key and some omit it, so without this rule the same
193        // project compiled through two of them yields different artifacts. More
194        // importantly `seam::empty_accumulator` seeds EVERY array section with `[]`, so
195        // every TOML-workflow compile arrives here with an empty list whether or not the
196        // project has ever heard of functions — and an emitted section is one #1326
197        // refuses to boot on a build that cannot serve it.
198        let definitions = intermediate.functions.take().unwrap_or_default();
199        if definitions.is_empty() {
200            anyhow::ensure!(
201                settings.module_dir.is_none() && settings.dlq_store.is_none(),
202                "the `[functions]` table configures the functions subsystem but the schema \
203                 declares no function, so nothing would use it. Declare a function, or remove \
204                 the table."
205            );
206            return Ok(None);
207        }
208
209        Ok(Some(FunctionsConfig {
210            module_dir: settings
211                .module_dir
212                .unwrap_or_else(|| std::path::PathBuf::from(DEFAULT_FUNCTION_MODULE_DIR)),
213            definitions,
214            dlq_store: settings.dlq_store,
215        }))
216    }
217
218    /// Convert `IntermediateSchema` to `CompiledSchema`.
219    ///
220    /// This performs:
221    /// 1. Type conversion (intermediate types → compiled types)
222    /// 2. Field name normalization (type → `field_type`)
223    /// 3. Optional mutation-error-union synthesis (`options.auto_error_union`)
224    /// 4. Validation (type references, circular refs, etc.)
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if any type, query, mutation, interface, subscription,
229    /// or directive conversion fails, if federation/security/observer config JSON
230    /// cannot be deserialized, or if compiled schema validation detects unknown
231    /// type references.
232    pub fn convert_with_options(
233        intermediate: IntermediateSchema,
234        options: &ConvertOptions,
235    ) -> Result<CompiledSchema> {
236        info!("Converting intermediate schema to compiled format");
237
238        // Aggregate the per-type `@subscribable(tables=[...])` annotations (#366)
239        // into the top-level `CompiledSchema.subscribable` list BEFORE the types
240        // are consumed below. Empty `tables` is treated as "not subscribable".
241        let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
242            .types
243            .iter()
244            .filter_map(|t| {
245                t.subscribable_tables
246                    .as_ref()
247                    .filter(|tables| !tables.is_empty())
248                    .map(|tables| fraiseql_core::schema::SubscribableEntity {
249                        entity_type: t.name.clone(),
250                        tables:      tables.clone(),
251                        pre_image:   t.subscribable_pre_image,
252                    })
253            })
254            .collect();
255
256        // Refuse a schema that declares observers, rather than validating them and throwing
257        // them away (#779).
258        //
259        // The `observers` block is emitted by the Python SDK's `@fraiseql.observer` (its
260        // shipped example declares five), it binds to `IntermediateSchema.observers`, and
261        // `SchemaValidator` spends ~220 lines checking names, entities, events, actions and
262        // retry policy — a typo in any of them fails the compile, which tells the author
263        // emphatically that the block is honoured. It was not: this function set
264        // `observers: Vec::new()` under a comment claiming they were "populated from
265        // IntermediateSchema", nothing else ever wrote the field, and no webhook, Slack
266        // message or email ever fired for any declared event.
267        //
268        // The runtime loads observers exclusively from `tb_observer` / the admin API and
269        // reads nothing from the compiled schema. So *carrying* them here would only move
270        // the silent drop one layer down and produce a compiled artifact whose `observers`
271        // array is decoration. Until a runtime consumer exists, the honest outcome is to
272        // fail and name the mechanism that does work.
273        if let Some(observers) = intermediate.observers.as_ref().filter(|o| !o.is_empty()) {
274            let names: Vec<&str> = observers.iter().map(|o| o.name.as_str()).collect();
275            anyhow::bail!(
276                "This schema declares {} observer(s) ({}), but declared observers are not \
277                 loaded by the runtime — it reads them from the `tb_observer` table and the \
278                 admin API only, so compiling them would produce a schema whose observers \
279                 never fire.\n\nDefine them in `tb_observer`, or register them at runtime \
280                 with `POST /api/observers`, and remove the `observers` block from the \
281                 authored schema.",
282                observers.len(),
283                names.join(", ")
284            );
285        }
286
287        // Refuse a schema that declares `aggregate_queries`, rather than carrying it through
288        // the seam and dropping it (#956).
289        //
290        // `aggregate_queries` is listed in `AUTHORABLE_ARRAY_SECTIONS`, so it is valid input
291        // on every compile path and every loader and merger carries it faithfully — and then
292        // this converter maps `fact_tables` and never reads it. `CompiledSchema` has no
293        // corresponding field, so the definitions reach the end of the seam and evaporate
294        // under a `✓ Schema compiled successfully`. That is the #755 shape surviving inside
295        // the very seam built to kill it.
296        //
297        // #624 gave `[[analytics.queries]]` real semantics by lowering each entry onto an
298        // ordinary `QueryDefinition`, so the capability exists and has a supported spelling.
299        // Refusing here — the `#779` disposition — names it, instead of accepting a second
300        // spelling that does nothing.
301        if let Some(aggregates) = intermediate.aggregate_queries.as_ref().filter(|a| !a.is_empty())
302        {
303            let names: Vec<&str> = aggregates.iter().map(|a| a.name.as_str()).collect();
304            anyhow::bail!(
305                "This schema declares {} aggregate_queries entry/entries ({}), which no \
306                 compile path lowers into the compiled schema — `CompiledSchema` has no \
307                 field for them, so they would vanish silently.\n\nDeclare them as \
308                 `[[analytics.queries]]` in fraiseql.toml instead (#624): each entry \
309                 becomes a real list-returning, view-backed query. Then remove the \
310                 `aggregate_queries` block from the authored schema.",
311                aggregates.len(),
312                names.join(", ")
313            );
314        }
315
316        // #923: collect the declared enum / interface / union names BEFORE the arrays are
317        // consumed below, so a field or argument naming one resolves to that kind instead of
318        // to a phantom `FieldType::Object`.
319        let declared = DeclaredTypeNames::collect(&intermediate);
320
321        // Split the authored `types` array on `is_input` before converting anything (#848).
322        //
323        // A type marked `is_input` is an input object that four SDKs happen to declare in
324        // the `types` array — Elixir's exporter emits no `input_types` key at all, so this
325        // is its only route. Routing it here rather than converting it as an object type is
326        // what keeps the compiled schema GraphQL-legal: an argument may only be typed with
327        // an input type (§3.10).
328        let (input_marked_types, object_types): (Vec<_>, Vec<_>) =
329            intermediate.types.into_iter().partition(|t| t.is_input);
330
331        let types = object_types
332            .into_iter()
333            .map(|t| Self::convert_type(t, &declared))
334            .collect::<Result<Vec<_>>>()
335            .context("Failed to convert types")?;
336
337        // Extract query_defaults before consuming intermediate.queries.
338        // unwrap_or_default() → all-true, matching historical behaviour when no
339        // [query_defaults] section is present in fraiseql.toml.
340        let defaults = intermediate.query_defaults.unwrap_or_default();
341
342        // Apply `[inject_defaults]` (#847) to any operation that has not named the
343        // parameter itself.
344        //
345        // This runs here, in the converter, rather than in `commands::compile` — the
346        // converter is the single path every caller of the public API goes through, so a
347        // default cannot be lost by reaching the compiled schema some other way.
348        //
349        // It is also deliberately *after* `[fraiseql.tenancy]` validation (which
350        // `commands::compile` performs on the intermediate schema before calling this).
351        // Tenancy auto-injects when `inject_params` is empty and errors when it is
352        // non-empty but lacks the annotated field, so applying defaults first would make a
353        // single unrelated default break every tenancy-annotated operation.
354        let inject_defaults = intermediate.inject_defaults.unwrap_or_default();
355        let query_defaults_inject = inject_defaults.for_queries();
356        let mutation_defaults_inject = inject_defaults.for_mutations();
357
358        // Convert queries. A query that opted into `count` emits a second,
359        // derived `<name>Count` definition (#938) — built by `count_sibling` so
360        // its tenancy, role and filter inheritance has exactly one definition.
361        let mut queries: Vec<fraiseql_core::schema::QueryDefinition> =
362            Vec::with_capacity(intermediate.queries.len());
363        for mut q in intermediate.queries {
364            IntermediateInjectDefaults::apply_to(&query_defaults_inject, &mut q.inject);
365            let wants_count = q.count;
366            let converted = Self::convert_query(q, &defaults, &declared)
367                .context("Failed to convert queries")?;
368            let sibling = wants_count.then(|| converted.count_sibling());
369            queries.push(converted);
370            queries.extend(sibling);
371        }
372
373        // A generated sibling must not silently displace an author's own query of
374        // the same name — whichever won, the other would vanish from the schema
375        // with no diagnostic.
376        let mut seen: HashSet<&str> = HashSet::new();
377        for q in &queries {
378            if !seen.insert(q.name.as_str()) {
379                anyhow::bail!(
380                    "Query name '{}' is declared twice. A `count = true` query generates \
381                     '<name>Count'; rename the conflicting query or drop count=true.",
382                    q.name
383                );
384            }
385        }
386
387        // Convert mutations
388        let mutations = intermediate
389            .mutations
390            .into_iter()
391            .map(|mut m| {
392                IntermediateInjectDefaults::apply_to(&mutation_defaults_inject, &mut m.inject);
393                Self::convert_mutation(m, &declared)
394            })
395            .collect::<Result<Vec<_>>>()
396            .context("Failed to convert mutations")?;
397
398        // Convert enums
399        let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
400
401        // Convert input types — the `input_types` array plus the `is_input`-marked entries
402        // lifted out of `types` above. Both routes land in one registry, so a duplicate
403        // between them is an authoring conflict and is refused rather than shadowed.
404        let mut input_objects = intermediate.input_types;
405        for marked in input_marked_types {
406            let lifted = Self::input_object_from_marked_type(marked)?;
407            if input_objects.iter().any(|existing| existing.name == lifted.name) {
408                anyhow::bail!(
409                    "'{}' is declared both in `input_types` and as a type marked \
410                     `is_input: true`. Keep exactly one declaration — two definitions of one \
411                     input object cannot be reconciled, and whichever won would depend on \
412                     compile order.",
413                    lifted.name
414                );
415            }
416            input_objects.push(lifted);
417        }
418        let input_types =
419            input_objects.into_iter().map(Self::convert_input_object).collect::<Vec<_>>();
420
421        // Convert interfaces
422        let interfaces = intermediate
423            .interfaces
424            .into_iter()
425            .map(|i| Self::convert_interface(i, &declared))
426            .collect::<Result<Vec<_>>>()
427            .context("Failed to convert interfaces")?;
428
429        // Convert unions
430        let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
431
432        // Convert subscriptions
433        let subscriptions = intermediate
434            .subscriptions
435            .into_iter()
436            .map(|s| Self::convert_subscription(s, &declared))
437            .collect::<Result<Vec<_>>>()
438            .context("Failed to convert subscriptions")?;
439
440        // Convert custom directives
441        let directives = intermediate
442            .directives
443            .unwrap_or_default()
444            .into_iter()
445            .map(|d| Self::convert_directive(d, &declared))
446            .collect::<Result<Vec<_>>>()
447            .context("Failed to convert directives")?;
448
449        // Convert fact tables from Vec<IntermediateFactTable> to HashMap<String, FactTableMetadata>
450        let fact_tables = intermediate
451            .fact_tables
452            .unwrap_or_default()
453            .into_iter()
454            .map(|ft| {
455                let name = ft.table_name.clone();
456                let metadata = Self::convert_fact_table(ft);
457                (name, metadata)
458            })
459            .collect();
460
461        let mut compiled = CompiledSchema {
462            types,
463            enums,
464            input_types,
465            interfaces,
466            unions,
467            queries,
468            mutations,
469            subscriptions,
470            directives,
471            fact_tables, // Analytics metadata
472            // Refused above when non-empty (#779); an empty vector is the only reachable
473            // value, so this is not a drop.
474            observers: Vec::new(),
475            sources: intermediate.sources.clone().unwrap_or_default(), /* #573 scheduled ingress
476                                                                        * sources */
477            subscribable, // @subscribable capture-trigger declarations (#366)
478            federation: intermediate
479                .federation_config
480                .map(serde_json::from_value)
481                .transpose()
482                .context("federation_config: invalid JSON structure")?,
483            security: intermediate
484                .security
485                .map(serde_json::from_value)
486                .transpose()
487                .context("security: invalid JSON structure")?,
488            auth: intermediate
489                .auth
490                .map(serde_json::from_value)
491                .transpose()
492                .context("auth: invalid PKCE OAuth-client structure (#621)")?,
493            observers_config: intermediate
494                .observers_config
495                .map(|mut oc| {
496                    // #631: `handlers` reaching this seam (SDK-authored
497                    // `observers_config.handlers`, bypassing the TOML
498                    // validator's #612 bail) must fail the compile, not land in
499                    // the compiled schema as decoration the runtime never
500                    // reads. tb_observer / the admin API is the single observer
501                    // concept. An EMPTY array is inert boilerplate some
502                    // exporters emit — strip it so `deny_unknown_fields` on the
503                    // compiled type does not reject it.
504                    match oc.get("handlers").and_then(serde_json::Value::as_array) {
505                        Some(handlers) if !handlers.is_empty() => {
506                            anyhow::bail!(
507                                "observers_config declares {} handler(s), but compiled \
508                                 handlers are not a runtime concept (#631): the runtime \
509                                 loads observers only from the `tb_observer` table and the \
510                                 admin API. Define them in `tb_observer` or via \
511                                 `POST /api/observers`, and remove `handlers` from the \
512                                 authored schema.",
513                                handlers.len()
514                            );
515                        },
516                        Some(_) => {
517                            if let Some(map) = oc.as_object_mut() {
518                                map.remove("handlers");
519                            }
520                        },
521                        None => {},
522                    }
523                    serde_json::from_value(oc).context("observers_config: invalid JSON structure")
524                })
525                .transpose()?,
526            subscriptions_config: intermediate.subscriptions_config, /* Subscriptions config from
527                                                                      * TOML */
528            validation_config: intermediate.validation_config, // Validation limits from TOML
529            debug_config: intermediate.debug_config,           // Debug config from TOML
530            mcp_config: intermediate.mcp_config,               // MCP config from TOML
531            rest_config: intermediate.rest_config,             // REST config from TOML
532            grpc_config: intermediate.grpc_config,             // gRPC config from TOML (#780)
533            naming_convention: intermediate.naming_convention, // Naming convention from TOML
534            session_variables: intermediate.session_variables.unwrap_or_default(),
535            hierarchies_config: intermediate.hierarchies_config,
536            changelog: intermediate.changelog_config, // Changelog exposure config from TOML
537            schema_sdl: None,                         // Raw GraphQL SDL
538            custom_scalars: CustomTypeRegistry::default(), // Custom scalar registry
539            ..Default::default()
540        };
541
542        // Populate custom scalars from intermediate schema
543        if let Some(custom_scalars_vec) = intermediate.custom_scalars {
544            for scalar_def in custom_scalars_vec {
545                let custom_type = Self::convert_custom_scalar(scalar_def)?;
546                compiled
547                    .custom_scalars
548                    .register(custom_type.name.clone(), custom_type)
549                    .context("Failed to register custom scalar")?;
550            }
551        }
552
553        // Changelog exposure requires the observer system: the views it exposes read
554        // from tables (`tb_entity_change_log`, `tb_transport_checkpoint`) that the
555        // observer install convention supplies. Emitting GraphQL types over absent
556        // tables would only fail later, at runtime — reject it here instead.
557        if let Some(ref cl) = compiled.changelog {
558            if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
559                anyhow::bail!(
560                    "[changelog] expose = true requires [observers] to be enabled: the tables \
561                     it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
562                     the observer system."
563                );
564            }
565            // #497: the change-log surface is a per-database stream, not a federated
566            // value type — the injected `EntityChangeLog` type and `entity_change_logs`
567            // root query are not `@shareable`. Two subgraphs in one supergraph that both
568            // `expose` it inject the identical type/root field, which `rover supergraph
569            // compose` rejects with INVALID_FIELD_SHARING. We can't detect the collision
570            // here (each subgraph compiles alone), so warn on the federated+exposed combo
571            // and point at the single-owner pattern. This is a reminder, not an error —
572            // the owning subgraph legitimately sets both.
573            if cl.expose && compiled.federation.as_ref().is_some_and(|f| f.enabled) {
574                warn!(
575                    "[changelog] expose = true on a federation subgraph: EntityChangeLog / \
576                     entity_change_logs are not @shareable, so expose the change-log in exactly \
577                     one subgraph per supergraph (others keep capturing via [changelog] \
578                     write_enabled). Two exposing subgraphs fail `rover supergraph compose` with \
579                     INVALID_FIELD_SHARING. See the changelog-graphql guide for the single-owner \
580                     pattern."
581                );
582            }
583        }
584
585        // Canonicalize the Trinity external identity (`id: UUID` → `id: ID`, ADR-0017)
586        // before the interface-forcing passes, so Relay `Node` / `CascadeNode` see a
587        // conformant `id: ID` on every entity. Wire-transparent (a UUID is an `ID`).
588        identity::normalize_entity_identity(&mut compiled);
589
590        // Inject synthetic Relay types (PageInfo, Node interface, XxxConnection, XxxEdge).
591        relay::inject_relay_types(&mut compiled)?;
592
593        // Inject the changelog GraphQL surface (EntityChangeLog / TransportCheckpoint
594        // types + cursor query + point lookup + upsert mutation) when opted in.
595        fraiseql_core::schema::inject_changelog(&mut compiled);
596
597        // Synthesize the typed cascade surface (CascadeNode interface + envelope
598        // types + per-mutation `<Name>Payload` wrappers) for mutations that opt in
599        // via `cascade = true`. Runs BEFORE error-union synthesis so a cascade
600        // payload becomes the success member of the result union
601        // (`<Name>Result = <Name>Payload | MutationError`). Inert with no cascade
602        // mutations.
603        cascade_types::synthesize_cascade_types(&mut compiled)?;
604
605        // Auto-synthesize a shared MutationError type + per-mutation result unions
606        // when opted in (`[fraiseql.mutations] auto_error_union`), so the runtime's
607        // success/error discrimination has a union to resolve against.
608        if options.auto_error_union {
609            mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
610        }
611
612        // IR honesty (#456): `parse_field_type` has no `Input` variant, so an
613        // argument referencing a declared input type was resolved to
614        // `FieldType::Object`. Rewrite those argument types to `FieldType::Input`
615        // now that every input type is registered (after relay / rich-filter /
616        // changelog / error-union injection) so the IR honestly represents the
617        // GraphQL input position — which also makes introspection report the correct
618        // `INPUT_OBJECT` kind for those args. The runtime already accepts both
619        // shapes, so this is purely representational.
620        Self::promote_input_type_args(&mut compiled);
621
622        // Validate the compiled schema
623        Self::validate(&compiled)?;
624
625        info!("Schema conversion successful");
626        Ok(compiled)
627    }
628
629    /// Rewrite mutation/query argument types of the form `Object(name)` (and lists
630    /// thereof) to `Input(name)` when `name` is a registered input type, so the IR
631    /// honestly represents input-position references (#456). Input type names are
632    /// disjoint from output type names per the GraphQL spec, so the lookup is exact
633    /// — an output-object argument is never misclassified.
634    fn promote_input_type_args(schema: &mut CompiledSchema) {
635        fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
636            match ft {
637                FieldType::Object(name) if input_names.contains(name) => {
638                    *ft = FieldType::Input(name.clone());
639                },
640                FieldType::List(inner) => promote(inner, input_names),
641                _ => {},
642            }
643        }
644
645        let input_names: HashSet<String> =
646            schema.input_types.iter().map(|i| i.name.clone()).collect();
647        if input_names.is_empty() {
648            return;
649        }
650
651        for mutation in &mut schema.mutations {
652            for arg in &mut mutation.arguments {
653                promote(&mut arg.arg_type, &input_names);
654            }
655        }
656        for query in &mut schema.queries {
657            for arg in &mut query.arguments {
658                promote(&mut arg.arg_type, &input_names);
659            }
660        }
661    }
662
663    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many field-level checks
664    fn validate(schema: &CompiledSchema) -> Result<()> {
665        info!("Validating compiled schema");
666
667        // Build type registry
668        let mut type_names: HashSet<String> = HashSet::new();
669        for type_def in &schema.types {
670            type_names.insert(type_def.name.to_string());
671        }
672
673        // Build interface registry. Interfaces are *also* valid query/mutation
674        // return types (a field may return an interface, narrowed via inline
675        // fragments), so register them in `type_names` too — previously omitted,
676        // which made a query/mutation returning an interface silently fail the
677        // reference check below.
678        let mut interface_names = HashSet::new();
679        for interface_def in &schema.interfaces {
680            interface_names.insert(interface_def.name.clone());
681            type_names.insert(interface_def.name.clone());
682        }
683
684        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
685        for input_type in &schema.input_types {
686            type_names.insert(input_type.name.clone());
687        }
688
689        // Add union type names — valid as mutation/query return types
690        for union_def in &schema.unions {
691            type_names.insert(union_def.name.clone());
692        }
693
694        // Add enum type names — valid as field and argument types
695        for enum_def in &schema.enums {
696            type_names.insert(enum_def.name.clone());
697        }
698
699        // Add built-in scalars
700        for scalar in crate::schema::builtin_scalar_names() {
701            type_names.insert(scalar.to_string());
702        }
703
704        // Add **declared** custom scalars, from the registry the compiler actually carries.
705        //
706        // This used to register every type name appearing in any object field, which made a
707        // field-type typo legalize itself as a return type too (#724 item 2) — the same
708        // blindness as `SchemaValidator`, duplicated here.
709        for (name, _) in schema.custom_scalars.list_all() {
710            type_names.insert(name);
711        }
712
713        // Collect every problem, then report them together.
714        //
715        // This tier used to `bail!` on the first error, with no suggestion, after redundantly
716        // `warn!`ing the same text (#724 item 4). Errors that only surface after synthesis —
717        // relay, cascade, changelog-injected types — are validated *here*, so the same class
718        // of mistake got a materially worse experience depending on which tier caught it, and
719        // a user with three typos needed three compile cycles for information the compiler
720        // had in hand on the first.
721        let mut problems: Vec<String> = Vec::new();
722        let mut known: Vec<&str> = type_names.iter().map(String::as_str).collect();
723        known.sort_unstable();
724
725        let describe = |name: &str| -> String {
726            let similar = fraiseql_core::runtime::suggest_similar(name, &known);
727            if similar.is_empty() {
728                String::new()
729            } else {
730                format!(" (did you mean: {}?)", similar.join(", "))
731            }
732        };
733
734        // Validate queries
735        for query in &schema.queries {
736            if !type_names.contains(&query.return_type) {
737                problems.push(format!(
738                    "Query '{}' references unknown type '{}'{}",
739                    query.name,
740                    query.return_type,
741                    describe(&query.return_type)
742                ));
743            }
744
745            for arg in &query.arguments {
746                let type_name = Self::extract_type_name(&arg.arg_type);
747                if !type_names.contains(&type_name) {
748                    problems.push(format!(
749                        "Query '{}' argument '{}' references unknown type '{}'{}",
750                        query.name,
751                        arg.name,
752                        type_name,
753                        describe(&type_name)
754                    ));
755                }
756            }
757        }
758
759        // Validate mutations
760        for mutation in &schema.mutations {
761            if !type_names.contains(&mutation.return_type) {
762                problems.push(format!(
763                    "Mutation '{}' references unknown type '{}'{}",
764                    mutation.name,
765                    mutation.return_type,
766                    describe(&mutation.return_type)
767                ));
768            }
769
770            for arg in &mutation.arguments {
771                let type_name = Self::extract_type_name(&arg.arg_type);
772                if !type_names.contains(&type_name) {
773                    problems.push(format!(
774                        "Mutation '{}' argument '{}' references unknown type '{}'{}",
775                        mutation.name,
776                        arg.name,
777                        type_name,
778                        describe(&type_name)
779                    ));
780                }
781            }
782        }
783
784        // Validate interface implementations
785        for type_def in &schema.types {
786            for interface_name in &type_def.implements {
787                if !interface_names.contains(interface_name) {
788                    let mut candidates: Vec<&str> =
789                        interface_names.iter().map(String::as_str).collect();
790                    candidates.sort_unstable();
791                    let similar =
792                        fraiseql_core::runtime::suggest_similar(interface_name, &candidates);
793                    let hint = if similar.is_empty() {
794                        String::new()
795                    } else {
796                        format!(" (did you mean: {}?)", similar.join(", "))
797                    };
798                    problems.push(format!(
799                        "Type '{}' implements unknown interface '{interface_name}'{hint}",
800                        type_def.name
801                    ));
802                    continue;
803                }
804
805                // Validate that the type has all fields required by the interface
806                if let Some(interface) = schema.find_interface(interface_name) {
807                    for interface_field in &interface.fields {
808                        let type_has_field = type_def.fields.iter().any(|f| {
809                            f.name == interface_field.name
810                                && f.field_type == interface_field.field_type
811                        });
812                        if !type_has_field {
813                            problems.push(format!(
814                                "Type '{}' implements interface '{interface_name}' but is \
815                                 missing field '{}'",
816                                type_def.name, interface_field.name
817                            ));
818                        }
819                    }
820                }
821            }
822        }
823
824        // Relationships the REST embed executor could not follow (#1266) — an undeclared
825        // target, a join column no field publishes, a target no list query returns.
826        //
827        // Borrowed wholesale from the load-time check rather than restated here: the two
828        // must agree, and the way they stop agreeing is one of them being taught a rule
829        // the other is not. `CompiledSchema::finish_load` runs the same function, which
830        // is what covers the hand-edited artifact this compile-time refusal cannot reach.
831        problems.extend(schema.relationship_violations());
832
833        if !problems.is_empty() {
834            let count = problems.len();
835            let noun = if count == 1 { "problem" } else { "problems" };
836            anyhow::bail!(
837                "Schema validation found {count} {noun}:\n{}",
838                problems.iter().map(|p| format!("  - {p}")).collect::<Vec<_>>().join("\n")
839            );
840        }
841
842        info!("Schema validation passed");
843        Ok(())
844    }
845
846    /// Extract type name from `FieldType` for validation
847    ///
848    /// Built-in types return their scalar name, Object types return the object name
849    fn extract_type_name(field_type: &FieldType) -> String {
850        // A built-in scalar's name is the one the author wrote, from the same
851        // table `parse_field_type` reads — this is that function's inverse.
852        if let Some(name) = field_type.authoring_name() {
853            return name.to_string();
854        }
855        match field_type {
856            FieldType::Scalar(name) => name.clone(),
857            FieldType::Object(name) => name.clone(),
858            FieldType::Enum(name) => name.clone(),
859            FieldType::Input(name) => name.clone(),
860            FieldType::Interface(name) => name.clone(),
861            FieldType::Union(name) => name.clone(),
862            FieldType::List(inner) => Self::extract_type_name(inner),
863            // Reason: non_exhaustive requires catch-all for cross-crate matches
864            _ => "Unknown".to_string(),
865        }
866    }
867
868    /// Convert `IntermediateFactTable` to `FactTableMetadata`.
869    fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
870        FactTableMetadata {
871            table_name:               ft.table_name,
872            type_name:                ft.type_name,
873            measures:                 ft
874                .measures
875                .into_iter()
876                .map(|m| MeasureColumn {
877                    name:     m.name,
878                    sql_type: Self::parse_sql_type(&m.sql_type),
879                    nullable: m.nullable,
880                })
881                .collect(),
882            dimensions:               DimensionColumn {
883                name:  ft.dimensions.name,
884                paths: ft
885                    .dimensions
886                    .paths
887                    .into_iter()
888                    .map(|p| DimensionPath {
889                        name:      p.name,
890                        json_path: p.json_path,
891                        data_type: p.data_type,
892                    })
893                    .collect(),
894            },
895            denormalized_filters:     ft
896                .denormalized_filters
897                .into_iter()
898                .map(|f| FilterColumn {
899                    name:     f.name,
900                    sql_type: Self::parse_sql_type(&f.sql_type),
901                    indexed:  f.indexed,
902                })
903                .collect(),
904            calendar_dimensions:      vec![],
905            partial_period:           None,
906            native_measures:          ft.native_measures,
907            native_dimension_mapping: ft.native_dimension_mapping,
908        }
909    }
910
911    /// Parse a SQL type string into a `SqlType` enum variant.
912    fn parse_sql_type(s: &str) -> SqlType {
913        match s.to_uppercase().as_str() {
914            "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
915            "BIGINT" | "INT8" => SqlType::BigInt,
916            "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
917            "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
918                SqlType::Float
919            },
920            "JSONB" => SqlType::Jsonb,
921            "JSON" => SqlType::Json,
922            "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
923            "UUID" => SqlType::Uuid,
924            "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
925                SqlType::Timestamp
926            },
927            "DATE" => SqlType::Date,
928            "BOOLEAN" | "BOOL" => SqlType::Boolean,
929            _ => SqlType::Other(s.to_string()),
930        }
931    }
932}
933
934/// Parse an authored `requires_actor` list into [`ActorType`]s (#966).
935///
936/// One parser for queries and mutations, so the two cannot disagree about which
937/// tokens exist.
938///
939/// # Why an unknown token is an error
940///
941/// `requires_actor` is an allow-list, so a dropped entry does not open the
942/// operation — it *closes* it further, and a typo'd `"human"` would refuse every
943/// human user while the schema reads as though it admits them. And a whole list
944/// that failed to parse would leave the field empty, which means *unrestricted*:
945/// the failure mode of silence is a security gate that is not there. Naming the
946/// bad token at compile time is the only reading where the author finds out.
947///
948/// # Errors
949///
950/// Returns an error naming the offending token and listing the valid ones.
951pub(crate) fn parse_requires_actor(
952    operation_kind: &str,
953    operation_name: &str,
954    tokens: &[String],
955) -> Result<Vec<fraiseql_core::security::ActorType>> {
956    use fraiseql_core::security::ActorType;
957
958    tokens
959        .iter()
960        .map(|token| {
961            ActorType::from_token(token).ok_or_else(|| {
962                anyhow::anyhow!(
963                    "{operation_kind} '{operation_name}': requires_actor entry '{token}' is not a \
964                     known actor type. Valid values: {}",
965                    ActorType::ALL.map(ActorType::as_str).join(", ")
966                )
967            })
968        })
969        .collect()
970}