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