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 type_names.insert("Int".to_string());
215 type_names.insert("Float".to_string());
216 type_names.insert("String".to_string());
217 type_names.insert("Boolean".to_string());
218 type_names.insert("ID".to_string());
219
220 for query in &schema.queries {
222 if !type_names.contains(&query.return_type) {
223 warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
224 anyhow::bail!(
225 "Query '{}' references unknown type '{}'",
226 query.name,
227 query.return_type
228 );
229 }
230
231 for arg in &query.arguments {
233 let type_name = Self::extract_type_name(&arg.arg_type);
234 if !type_names.contains(&type_name) {
235 anyhow::bail!(
236 "Query '{}' argument '{}' references unknown type '{}'",
237 query.name,
238 arg.name,
239 type_name
240 );
241 }
242 }
243 }
244
245 for mutation in &schema.mutations {
247 if !type_names.contains(&mutation.return_type) {
248 anyhow::bail!(
249 "Mutation '{}' references unknown type '{}'",
250 mutation.name,
251 mutation.return_type
252 );
253 }
254
255 for arg in &mutation.arguments {
257 let type_name = Self::extract_type_name(&arg.arg_type);
258 if !type_names.contains(&type_name) {
259 anyhow::bail!(
260 "Mutation '{}' argument '{}' references unknown type '{}'",
261 mutation.name,
262 arg.name,
263 type_name
264 );
265 }
266 }
267 }
268
269 for type_def in &schema.types {
271 for interface_name in &type_def.implements {
272 if !interface_names.contains(interface_name) {
273 anyhow::bail!(
274 "Type '{}' implements unknown interface '{}'",
275 type_def.name,
276 interface_name
277 );
278 }
279
280 if let Some(interface) = schema.find_interface(interface_name) {
282 for interface_field in &interface.fields {
283 let type_has_field = type_def.fields.iter().any(|f| {
284 f.name == interface_field.name
285 && f.field_type == interface_field.field_type
286 });
287 if !type_has_field {
288 anyhow::bail!(
289 "Type '{}' implements interface '{}' but is missing field '{}'",
290 type_def.name,
291 interface_name,
292 interface_field.name
293 );
294 }
295 }
296 }
297 }
298 }
299
300 info!("Schema validation passed");
301 Ok(())
302 }
303
304 fn extract_type_name(field_type: &FieldType) -> String {
308 match field_type {
309 FieldType::String => "String".to_string(),
310 FieldType::Int => "Int".to_string(),
311 FieldType::Float => "Float".to_string(),
312 FieldType::Boolean => "Boolean".to_string(),
313 FieldType::Id => "ID".to_string(),
314 FieldType::DateTime => "DateTime".to_string(),
315 FieldType::Date => "Date".to_string(),
316 FieldType::Time => "Time".to_string(),
317 FieldType::Json => "Json".to_string(),
318 FieldType::Uuid => "UUID".to_string(),
319 FieldType::Decimal => "Decimal".to_string(),
320 FieldType::Vector => "Vector".to_string(),
321 FieldType::Scalar(name) => name.clone(),
322 FieldType::Object(name) => name.clone(),
323 FieldType::Enum(name) => name.clone(),
324 FieldType::Input(name) => name.clone(),
325 FieldType::Interface(name) => name.clone(),
326 FieldType::Union(name) => name.clone(),
327 FieldType::List(inner) => Self::extract_type_name(inner),
328 _ => "Unknown".to_string(),
330 }
331 }
332
333 fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
335 FactTableMetadata {
336 table_name: ft.table_name,
337 measures: ft
338 .measures
339 .into_iter()
340 .map(|m| MeasureColumn {
341 name: m.name,
342 sql_type: Self::parse_sql_type(&m.sql_type),
343 nullable: m.nullable,
344 })
345 .collect(),
346 dimensions: DimensionColumn {
347 name: ft.dimensions.name,
348 paths: ft
349 .dimensions
350 .paths
351 .into_iter()
352 .map(|p| DimensionPath {
353 name: p.name,
354 json_path: p.json_path,
355 data_type: p.data_type,
356 })
357 .collect(),
358 },
359 denormalized_filters: ft
360 .denormalized_filters
361 .into_iter()
362 .map(|f| FilterColumn {
363 name: f.name,
364 sql_type: Self::parse_sql_type(&f.sql_type),
365 indexed: f.indexed,
366 })
367 .collect(),
368 calendar_dimensions: vec![],
369 }
370 }
371
372 fn parse_sql_type(s: &str) -> SqlType {
374 match s.to_uppercase().as_str() {
375 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
376 "BIGINT" | "INT8" => SqlType::BigInt,
377 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
378 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
379 SqlType::Float
380 },
381 "JSONB" => SqlType::Jsonb,
382 "JSON" => SqlType::Json,
383 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
384 "UUID" => SqlType::Uuid,
385 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
386 SqlType::Timestamp
387 },
388 "DATE" => SqlType::Date,
389 "BOOLEAN" | "BOOL" => SqlType::Boolean,
390 _ => SqlType::Other(s.to_string()),
391 }
392 }
393}