fraiseql_cli/schema/converter/
mod.rs1mod 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
33pub struct SchemaConverter;
35
36impl SchemaConverter {
37 pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
52 info!("Converting intermediate schema to compiled format");
53
54 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 let defaults = intermediate.query_defaults.unwrap_or_default();
66
67 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 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 let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
85
86 let input_types = intermediate
88 .input_types
89 .into_iter()
90 .map(Self::convert_input_object)
91 .collect::<Vec<_>>();
92
93 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 let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
103
104 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 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 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, observers: Vec::new(), 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, validation_config: intermediate.validation_config, debug_config: intermediate.debug_config, mcp_config: intermediate.mcp_config, rest_config: intermediate.rest_config, naming_convention: intermediate.naming_convention, session_variables: intermediate.session_variables.unwrap_or_default(),
169 hierarchies_config: intermediate.hierarchies_config,
170 schema_sdl: None, custom_scalars: CustomTypeRegistry::default(), schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
173 ..Default::default()
174 };
175
176 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 relay::inject_relay_types(&mut compiled);
189
190 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 Self::validate(&compiled)?;
197
198 info!("Schema conversion successful");
199 Ok(compiled)
200 }
201
202 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
204 info!("Validating compiled schema");
205
206 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 let mut interface_names = HashSet::new();
214 for interface_def in &schema.interfaces {
215 interface_names.insert(interface_def.name.clone());
216 }
217
218 for input_type in &schema.input_types {
220 type_names.insert(input_type.name.clone());
221 }
222
223 for union_def in &schema.unions {
225 type_names.insert(union_def.name.clone());
226 }
227
228 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
230 type_names.insert((*scalar).to_string());
231 }
232
233 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 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 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 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 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 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 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 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 _ => "Unknown".to_string(),
352 }
353 }
354
355 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 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}