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