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 mutations;
7mod queries;
8mod relay;
9mod subscriptions;
10pub(crate) mod tenancy;
11mod types;
12
13#[cfg(test)]
14mod tests;
15
16use std::collections::HashSet;
17
18use anyhow::{Context, Result};
19use fraiseql_core::{
20    compiler::fact_table::{
21        DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
22    },
23    schema::{CompiledSchema, FieldType},
24    validation::CustomTypeRegistry,
25};
26use tracing::{info, warn};
27
28use super::{
29    intermediate::{IntermediateFactTable, IntermediateSchema},
30    rich_filters::{RichFilterConfig, compile_rich_filters},
31};
32
33/// Converts intermediate format to compiled format
34pub struct SchemaConverter;
35
36impl SchemaConverter {
37    /// Convert `IntermediateSchema` to `CompiledSchema`
38    ///
39    /// This performs:
40    /// 1. Type conversion (intermediate types → compiled types)
41    /// 2. Field name normalization (type → `field_type`)
42    /// 3. Validation (type references, circular refs, etc.)
43    /// 4. Optimization
44    ///
45    /// # Errors
46    ///
47    /// Returns an error if any type, query, mutation, interface, subscription,
48    /// or directive conversion fails, if federation/security/observer config JSON
49    /// cannot be deserialized, or if compiled schema validation detects unknown
50    /// type references.
51    pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
52        info!("Converting intermediate schema to compiled format");
53
54        // Convert types
55        let types = intermediate
56            .types
57            .into_iter()
58            .map(Self::convert_type)
59            .collect::<Result<Vec<_>>>()
60            .context("Failed to convert types")?;
61
62        // Extract query_defaults before consuming intermediate.queries.
63        // unwrap_or_default() → all-true, matching historical behaviour when no
64        // [query_defaults] section is present in fraiseql.toml.
65        let defaults = intermediate.query_defaults.unwrap_or_default();
66
67        // Convert queries
68        let queries = intermediate
69            .queries
70            .into_iter()
71            .map(|q| Self::convert_query(q, &defaults))
72            .collect::<Result<Vec<_>>>()
73            .context("Failed to convert queries")?;
74
75        // Convert mutations
76        let mutations = intermediate
77            .mutations
78            .into_iter()
79            .map(Self::convert_mutation)
80            .collect::<Result<Vec<_>>>()
81            .context("Failed to convert mutations")?;
82
83        // Convert enums
84        let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
85
86        // Convert input types
87        let input_types = intermediate
88            .input_types
89            .into_iter()
90            .map(Self::convert_input_object)
91            .collect::<Vec<_>>();
92
93        // Convert interfaces
94        let interfaces = intermediate
95            .interfaces
96            .into_iter()
97            .map(Self::convert_interface)
98            .collect::<Result<Vec<_>>>()
99            .context("Failed to convert interfaces")?;
100
101        // Convert unions
102        let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
103
104        // Convert subscriptions
105        let subscriptions = intermediate
106            .subscriptions
107            .into_iter()
108            .map(Self::convert_subscription)
109            .collect::<Result<Vec<_>>>()
110            .context("Failed to convert subscriptions")?;
111
112        // Convert custom directives
113        let directives = intermediate
114            .directives
115            .unwrap_or_default()
116            .into_iter()
117            .map(Self::convert_directive)
118            .collect::<Result<Vec<_>>>()
119            .context("Failed to convert directives")?;
120
121        // Convert fact tables from Vec<IntermediateFactTable> to HashMap<String, FactTableMetadata>
122        let fact_tables = intermediate
123            .fact_tables
124            .unwrap_or_default()
125            .into_iter()
126            .map(|ft| {
127                let name = ft.table_name.clone();
128                let metadata = Self::convert_fact_table(ft);
129                (name, metadata)
130            })
131            .collect();
132
133        let mut compiled = CompiledSchema {
134            types,
135            enums,
136            input_types,
137            interfaces,
138            unions,
139            queries,
140            mutations,
141            subscriptions,
142            directives,
143            fact_tables, // Analytics metadata
144            observers: Vec::new(), /* Observer definitions (populated from
145                          * IntermediateSchema) */
146            federation: intermediate
147                .federation_config
148                .map(serde_json::from_value)
149                .transpose()
150                .context("federation_config: invalid JSON structure")?,
151            security: intermediate
152                .security
153                .map(serde_json::from_value)
154                .transpose()
155                .context("security: invalid JSON structure")?,
156            observers_config: intermediate
157                .observers_config
158                .map(serde_json::from_value)
159                .transpose()
160                .context("observers_config: invalid JSON structure")?,
161            subscriptions_config: intermediate.subscriptions_config, /* Subscriptions config from
162                                                                      * TOML */
163            validation_config: intermediate.validation_config, // Validation limits from TOML
164            debug_config: intermediate.debug_config,           // Debug config from TOML
165            mcp_config: intermediate.mcp_config,               // MCP config from TOML
166            rest_config: intermediate.rest_config,             // REST config from TOML
167            naming_convention: intermediate.naming_convention, // Naming convention from TOML
168            session_variables: intermediate.session_variables.unwrap_or_default(),
169            hierarchies_config: intermediate.hierarchies_config,
170            schema_sdl: None,                              // Raw GraphQL SDL
171            custom_scalars: CustomTypeRegistry::default(), // Custom scalar registry
172            schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
173            ..Default::default()
174        };
175
176        // Populate custom scalars from intermediate schema
177        if let Some(custom_scalars_vec) = intermediate.custom_scalars {
178            for scalar_def in custom_scalars_vec {
179                let custom_type = Self::convert_custom_scalar(scalar_def)?;
180                compiled
181                    .custom_scalars
182                    .register(custom_type.name.clone(), custom_type)
183                    .context("Failed to register custom scalar")?;
184            }
185        }
186
187        // Inject synthetic Relay types (PageInfo, Node interface, XxxConnection, XxxEdge).
188        relay::inject_relay_types(&mut compiled);
189
190        // Compile rich filter types (EmailAddress, VIN, IBAN, etc.)
191        let rich_filter_config = RichFilterConfig::default();
192        compile_rich_filters(&mut compiled, &rich_filter_config)
193            .context("Failed to compile rich filter types")?;
194
195        // Validate the compiled schema
196        Self::validate(&compiled)?;
197
198        info!("Schema conversion successful");
199        Ok(compiled)
200    }
201
202    #[allow(clippy::cognitive_complexity)] // Reason: comprehensive schema validation with many field-level checks
203    fn validate(schema: &CompiledSchema) -> Result<()> {
204        info!("Validating compiled schema");
205
206        // Build type registry
207        let mut type_names: HashSet<String> = HashSet::new();
208        for type_def in &schema.types {
209            type_names.insert(type_def.name.to_string());
210        }
211
212        // Build interface registry
213        let mut interface_names = HashSet::new();
214        for interface_def in &schema.interfaces {
215            interface_names.insert(interface_def.name.clone());
216        }
217
218        // Add input types — valid as mutation argument types (fraiseql/fraiseql#190)
219        for input_type in &schema.input_types {
220            type_names.insert(input_type.name.clone());
221        }
222
223        // Add union type names — valid as mutation/query return types
224        for union_def in &schema.unions {
225            type_names.insert(union_def.name.clone());
226        }
227
228        // Add built-in scalars
229        for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
230            type_names.insert((*scalar).to_string());
231        }
232
233        // Collect custom scalars implicitly: any type name used in object field definitions
234        // (e.g. IPAddress, Hostname, MACAddress, CIDR, Money, etc.)
235        for type_def in &schema.types {
236            for field in &type_def.fields {
237                let base = Self::extract_type_name(&field.field_type);
238                type_names.insert(base);
239            }
240        }
241
242        // Validate queries
243        for query in &schema.queries {
244            if !type_names.contains(&query.return_type) {
245                warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
246                anyhow::bail!(
247                    "Query '{}' references unknown type '{}'",
248                    query.name,
249                    query.return_type
250                );
251            }
252
253            // Validate argument types
254            for arg in &query.arguments {
255                let type_name = Self::extract_type_name(&arg.arg_type);
256                if !type_names.contains(&type_name) {
257                    anyhow::bail!(
258                        "Query '{}' argument '{}' references unknown type '{}'",
259                        query.name,
260                        arg.name,
261                        type_name
262                    );
263                }
264            }
265        }
266
267        // Validate mutations
268        for mutation in &schema.mutations {
269            if !type_names.contains(&mutation.return_type) {
270                anyhow::bail!(
271                    "Mutation '{}' references unknown type '{}'",
272                    mutation.name,
273                    mutation.return_type
274                );
275            }
276
277            // Validate argument types
278            for arg in &mutation.arguments {
279                let type_name = Self::extract_type_name(&arg.arg_type);
280                if !type_names.contains(&type_name) {
281                    anyhow::bail!(
282                        "Mutation '{}' argument '{}' references unknown type '{}'",
283                        mutation.name,
284                        arg.name,
285                        type_name
286                    );
287                }
288            }
289        }
290
291        // Validate interface implementations
292        for type_def in &schema.types {
293            for interface_name in &type_def.implements {
294                if !interface_names.contains(interface_name) {
295                    anyhow::bail!(
296                        "Type '{}' implements unknown interface '{}'",
297                        type_def.name,
298                        interface_name
299                    );
300                }
301
302                // Validate that the type has all fields required by the interface
303                if let Some(interface) = schema.find_interface(interface_name) {
304                    for interface_field in &interface.fields {
305                        let type_has_field = type_def.fields.iter().any(|f| {
306                            f.name == interface_field.name
307                                && f.field_type == interface_field.field_type
308                        });
309                        if !type_has_field {
310                            anyhow::bail!(
311                                "Type '{}' implements interface '{}' but is missing field '{}'",
312                                type_def.name,
313                                interface_name,
314                                interface_field.name
315                            );
316                        }
317                    }
318                }
319            }
320        }
321
322        info!("Schema validation passed");
323        Ok(())
324    }
325
326    /// Extract type name from `FieldType` for validation
327    ///
328    /// Built-in types return their scalar name, Object types return the object name
329    fn extract_type_name(field_type: &FieldType) -> String {
330        match field_type {
331            FieldType::String => "String".to_string(),
332            FieldType::Int => "Int".to_string(),
333            FieldType::Float => "Float".to_string(),
334            FieldType::Boolean => "Boolean".to_string(),
335            FieldType::Id => "ID".to_string(),
336            FieldType::DateTime => "DateTime".to_string(),
337            FieldType::Date => "Date".to_string(),
338            FieldType::Time => "Time".to_string(),
339            FieldType::Json => "Json".to_string(),
340            FieldType::Uuid => "UUID".to_string(),
341            FieldType::Decimal => "Decimal".to_string(),
342            FieldType::Vector => "Vector".to_string(),
343            FieldType::Scalar(name) => name.clone(),
344            FieldType::Object(name) => name.clone(),
345            FieldType::Enum(name) => name.clone(),
346            FieldType::Input(name) => name.clone(),
347            FieldType::Interface(name) => name.clone(),
348            FieldType::Union(name) => name.clone(),
349            FieldType::List(inner) => Self::extract_type_name(inner),
350            // Reason: non_exhaustive requires catch-all for cross-crate matches
351            _ => "Unknown".to_string(),
352        }
353    }
354
355    /// Convert `IntermediateFactTable` to `FactTableMetadata`.
356    fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
357        FactTableMetadata {
358            table_name:               ft.table_name,
359            measures:                 ft
360                .measures
361                .into_iter()
362                .map(|m| MeasureColumn {
363                    name:     m.name,
364                    sql_type: Self::parse_sql_type(&m.sql_type),
365                    nullable: m.nullable,
366                })
367                .collect(),
368            dimensions:               DimensionColumn {
369                name:  ft.dimensions.name,
370                paths: ft
371                    .dimensions
372                    .paths
373                    .into_iter()
374                    .map(|p| DimensionPath {
375                        name:      p.name,
376                        json_path: p.json_path,
377                        data_type: p.data_type,
378                    })
379                    .collect(),
380            },
381            denormalized_filters:     ft
382                .denormalized_filters
383                .into_iter()
384                .map(|f| FilterColumn {
385                    name:     f.name,
386                    sql_type: Self::parse_sql_type(&f.sql_type),
387                    indexed:  f.indexed,
388                })
389                .collect(),
390            calendar_dimensions:      vec![],
391            partial_period:           None,
392            native_measures:          ft.native_measures,
393            native_dimension_mapping: ft.native_dimension_mapping,
394        }
395    }
396
397    /// Parse a SQL type string into a `SqlType` enum variant.
398    fn parse_sql_type(s: &str) -> SqlType {
399        match s.to_uppercase().as_str() {
400            "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
401            "BIGINT" | "INT8" => SqlType::BigInt,
402            "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
403            "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
404                SqlType::Float
405            },
406            "JSONB" => SqlType::Jsonb,
407            "JSON" => SqlType::Json,
408            "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
409            "UUID" => SqlType::Uuid,
410            "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
411                SqlType::Timestamp
412            },
413            "DATE" => SqlType::Date,
414            "BOOLEAN" | "BOOL" => SqlType::Boolean,
415            _ => SqlType::Other(s.to_string()),
416        }
417    }
418}