fraiseql_cli/schema/converter/
mod.rs1mod 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
32pub struct SchemaConverter;
34
35impl SchemaConverter {
36 pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
51 info!("Converting intermediate schema to compiled format");
52
53 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 let defaults = intermediate.query_defaults.unwrap_or_default();
65
66 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 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 let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
84
85 let input_types = intermediate
87 .input_types
88 .into_iter()
89 .map(Self::convert_input_object)
90 .collect::<Vec<_>>();
91
92 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 let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
102
103 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 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 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, observers: Vec::new(), 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, validation_config: intermediate.validation_config, debug_config: intermediate.debug_config, mcp_config: intermediate.mcp_config, schema_sdl: None, custom_scalars: CustomTypeRegistry::default(), schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
168 ..Default::default()
169 };
170
171 if let Some(custom_scalars_vec) = intermediate.custom_scalars {
173 for scalar_def in custom_scalars_vec {
174 let custom_type = Self::convert_custom_scalar(scalar_def)?;
175 compiled
176 .custom_scalars
177 .register(custom_type.name.clone(), custom_type)
178 .context("Failed to register custom scalar")?;
179 }
180 }
181
182 relay::inject_relay_types(&mut compiled);
184
185 let rich_filter_config = RichFilterConfig::default();
187 compile_rich_filters(&mut compiled, &rich_filter_config)
188 .context("Failed to compile rich filter types")?;
189
190 Self::validate(&compiled)?;
192
193 info!("Schema conversion successful");
194 Ok(compiled)
195 }
196
197 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
199 info!("Validating compiled schema");
200
201 let mut type_names: HashSet<String> = HashSet::new();
203 for type_def in &schema.types {
204 type_names.insert(type_def.name.to_string());
205 }
206
207 let mut interface_names = HashSet::new();
209 for interface_def in &schema.interfaces {
210 interface_names.insert(interface_def.name.clone());
211 }
212
213 for input_type in &schema.input_types {
215 type_names.insert(input_type.name.clone());
216 }
217
218 type_names.insert("Int".to_string());
220 type_names.insert("Float".to_string());
221 type_names.insert("String".to_string());
222 type_names.insert("Boolean".to_string());
223 type_names.insert("ID".to_string());
224
225 for query in &schema.queries {
227 if !type_names.contains(&query.return_type) {
228 warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
229 anyhow::bail!(
230 "Query '{}' references unknown type '{}'",
231 query.name,
232 query.return_type
233 );
234 }
235
236 for arg in &query.arguments {
238 let type_name = Self::extract_type_name(&arg.arg_type);
239 if !type_names.contains(&type_name) {
240 anyhow::bail!(
241 "Query '{}' argument '{}' references unknown type '{}'",
242 query.name,
243 arg.name,
244 type_name
245 );
246 }
247 }
248 }
249
250 for mutation in &schema.mutations {
252 if !type_names.contains(&mutation.return_type) {
253 anyhow::bail!(
254 "Mutation '{}' references unknown type '{}'",
255 mutation.name,
256 mutation.return_type
257 );
258 }
259
260 for arg in &mutation.arguments {
262 let type_name = Self::extract_type_name(&arg.arg_type);
263 if !type_names.contains(&type_name) {
264 anyhow::bail!(
265 "Mutation '{}' argument '{}' references unknown type '{}'",
266 mutation.name,
267 arg.name,
268 type_name
269 );
270 }
271 }
272 }
273
274 for type_def in &schema.types {
276 for interface_name in &type_def.implements {
277 if !interface_names.contains(interface_name) {
278 anyhow::bail!(
279 "Type '{}' implements unknown interface '{}'",
280 type_def.name,
281 interface_name
282 );
283 }
284
285 if let Some(interface) = schema.find_interface(interface_name) {
287 for interface_field in &interface.fields {
288 let type_has_field = type_def.fields.iter().any(|f| {
289 f.name == interface_field.name
290 && f.field_type == interface_field.field_type
291 });
292 if !type_has_field {
293 anyhow::bail!(
294 "Type '{}' implements interface '{}' but is missing field '{}'",
295 type_def.name,
296 interface_name,
297 interface_field.name
298 );
299 }
300 }
301 }
302 }
303 }
304
305 info!("Schema validation passed");
306 Ok(())
307 }
308
309 fn extract_type_name(field_type: &FieldType) -> String {
313 match field_type {
314 FieldType::String => "String".to_string(),
315 FieldType::Int => "Int".to_string(),
316 FieldType::Float => "Float".to_string(),
317 FieldType::Boolean => "Boolean".to_string(),
318 FieldType::Id => "ID".to_string(),
319 FieldType::DateTime => "DateTime".to_string(),
320 FieldType::Date => "Date".to_string(),
321 FieldType::Time => "Time".to_string(),
322 FieldType::Json => "Json".to_string(),
323 FieldType::Uuid => "UUID".to_string(),
324 FieldType::Decimal => "Decimal".to_string(),
325 FieldType::Vector => "Vector".to_string(),
326 FieldType::Scalar(name) => name.clone(),
327 FieldType::Object(name) => name.clone(),
328 FieldType::Enum(name) => name.clone(),
329 FieldType::Input(name) => name.clone(),
330 FieldType::Interface(name) => name.clone(),
331 FieldType::Union(name) => name.clone(),
332 FieldType::List(inner) => Self::extract_type_name(inner),
333 _ => "Unknown".to_string(),
335 }
336 }
337
338 fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
340 FactTableMetadata {
341 table_name: ft.table_name,
342 measures: ft
343 .measures
344 .into_iter()
345 .map(|m| MeasureColumn {
346 name: m.name,
347 sql_type: Self::parse_sql_type(&m.sql_type),
348 nullable: m.nullable,
349 })
350 .collect(),
351 dimensions: DimensionColumn {
352 name: ft.dimensions.name,
353 paths: ft
354 .dimensions
355 .paths
356 .into_iter()
357 .map(|p| DimensionPath {
358 name: p.name,
359 json_path: p.json_path,
360 data_type: p.data_type,
361 })
362 .collect(),
363 },
364 denormalized_filters: ft
365 .denormalized_filters
366 .into_iter()
367 .map(|f| FilterColumn {
368 name: f.name,
369 sql_type: Self::parse_sql_type(&f.sql_type),
370 indexed: f.indexed,
371 })
372 .collect(),
373 calendar_dimensions: vec![],
374 }
375 }
376
377 fn parse_sql_type(s: &str) -> SqlType {
379 match s.to_uppercase().as_str() {
380 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
381 "BIGINT" | "INT8" => SqlType::BigInt,
382 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
383 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
384 SqlType::Float
385 },
386 "JSONB" => SqlType::Jsonb,
387 "JSON" => SqlType::Json,
388 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
389 "UUID" => SqlType::Uuid,
390 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
391 SqlType::Timestamp
392 },
393 "DATE" => SqlType::Date,
394 "BOOLEAN" | "BOOL" => SqlType::Boolean,
395 _ => SqlType::Other(s.to_string()),
396 }
397 }
398}