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