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, naming_convention: intermediate.naming_convention, session_variables: intermediate.session_variables.unwrap_or_default(),
167 schema_sdl: None, custom_scalars: CustomTypeRegistry::default(), schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
170 ..Default::default()
171 };
172
173 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 relay::inject_relay_types(&mut compiled);
186
187 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 Self::validate(&compiled)?;
194
195 info!("Schema conversion successful");
196 Ok(compiled)
197 }
198
199 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
201 info!("Validating compiled schema");
202
203 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 let mut interface_names = HashSet::new();
211 for interface_def in &schema.interfaces {
212 interface_names.insert(interface_def.name.clone());
213 }
214
215 for input_type in &schema.input_types {
217 type_names.insert(input_type.name.clone());
218 }
219
220 for union_def in &schema.unions {
222 type_names.insert(union_def.name.clone());
223 }
224
225 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
227 type_names.insert((*scalar).to_string());
228 }
229
230 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 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 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 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 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 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 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 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 _ => "Unknown".to_string(),
349 }
350 }
351
352 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 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}