fraiseql_cli/schema/converter/
mod.rs1mod directives;
6mod mutation_error_union;
7mod mutations;
8mod queries;
9mod relay;
10mod subscriptions;
11pub(crate) mod tenancy;
12mod types;
13
14#[cfg(test)]
15mod tests;
16
17use std::collections::HashSet;
18
19use anyhow::{Context, Result};
20use fraiseql_core::{
21 compiler::fact_table::{
22 DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
23 },
24 schema::{CompiledSchema, FieldType},
25 validation::CustomTypeRegistry,
26};
27use tracing::{info, warn};
28
29use super::{
30 intermediate::{IntermediateFactTable, IntermediateSchema},
31 rich_filters::{RichFilterConfig, compile_rich_filters},
32};
33
34pub struct SchemaConverter;
36
37#[derive(Debug, Clone, Default)]
43pub struct ConvertOptions {
44 pub auto_error_union: bool,
48}
49
50impl SchemaConverter {
51 pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
57 Self::convert_with_options(intermediate, &ConvertOptions::default())
58 }
59
60 pub fn convert_with_options(
75 intermediate: IntermediateSchema,
76 options: &ConvertOptions,
77 ) -> Result<CompiledSchema> {
78 info!("Converting intermediate schema to compiled format");
79
80 let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
84 .types
85 .iter()
86 .filter_map(|t| {
87 t.subscribable_tables
88 .as_ref()
89 .filter(|tables| !tables.is_empty())
90 .map(|tables| fraiseql_core::schema::SubscribableEntity {
91 entity_type: t.name.clone(),
92 tables: tables.clone(),
93 pre_image: t.subscribable_pre_image,
94 })
95 })
96 .collect();
97
98 let types = intermediate
100 .types
101 .into_iter()
102 .map(Self::convert_type)
103 .collect::<Result<Vec<_>>>()
104 .context("Failed to convert types")?;
105
106 let defaults = intermediate.query_defaults.unwrap_or_default();
110
111 let queries = intermediate
113 .queries
114 .into_iter()
115 .map(|q| Self::convert_query(q, &defaults))
116 .collect::<Result<Vec<_>>>()
117 .context("Failed to convert queries")?;
118
119 let mutations = intermediate
121 .mutations
122 .into_iter()
123 .map(Self::convert_mutation)
124 .collect::<Result<Vec<_>>>()
125 .context("Failed to convert mutations")?;
126
127 let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
129
130 let input_types = intermediate
132 .input_types
133 .into_iter()
134 .map(Self::convert_input_object)
135 .collect::<Vec<_>>();
136
137 let interfaces = intermediate
139 .interfaces
140 .into_iter()
141 .map(Self::convert_interface)
142 .collect::<Result<Vec<_>>>()
143 .context("Failed to convert interfaces")?;
144
145 let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
147
148 let subscriptions = intermediate
150 .subscriptions
151 .into_iter()
152 .map(Self::convert_subscription)
153 .collect::<Result<Vec<_>>>()
154 .context("Failed to convert subscriptions")?;
155
156 let directives = intermediate
158 .directives
159 .unwrap_or_default()
160 .into_iter()
161 .map(Self::convert_directive)
162 .collect::<Result<Vec<_>>>()
163 .context("Failed to convert directives")?;
164
165 let fact_tables = intermediate
167 .fact_tables
168 .unwrap_or_default()
169 .into_iter()
170 .map(|ft| {
171 let name = ft.table_name.clone();
172 let metadata = Self::convert_fact_table(ft);
173 (name, metadata)
174 })
175 .collect();
176
177 let mut compiled = CompiledSchema {
178 types,
179 enums,
180 input_types,
181 interfaces,
182 unions,
183 queries,
184 mutations,
185 subscriptions,
186 directives,
187 fact_tables, observers: Vec::new(), subscribable, federation: intermediate
192 .federation_config
193 .map(serde_json::from_value)
194 .transpose()
195 .context("federation_config: invalid JSON structure")?,
196 security: intermediate
197 .security
198 .map(serde_json::from_value)
199 .transpose()
200 .context("security: invalid JSON structure")?,
201 observers_config: intermediate
202 .observers_config
203 .map(serde_json::from_value)
204 .transpose()
205 .context("observers_config: invalid JSON structure")?,
206 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(),
214 hierarchies_config: intermediate.hierarchies_config,
215 changelog: intermediate.changelog_config, schema_sdl: None, custom_scalars: CustomTypeRegistry::default(), schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
219 ..Default::default()
220 };
221
222 if let Some(custom_scalars_vec) = intermediate.custom_scalars {
224 for scalar_def in custom_scalars_vec {
225 let custom_type = Self::convert_custom_scalar(scalar_def)?;
226 compiled
227 .custom_scalars
228 .register(custom_type.name.clone(), custom_type)
229 .context("Failed to register custom scalar")?;
230 }
231 }
232
233 if let Some(ref cl) = compiled.changelog {
238 if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
239 anyhow::bail!(
240 "[changelog] expose = true requires [observers] to be enabled: the tables \
241 it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
242 the observer system."
243 );
244 }
245 }
246
247 relay::inject_relay_types(&mut compiled);
249
250 let rich_filter_config = RichFilterConfig::default();
252 compile_rich_filters(&mut compiled, &rich_filter_config)
253 .context("Failed to compile rich filter types")?;
254
255 fraiseql_core::schema::inject_changelog(&mut compiled);
258
259 if options.auto_error_union {
263 mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
264 }
265
266 Self::validate(&compiled)?;
268
269 info!("Schema conversion successful");
270 Ok(compiled)
271 }
272
273 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
275 info!("Validating compiled schema");
276
277 let mut type_names: HashSet<String> = HashSet::new();
279 for type_def in &schema.types {
280 type_names.insert(type_def.name.to_string());
281 }
282
283 let mut interface_names = HashSet::new();
285 for interface_def in &schema.interfaces {
286 interface_names.insert(interface_def.name.clone());
287 }
288
289 for input_type in &schema.input_types {
291 type_names.insert(input_type.name.clone());
292 }
293
294 for union_def in &schema.unions {
296 type_names.insert(union_def.name.clone());
297 }
298
299 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
301 type_names.insert((*scalar).to_string());
302 }
303
304 for type_def in &schema.types {
307 for field in &type_def.fields {
308 let base = Self::extract_type_name(&field.field_type);
309 type_names.insert(base);
310 }
311 }
312
313 for query in &schema.queries {
315 if !type_names.contains(&query.return_type) {
316 warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
317 anyhow::bail!(
318 "Query '{}' references unknown type '{}'",
319 query.name,
320 query.return_type
321 );
322 }
323
324 for arg in &query.arguments {
326 let type_name = Self::extract_type_name(&arg.arg_type);
327 if !type_names.contains(&type_name) {
328 anyhow::bail!(
329 "Query '{}' argument '{}' references unknown type '{}'",
330 query.name,
331 arg.name,
332 type_name
333 );
334 }
335 }
336 }
337
338 for mutation in &schema.mutations {
340 if !type_names.contains(&mutation.return_type) {
341 anyhow::bail!(
342 "Mutation '{}' references unknown type '{}'",
343 mutation.name,
344 mutation.return_type
345 );
346 }
347
348 for arg in &mutation.arguments {
350 let type_name = Self::extract_type_name(&arg.arg_type);
351 if !type_names.contains(&type_name) {
352 anyhow::bail!(
353 "Mutation '{}' argument '{}' references unknown type '{}'",
354 mutation.name,
355 arg.name,
356 type_name
357 );
358 }
359 }
360 }
361
362 for type_def in &schema.types {
364 for interface_name in &type_def.implements {
365 if !interface_names.contains(interface_name) {
366 anyhow::bail!(
367 "Type '{}' implements unknown interface '{}'",
368 type_def.name,
369 interface_name
370 );
371 }
372
373 if let Some(interface) = schema.find_interface(interface_name) {
375 for interface_field in &interface.fields {
376 let type_has_field = type_def.fields.iter().any(|f| {
377 f.name == interface_field.name
378 && f.field_type == interface_field.field_type
379 });
380 if !type_has_field {
381 anyhow::bail!(
382 "Type '{}' implements interface '{}' but is missing field '{}'",
383 type_def.name,
384 interface_name,
385 interface_field.name
386 );
387 }
388 }
389 }
390 }
391 }
392
393 info!("Schema validation passed");
394 Ok(())
395 }
396
397 fn extract_type_name(field_type: &FieldType) -> String {
401 match field_type {
402 FieldType::String => "String".to_string(),
403 FieldType::Int => "Int".to_string(),
404 FieldType::Float => "Float".to_string(),
405 FieldType::Boolean => "Boolean".to_string(),
406 FieldType::Id => "ID".to_string(),
407 FieldType::DateTime => "DateTime".to_string(),
408 FieldType::Date => "Date".to_string(),
409 FieldType::Time => "Time".to_string(),
410 FieldType::Json => "Json".to_string(),
411 FieldType::Uuid => "UUID".to_string(),
412 FieldType::Decimal => "Decimal".to_string(),
413 FieldType::Vector => "Vector".to_string(),
414 FieldType::Scalar(name) => name.clone(),
415 FieldType::Object(name) => name.clone(),
416 FieldType::Enum(name) => name.clone(),
417 FieldType::Input(name) => name.clone(),
418 FieldType::Interface(name) => name.clone(),
419 FieldType::Union(name) => name.clone(),
420 FieldType::List(inner) => Self::extract_type_name(inner),
421 _ => "Unknown".to_string(),
423 }
424 }
425
426 fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
428 FactTableMetadata {
429 table_name: ft.table_name,
430 measures: ft
431 .measures
432 .into_iter()
433 .map(|m| MeasureColumn {
434 name: m.name,
435 sql_type: Self::parse_sql_type(&m.sql_type),
436 nullable: m.nullable,
437 })
438 .collect(),
439 dimensions: DimensionColumn {
440 name: ft.dimensions.name,
441 paths: ft
442 .dimensions
443 .paths
444 .into_iter()
445 .map(|p| DimensionPath {
446 name: p.name,
447 json_path: p.json_path,
448 data_type: p.data_type,
449 })
450 .collect(),
451 },
452 denormalized_filters: ft
453 .denormalized_filters
454 .into_iter()
455 .map(|f| FilterColumn {
456 name: f.name,
457 sql_type: Self::parse_sql_type(&f.sql_type),
458 indexed: f.indexed,
459 })
460 .collect(),
461 calendar_dimensions: vec![],
462 partial_period: None,
463 native_measures: ft.native_measures,
464 native_dimension_mapping: ft.native_dimension_mapping,
465 }
466 }
467
468 fn parse_sql_type(s: &str) -> SqlType {
470 match s.to_uppercase().as_str() {
471 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
472 "BIGINT" | "INT8" => SqlType::BigInt,
473 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
474 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
475 SqlType::Float
476 },
477 "JSONB" => SqlType::Jsonb,
478 "JSON" => SqlType::Json,
479 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
480 "UUID" => SqlType::Uuid,
481 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
482 SqlType::Timestamp
483 },
484 "DATE" => SqlType::Date,
485 "BOOLEAN" | "BOOL" => SqlType::Boolean,
486 _ => SqlType::Other(s.to_string()),
487 }
488 }
489}