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::promote_input_type_args(&mut compiled);
275
276 Self::validate(&compiled)?;
278
279 info!("Schema conversion successful");
280 Ok(compiled)
281 }
282
283 fn promote_input_type_args(schema: &mut CompiledSchema) {
289 fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
290 match ft {
291 FieldType::Object(name) if input_names.contains(name) => {
292 *ft = FieldType::Input(name.clone());
293 },
294 FieldType::List(inner) => promote(inner, input_names),
295 _ => {},
296 }
297 }
298
299 let input_names: HashSet<String> =
300 schema.input_types.iter().map(|i| i.name.clone()).collect();
301 if input_names.is_empty() {
302 return;
303 }
304
305 for mutation in &mut schema.mutations {
306 for arg in &mut mutation.arguments {
307 promote(&mut arg.arg_type, &input_names);
308 }
309 }
310 for query in &mut schema.queries {
311 for arg in &mut query.arguments {
312 promote(&mut arg.arg_type, &input_names);
313 }
314 }
315 }
316
317 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
319 info!("Validating compiled schema");
320
321 let mut type_names: HashSet<String> = HashSet::new();
323 for type_def in &schema.types {
324 type_names.insert(type_def.name.to_string());
325 }
326
327 let mut interface_names = HashSet::new();
329 for interface_def in &schema.interfaces {
330 interface_names.insert(interface_def.name.clone());
331 }
332
333 for input_type in &schema.input_types {
335 type_names.insert(input_type.name.clone());
336 }
337
338 for union_def in &schema.unions {
340 type_names.insert(union_def.name.clone());
341 }
342
343 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
345 type_names.insert((*scalar).to_string());
346 }
347
348 for type_def in &schema.types {
351 for field in &type_def.fields {
352 let base = Self::extract_type_name(&field.field_type);
353 type_names.insert(base);
354 }
355 }
356
357 for query in &schema.queries {
359 if !type_names.contains(&query.return_type) {
360 warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
361 anyhow::bail!(
362 "Query '{}' references unknown type '{}'",
363 query.name,
364 query.return_type
365 );
366 }
367
368 for arg in &query.arguments {
370 let type_name = Self::extract_type_name(&arg.arg_type);
371 if !type_names.contains(&type_name) {
372 anyhow::bail!(
373 "Query '{}' argument '{}' references unknown type '{}'",
374 query.name,
375 arg.name,
376 type_name
377 );
378 }
379 }
380 }
381
382 for mutation in &schema.mutations {
384 if !type_names.contains(&mutation.return_type) {
385 anyhow::bail!(
386 "Mutation '{}' references unknown type '{}'",
387 mutation.name,
388 mutation.return_type
389 );
390 }
391
392 for arg in &mutation.arguments {
394 let type_name = Self::extract_type_name(&arg.arg_type);
395 if !type_names.contains(&type_name) {
396 anyhow::bail!(
397 "Mutation '{}' argument '{}' references unknown type '{}'",
398 mutation.name,
399 arg.name,
400 type_name
401 );
402 }
403 }
404 }
405
406 for type_def in &schema.types {
408 for interface_name in &type_def.implements {
409 if !interface_names.contains(interface_name) {
410 anyhow::bail!(
411 "Type '{}' implements unknown interface '{}'",
412 type_def.name,
413 interface_name
414 );
415 }
416
417 if let Some(interface) = schema.find_interface(interface_name) {
419 for interface_field in &interface.fields {
420 let type_has_field = type_def.fields.iter().any(|f| {
421 f.name == interface_field.name
422 && f.field_type == interface_field.field_type
423 });
424 if !type_has_field {
425 anyhow::bail!(
426 "Type '{}' implements interface '{}' but is missing field '{}'",
427 type_def.name,
428 interface_name,
429 interface_field.name
430 );
431 }
432 }
433 }
434 }
435 }
436
437 info!("Schema validation passed");
438 Ok(())
439 }
440
441 fn extract_type_name(field_type: &FieldType) -> String {
445 match field_type {
446 FieldType::String => "String".to_string(),
447 FieldType::Int => "Int".to_string(),
448 FieldType::Float => "Float".to_string(),
449 FieldType::Boolean => "Boolean".to_string(),
450 FieldType::Id => "ID".to_string(),
451 FieldType::DateTime => "DateTime".to_string(),
452 FieldType::Date => "Date".to_string(),
453 FieldType::Time => "Time".to_string(),
454 FieldType::Json => "Json".to_string(),
455 FieldType::Uuid => "UUID".to_string(),
456 FieldType::Decimal => "Decimal".to_string(),
457 FieldType::Vector => "Vector".to_string(),
458 FieldType::Scalar(name) => name.clone(),
459 FieldType::Object(name) => name.clone(),
460 FieldType::Enum(name) => name.clone(),
461 FieldType::Input(name) => name.clone(),
462 FieldType::Interface(name) => name.clone(),
463 FieldType::Union(name) => name.clone(),
464 FieldType::List(inner) => Self::extract_type_name(inner),
465 _ => "Unknown".to_string(),
467 }
468 }
469
470 fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
472 FactTableMetadata {
473 table_name: ft.table_name,
474 measures: ft
475 .measures
476 .into_iter()
477 .map(|m| MeasureColumn {
478 name: m.name,
479 sql_type: Self::parse_sql_type(&m.sql_type),
480 nullable: m.nullable,
481 })
482 .collect(),
483 dimensions: DimensionColumn {
484 name: ft.dimensions.name,
485 paths: ft
486 .dimensions
487 .paths
488 .into_iter()
489 .map(|p| DimensionPath {
490 name: p.name,
491 json_path: p.json_path,
492 data_type: p.data_type,
493 })
494 .collect(),
495 },
496 denormalized_filters: ft
497 .denormalized_filters
498 .into_iter()
499 .map(|f| FilterColumn {
500 name: f.name,
501 sql_type: Self::parse_sql_type(&f.sql_type),
502 indexed: f.indexed,
503 })
504 .collect(),
505 calendar_dimensions: vec![],
506 partial_period: None,
507 native_measures: ft.native_measures,
508 native_dimension_mapping: ft.native_dimension_mapping,
509 }
510 }
511
512 fn parse_sql_type(s: &str) -> SqlType {
514 match s.to_uppercase().as_str() {
515 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
516 "BIGINT" | "INT8" => SqlType::BigInt,
517 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
518 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
519 SqlType::Float
520 },
521 "JSONB" => SqlType::Jsonb,
522 "JSON" => SqlType::Json,
523 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
524 "UUID" => SqlType::Uuid,
525 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
526 SqlType::Timestamp
527 },
528 "DATE" => SqlType::Date,
529 "BOOLEAN" | "BOOL" => SqlType::Boolean,
530 _ => SqlType::Other(s.to_string()),
531 }
532 }
533}