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;
7mod identity;
8mod interface_conformance;
9mod mutation_error_union;
10mod mutations;
11mod queries;
12mod relay;
13mod subscriptions;
14pub(crate) mod tenancy;
15mod types;
16
17#[cfg(test)]
18mod tests;
19
20use std::collections::HashSet;
21
22use anyhow::{Context, Result};
23use fraiseql_core::{
24    compiler::fact_table::{
25        DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
26    },
27    schema::{CompiledSchema, FieldType},
28    validation::CustomTypeRegistry,
29};
30use tracing::{info, warn};
31
32use super::{
33    intermediate::{IntermediateFactTable, IntermediateSchema},
34    rich_filters::{RichFilterConfig, compile_rich_filters},
35};
36
37/// Converts intermediate format to compiled format
38pub struct SchemaConverter;
39
40/// Optional, default-off behaviours for [`SchemaConverter::convert_with_options`].
41///
42/// Threaded here rather than on [`IntermediateSchema`] so adding an option never
43/// breaks the many full-literal `IntermediateSchema { .. }` constructions, and
44/// the plain [`SchemaConverter::convert`] keeps its historical signature.
45#[derive(Debug, Clone, Default)]
46pub struct ConvertOptions {
47    /// Auto-synthesize a shared `MutationError` type + per-mutation result unions
48    /// and rewrite object-returning mutations to those unions (`[fraiseql.mutations]
49    /// auto_error_union`).
50    pub auto_error_union: bool,
51}
52
53impl SchemaConverter {
54    /// Convert `IntermediateSchema` to `CompiledSchema` with default options.
55    ///
56    /// # Errors
57    ///
58    /// See [`SchemaConverter::convert_with_options`].
59    pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
60        Self::convert_with_options(intermediate, &ConvertOptions::default())
61    }
62
63    /// Convert `IntermediateSchema` to `CompiledSchema`.
64    ///
65    /// This performs:
66    /// 1. Type conversion (intermediate types → compiled types)
67    /// 2. Field name normalization (type → `field_type`)
68    /// 3. Optional mutation-error-union synthesis (`options.auto_error_union`)
69    /// 4. Validation (type references, circular refs, etc.)
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if any type, query, mutation, interface, subscription,
74    /// or directive conversion fails, if federation/security/observer config JSON
75    /// cannot be deserialized, or if compiled schema validation detects unknown
76    /// type references.
77    pub fn convert_with_options(
78        intermediate: IntermediateSchema,
79        options: &ConvertOptions,
80    ) -> Result<CompiledSchema> {
81        info!("Converting intermediate schema to compiled format");
82
83        // Aggregate the per-type `@subscribable(tables=[...])` annotations (#366)
84        // into the top-level `CompiledSchema.subscribable` list BEFORE the types
85        // are consumed below. Empty `tables` is treated as "not subscribable".
86        let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
87            .types
88            .iter()
89            .filter_map(|t| {
90                t.subscribable_tables
91                    .as_ref()
92                    .filter(|tables| !tables.is_empty())
93                    .map(|tables| fraiseql_core::schema::SubscribableEntity {
94                        entity_type: t.name.clone(),
95                        tables:      tables.clone(),
96                        pre_image:   t.subscribable_pre_image,
97                    })
98            })
99            .collect();
100
101        // Convert types
102        let types = intermediate
103            .types
104            .into_iter()
105            .map(Self::convert_type)
106            .collect::<Result<Vec<_>>>()
107            .context("Failed to convert types")?;
108
109        // Extract query_defaults before consuming intermediate.queries.
110        // unwrap_or_default() → all-true, matching historical behaviour when no
111        // [query_defaults] section is present in fraiseql.toml.
112        let defaults = intermediate.query_defaults.unwrap_or_default();
113
114        // Convert queries
115        let queries = intermediate
116            .queries
117            .into_iter()
118            .map(|q| Self::convert_query(q, &defaults))
119            .collect::<Result<Vec<_>>>()
120            .context("Failed to convert queries")?;
121
122        // Convert mutations
123        let mutations = intermediate
124            .mutations
125            .into_iter()
126            .map(Self::convert_mutation)
127            .collect::<Result<Vec<_>>>()
128            .context("Failed to convert mutations")?;
129
130        // Convert enums
131        let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
132
133        // Convert input types
134        let input_types = intermediate
135            .input_types
136            .into_iter()
137            .map(Self::convert_input_object)
138            .collect::<Vec<_>>();
139
140        // Convert interfaces
141        let interfaces = intermediate
142            .interfaces
143            .into_iter()
144            .map(Self::convert_interface)
145            .collect::<Result<Vec<_>>>()
146            .context("Failed to convert interfaces")?;
147
148        // Convert unions
149        let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
150
151        // Convert subscriptions
152        let subscriptions = intermediate
153            .subscriptions
154            .into_iter()
155            .map(Self::convert_subscription)
156            .collect::<Result<Vec<_>>>()
157            .context("Failed to convert subscriptions")?;
158
159        // Convert custom directives
160        let directives = intermediate
161            .directives
162            .unwrap_or_default()
163            .into_iter()
164            .map(Self::convert_directive)
165            .collect::<Result<Vec<_>>>()
166            .context("Failed to convert directives")?;
167
168        // Convert fact tables from Vec<IntermediateFactTable> to HashMap<String, FactTableMetadata>
169        let fact_tables = intermediate
170            .fact_tables
171            .unwrap_or_default()
172            .into_iter()
173            .map(|ft| {
174                let name = ft.table_name.clone();
175                let metadata = Self::convert_fact_table(ft);
176                (name, metadata)
177            })
178            .collect();
179
180        let mut compiled = CompiledSchema {
181            types,
182            enums,
183            input_types,
184            interfaces,
185            unions,
186            queries,
187            mutations,
188            subscriptions,
189            directives,
190            fact_tables, // Analytics metadata
191            observers: Vec::new(), /* Observer definitions (populated from
192                          * IntermediateSchema) */
193            sources: intermediate.sources.clone().unwrap_or_default(), /* #573 scheduled ingress
194                                                                        * sources */
195            subscribable, // @subscribable capture-trigger declarations (#366)
196            federation: intermediate
197                .federation_config
198                .map(serde_json::from_value)
199                .transpose()
200                .context("federation_config: invalid JSON structure")?,
201            security: intermediate
202                .security
203                .map(serde_json::from_value)
204                .transpose()
205                .context("security: invalid JSON structure")?,
206            observers_config: intermediate
207                .observers_config
208                .map(serde_json::from_value)
209                .transpose()
210                .context("observers_config: invalid JSON structure")?,
211            subscriptions_config: intermediate.subscriptions_config, /* Subscriptions config from
212                                                                      * TOML */
213            validation_config: intermediate.validation_config, // Validation limits from TOML
214            debug_config: intermediate.debug_config,           // Debug config from TOML
215            mcp_config: intermediate.mcp_config,               // MCP config from TOML
216            rest_config: intermediate.rest_config,             // REST config from TOML
217            naming_convention: intermediate.naming_convention, // Naming convention from TOML
218            session_variables: intermediate.session_variables.unwrap_or_default(),
219            hierarchies_config: intermediate.hierarchies_config,
220            changelog: intermediate.changelog_config, // Changelog exposure config from TOML
221            schema_sdl: None,                         // Raw GraphQL SDL
222            custom_scalars: CustomTypeRegistry::default(), // Custom scalar registry
223            schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
224            ..Default::default()
225        };
226
227        // Populate custom scalars from intermediate schema
228        if let Some(custom_scalars_vec) = intermediate.custom_scalars {
229            for scalar_def in custom_scalars_vec {
230                let custom_type = Self::convert_custom_scalar(scalar_def)?;
231                compiled
232                    .custom_scalars
233                    .register(custom_type.name.clone(), custom_type)
234                    .context("Failed to register custom scalar")?;
235            }
236        }
237
238        // Changelog exposure requires the observer system: the views it exposes read
239        // from tables (`tb_entity_change_log`, `tb_transport_checkpoint`) that the
240        // observer install convention supplies. Emitting GraphQL types over absent
241        // tables would only fail later, at runtime — reject it here instead.
242        if let Some(ref cl) = compiled.changelog {
243            if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
244                anyhow::bail!(
245                    "[changelog] expose = true requires [observers] to be enabled: the tables \
246                     it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
247                     the observer system."
248                );
249            }
250            // #497: the change-log surface is a per-database stream, not a federated
251            // value type — the injected `EntityChangeLog` type and `entity_change_logs`
252            // root query are not `@shareable`. Two subgraphs in one supergraph that both
253            // `expose` it inject the identical type/root field, which `rover supergraph
254            // compose` rejects with INVALID_FIELD_SHARING. We can't detect the collision
255            // here (each subgraph compiles alone), so warn on the federated+exposed combo
256            // and point at the single-owner pattern. This is a reminder, not an error —
257            // the owning subgraph legitimately sets both.
258            if cl.expose && compiled.federation.as_ref().is_some_and(|f| f.enabled) {
259                warn!(
260                    "[changelog] expose = true on a federation subgraph: EntityChangeLog / \
261                     entity_change_logs are not @shareable, so expose the change-log in exactly \
262                     one subgraph per supergraph (others keep capturing via [changelog] \
263                     write_enabled). Two exposing subgraphs fail `rover supergraph compose` with \
264                     INVALID_FIELD_SHARING. See the changelog-graphql guide for the single-owner \
265                     pattern."
266                );
267            }
268        }
269
270        // Canonicalize the Trinity external identity (`id: UUID` → `id: ID`, ADR-0017)
271        // before the interface-forcing passes, so Relay `Node` / `CascadeNode` see a
272        // conformant `id: ID` on every entity. Wire-transparent (a UUID is an `ID`).
273        identity::normalize_entity_identity(&mut compiled);
274
275        // Inject synthetic Relay types (PageInfo, Node interface, XxxConnection, XxxEdge).
276        relay::inject_relay_types(&mut compiled)?;
277
278        // Compile rich filter types (EmailAddress, VIN, IBAN, etc.)
279        let rich_filter_config = RichFilterConfig::default();
280        compile_rich_filters(&mut compiled, &rich_filter_config)
281            .context("Failed to compile rich filter types")?;
282
283        // Inject the changelog GraphQL surface (EntityChangeLog / TransportCheckpoint
284        // types + cursor query + point lookup + upsert mutation) when opted in.
285        fraiseql_core::schema::inject_changelog(&mut compiled);
286
287        // Synthesize the typed cascade surface (CascadeNode interface + envelope
288        // types + per-mutation `<Name>Payload` wrappers) for mutations that opt in
289        // via `cascade = true`. Runs BEFORE error-union synthesis so a cascade
290        // payload becomes the success member of the result union
291        // (`<Name>Result = <Name>Payload | MutationError`). Inert with no cascade
292        // mutations.
293        cascade_types::synthesize_cascade_types(&mut compiled)?;
294
295        // Auto-synthesize a shared MutationError type + per-mutation result unions
296        // when opted in (`[fraiseql.mutations] auto_error_union`), so the runtime's
297        // success/error discrimination has a union to resolve against.
298        if options.auto_error_union {
299            mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
300        }
301
302        // IR honesty (#456): `parse_field_type` has no `Input` variant, so an
303        // argument referencing a declared input type was resolved to
304        // `FieldType::Object`. Rewrite those argument types to `FieldType::Input`
305        // now that every input type is registered (after relay / rich-filter /
306        // changelog / error-union injection) so the IR honestly represents the
307        // GraphQL input position — which also makes introspection report the correct
308        // `INPUT_OBJECT` kind for those args. The runtime already accepts both
309        // shapes, so this is purely representational.
310        Self::promote_input_type_args(&mut compiled);
311
312        // Validate the compiled schema
313        Self::validate(&compiled)?;
314
315        info!("Schema conversion successful");
316        Ok(compiled)
317    }
318
319    /// Rewrite mutation/query argument types of the form `Object(name)` (and lists
320    /// thereof) to `Input(name)` when `name` is a registered input type, so the IR
321    /// honestly represents input-position references (#456). Input type names are
322    /// disjoint from output type names per the GraphQL spec, so the lookup is exact
323    /// — an output-object argument is never misclassified.
324    fn promote_input_type_args(schema: &mut CompiledSchema) {
325        fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
326            match ft {
327                FieldType::Object(name) if input_names.contains(name) => {
328                    *ft = FieldType::Input(name.clone());
329                },
330                FieldType::List(inner) => promote(inner, input_names),
331                _ => {},
332            }
333        }
334
335        let input_names: HashSet<String> =
336            schema.input_types.iter().map(|i| i.name.clone()).collect();
337        if input_names.is_empty() {
338            return;
339        }
340
341        for mutation in &mut schema.mutations {
342            for arg in &mut mutation.arguments {
343                promote(&mut arg.arg_type, &input_names);
344            }
345        }
346        for query in &mut schema.queries {
347            for arg in &mut query.arguments {
348                promote(&mut arg.arg_type, &input_names);
349            }
350        }
351    }
352
353    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many field-level checks
354    fn validate(schema: &CompiledSchema) -> Result<()> {
355        info!("Validating compiled schema");
356
357        // Build type registry
358        let mut type_names: HashSet<String> = HashSet::new();
359        for type_def in &schema.types {
360            type_names.insert(type_def.name.to_string());
361        }
362
363        // Build interface registry. Interfaces are *also* valid query/mutation
364        // return types (a field may return an interface, narrowed via inline
365        // fragments), so register them in `type_names` too — previously omitted,
366        // which made a query/mutation returning an interface silently fail the
367        // reference check below.
368        let mut interface_names = HashSet::new();
369        for interface_def in &schema.interfaces {
370            interface_names.insert(interface_def.name.clone());
371            type_names.insert(interface_def.name.clone());
372        }
373
374        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
375        for input_type in &schema.input_types {
376            type_names.insert(input_type.name.clone());
377        }
378
379        // Add union type names — valid as mutation/query return types
380        for union_def in &schema.unions {
381            type_names.insert(union_def.name.clone());
382        }
383
384        // Add built-in scalars
385        for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
386            type_names.insert((*scalar).to_string());
387        }
388
389        // Collect custom scalars implicitly: any type name used in object field definitions
390        // (e.g. IPAddress, Hostname, MACAddress, CIDR, Money, etc.)
391        for type_def in &schema.types {
392            for field in &type_def.fields {
393                let base = Self::extract_type_name(&field.field_type);
394                type_names.insert(base);
395            }
396        }
397
398        // Validate queries
399        for query in &schema.queries {
400            if !type_names.contains(&query.return_type) {
401                warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
402                anyhow::bail!(
403                    "Query '{}' references unknown type '{}'",
404                    query.name,
405                    query.return_type
406                );
407            }
408
409            // Validate argument types
410            for arg in &query.arguments {
411                let type_name = Self::extract_type_name(&arg.arg_type);
412                if !type_names.contains(&type_name) {
413                    warn!(
414                        "Query '{}' argument '{}' references unknown type: {}",
415                        query.name, arg.name, type_name
416                    );
417                    anyhow::bail!(
418                        "Query '{}' argument '{}' references unknown type '{}'",
419                        query.name,
420                        arg.name,
421                        type_name
422                    );
423                }
424            }
425        }
426
427        // Validate mutations
428        for mutation in &schema.mutations {
429            if !type_names.contains(&mutation.return_type) {
430                warn!(
431                    "Mutation '{}' references unknown type: {}",
432                    mutation.name, mutation.return_type
433                );
434                anyhow::bail!(
435                    "Mutation '{}' references unknown type '{}'",
436                    mutation.name,
437                    mutation.return_type
438                );
439            }
440
441            // Validate argument types
442            for arg in &mutation.arguments {
443                let type_name = Self::extract_type_name(&arg.arg_type);
444                if !type_names.contains(&type_name) {
445                    warn!(
446                        "Mutation '{}' argument '{}' references unknown type: {}",
447                        mutation.name, arg.name, type_name
448                    );
449                    anyhow::bail!(
450                        "Mutation '{}' argument '{}' references unknown type '{}'",
451                        mutation.name,
452                        arg.name,
453                        type_name
454                    );
455                }
456            }
457        }
458
459        // Validate interface implementations
460        for type_def in &schema.types {
461            for interface_name in &type_def.implements {
462                if !interface_names.contains(interface_name) {
463                    warn!(
464                        "Type '{}' implements unknown interface: {}",
465                        type_def.name, interface_name
466                    );
467                    anyhow::bail!(
468                        "Type '{}' implements unknown interface '{}'",
469                        type_def.name,
470                        interface_name
471                    );
472                }
473
474                // Validate that the type has all fields required by the interface
475                if let Some(interface) = schema.find_interface(interface_name) {
476                    for interface_field in &interface.fields {
477                        let type_has_field = type_def.fields.iter().any(|f| {
478                            f.name == interface_field.name
479                                && f.field_type == interface_field.field_type
480                        });
481                        if !type_has_field {
482                            warn!(
483                                "Type '{}' implements interface '{}' but is missing field: {}",
484                                type_def.name, interface_name, interface_field.name
485                            );
486                            anyhow::bail!(
487                                "Type '{}' implements interface '{}' but is missing field '{}'",
488                                type_def.name,
489                                interface_name,
490                                interface_field.name
491                            );
492                        }
493                    }
494                }
495            }
496        }
497
498        info!("Schema validation passed");
499        Ok(())
500    }
501
502    /// Extract type name from `FieldType` for validation
503    ///
504    /// Built-in types return their scalar name, Object types return the object name
505    fn extract_type_name(field_type: &FieldType) -> String {
506        match field_type {
507            FieldType::String => "String".to_string(),
508            FieldType::Int => "Int".to_string(),
509            FieldType::Float => "Float".to_string(),
510            FieldType::Boolean => "Boolean".to_string(),
511            FieldType::Id => "ID".to_string(),
512            FieldType::DateTime => "DateTime".to_string(),
513            FieldType::Date => "Date".to_string(),
514            FieldType::Time => "Time".to_string(),
515            FieldType::Json => "Json".to_string(),
516            FieldType::Uuid => "UUID".to_string(),
517            FieldType::Decimal => "Decimal".to_string(),
518            FieldType::Vector => "Vector".to_string(),
519            FieldType::Scalar(name) => name.clone(),
520            FieldType::Object(name) => name.clone(),
521            FieldType::Enum(name) => name.clone(),
522            FieldType::Input(name) => name.clone(),
523            FieldType::Interface(name) => name.clone(),
524            FieldType::Union(name) => name.clone(),
525            FieldType::List(inner) => Self::extract_type_name(inner),
526            // Reason: non_exhaustive requires catch-all for cross-crate matches
527            _ => "Unknown".to_string(),
528        }
529    }
530
531    /// Convert `IntermediateFactTable` to `FactTableMetadata`.
532    fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
533        FactTableMetadata {
534            table_name:               ft.table_name,
535            measures:                 ft
536                .measures
537                .into_iter()
538                .map(|m| MeasureColumn {
539                    name:     m.name,
540                    sql_type: Self::parse_sql_type(&m.sql_type),
541                    nullable: m.nullable,
542                })
543                .collect(),
544            dimensions:               DimensionColumn {
545                name:  ft.dimensions.name,
546                paths: ft
547                    .dimensions
548                    .paths
549                    .into_iter()
550                    .map(|p| DimensionPath {
551                        name:      p.name,
552                        json_path: p.json_path,
553                        data_type: p.data_type,
554                    })
555                    .collect(),
556            },
557            denormalized_filters:     ft
558                .denormalized_filters
559                .into_iter()
560                .map(|f| FilterColumn {
561                    name:     f.name,
562                    sql_type: Self::parse_sql_type(&f.sql_type),
563                    indexed:  f.indexed,
564                })
565                .collect(),
566            calendar_dimensions:      vec![],
567            partial_period:           None,
568            native_measures:          ft.native_measures,
569            native_dimension_mapping: ft.native_dimension_mapping,
570        }
571    }
572
573    /// Parse a SQL type string into a `SqlType` enum variant.
574    fn parse_sql_type(s: &str) -> SqlType {
575        match s.to_uppercase().as_str() {
576            "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
577            "BIGINT" | "INT8" => SqlType::BigInt,
578            "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
579            "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
580                SqlType::Float
581            },
582            "JSONB" => SqlType::Jsonb,
583            "JSON" => SqlType::Json,
584            "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
585            "UUID" => SqlType::Uuid,
586            "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
587                SqlType::Timestamp
588            },
589            "DATE" => SqlType::Date,
590            "BOOLEAN" | "BOOL" => SqlType::Boolean,
591            _ => SqlType::Other(s.to_string()),
592        }
593    }
594}