fraiseql_cli/schema/converter/
mod.rs1mod cascade_types;
6mod directives;
7mod identity;
8mod interface_conformance;
9mod mutation_error_union;
10mod mutations;
11mod queries;
12mod relay;
13mod subscriptions;
14pub(crate) mod tenancy;
15mod types;
16
17#[cfg(test)]
18mod tests;
19
20use std::collections::HashSet;
21
22use anyhow::{Context, Result};
23use fraiseql_core::{
24 compiler::fact_table::{
25 DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
26 },
27 schema::{CompiledSchema, FieldType},
28 validation::CustomTypeRegistry,
29};
30use tracing::{info, warn};
31
32use super::{
33 intermediate::{IntermediateFactTable, IntermediateSchema},
34 rich_filters::{RichFilterConfig, compile_rich_filters},
35};
36
37pub struct SchemaConverter;
39
40#[derive(Debug, Clone, Default)]
46pub struct ConvertOptions {
47 pub auto_error_union: bool,
51}
52
53impl SchemaConverter {
54 pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
60 Self::convert_with_options(intermediate, &ConvertOptions::default())
61 }
62
63 pub fn convert_with_options(
78 intermediate: IntermediateSchema,
79 options: &ConvertOptions,
80 ) -> Result<CompiledSchema> {
81 info!("Converting intermediate schema to compiled format");
82
83 let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
87 .types
88 .iter()
89 .filter_map(|t| {
90 t.subscribable_tables
91 .as_ref()
92 .filter(|tables| !tables.is_empty())
93 .map(|tables| fraiseql_core::schema::SubscribableEntity {
94 entity_type: t.name.clone(),
95 tables: tables.clone(),
96 pre_image: t.subscribable_pre_image,
97 })
98 })
99 .collect();
100
101 let types = intermediate
103 .types
104 .into_iter()
105 .map(Self::convert_type)
106 .collect::<Result<Vec<_>>>()
107 .context("Failed to convert types")?;
108
109 let defaults = intermediate.query_defaults.unwrap_or_default();
113
114 let queries = intermediate
116 .queries
117 .into_iter()
118 .map(|q| Self::convert_query(q, &defaults))
119 .collect::<Result<Vec<_>>>()
120 .context("Failed to convert queries")?;
121
122 let mutations = intermediate
124 .mutations
125 .into_iter()
126 .map(Self::convert_mutation)
127 .collect::<Result<Vec<_>>>()
128 .context("Failed to convert mutations")?;
129
130 let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
132
133 let input_types = intermediate
135 .input_types
136 .into_iter()
137 .map(Self::convert_input_object)
138 .collect::<Vec<_>>();
139
140 let interfaces = intermediate
142 .interfaces
143 .into_iter()
144 .map(Self::convert_interface)
145 .collect::<Result<Vec<_>>>()
146 .context("Failed to convert interfaces")?;
147
148 let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
150
151 let subscriptions = intermediate
153 .subscriptions
154 .into_iter()
155 .map(Self::convert_subscription)
156 .collect::<Result<Vec<_>>>()
157 .context("Failed to convert subscriptions")?;
158
159 let directives = intermediate
161 .directives
162 .unwrap_or_default()
163 .into_iter()
164 .map(Self::convert_directive)
165 .collect::<Result<Vec<_>>>()
166 .context("Failed to convert directives")?;
167
168 let fact_tables = intermediate
170 .fact_tables
171 .unwrap_or_default()
172 .into_iter()
173 .map(|ft| {
174 let name = ft.table_name.clone();
175 let metadata = Self::convert_fact_table(ft);
176 (name, metadata)
177 })
178 .collect();
179
180 let mut compiled = CompiledSchema {
181 types,
182 enums,
183 input_types,
184 interfaces,
185 unions,
186 queries,
187 mutations,
188 subscriptions,
189 directives,
190 fact_tables, observers: Vec::new(), sources: intermediate.sources.clone().unwrap_or_default(), subscribable, federation: intermediate
197 .federation_config
198 .map(serde_json::from_value)
199 .transpose()
200 .context("federation_config: invalid JSON structure")?,
201 security: intermediate
202 .security
203 .map(serde_json::from_value)
204 .transpose()
205 .context("security: invalid JSON structure")?,
206 observers_config: intermediate
207 .observers_config
208 .map(serde_json::from_value)
209 .transpose()
210 .context("observers_config: invalid JSON structure")?,
211 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(),
219 hierarchies_config: intermediate.hierarchies_config,
220 changelog: intermediate.changelog_config, schema_sdl: None, custom_scalars: CustomTypeRegistry::default(), schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
224 ..Default::default()
225 };
226
227 if let Some(custom_scalars_vec) = intermediate.custom_scalars {
229 for scalar_def in custom_scalars_vec {
230 let custom_type = Self::convert_custom_scalar(scalar_def)?;
231 compiled
232 .custom_scalars
233 .register(custom_type.name.clone(), custom_type)
234 .context("Failed to register custom scalar")?;
235 }
236 }
237
238 if let Some(ref cl) = compiled.changelog {
243 if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
244 anyhow::bail!(
245 "[changelog] expose = true requires [observers] to be enabled: the tables \
246 it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
247 the observer system."
248 );
249 }
250 if cl.expose && compiled.federation.as_ref().is_some_and(|f| f.enabled) {
259 warn!(
260 "[changelog] expose = true on a federation subgraph: EntityChangeLog / \
261 entity_change_logs are not @shareable, so expose the change-log in exactly \
262 one subgraph per supergraph (others keep capturing via [changelog] \
263 write_enabled). Two exposing subgraphs fail `rover supergraph compose` with \
264 INVALID_FIELD_SHARING. See the changelog-graphql guide for the single-owner \
265 pattern."
266 );
267 }
268 }
269
270 identity::normalize_entity_identity(&mut compiled);
274
275 relay::inject_relay_types(&mut compiled)?;
277
278 let rich_filter_config = RichFilterConfig::default();
280 compile_rich_filters(&mut compiled, &rich_filter_config)
281 .context("Failed to compile rich filter types")?;
282
283 fraiseql_core::schema::inject_changelog(&mut compiled);
286
287 cascade_types::synthesize_cascade_types(&mut compiled)?;
294
295 if options.auto_error_union {
299 mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
300 }
301
302 Self::promote_input_type_args(&mut compiled);
311
312 Self::validate(&compiled)?;
314
315 info!("Schema conversion successful");
316 Ok(compiled)
317 }
318
319 fn promote_input_type_args(schema: &mut CompiledSchema) {
325 fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
326 match ft {
327 FieldType::Object(name) if input_names.contains(name) => {
328 *ft = FieldType::Input(name.clone());
329 },
330 FieldType::List(inner) => promote(inner, input_names),
331 _ => {},
332 }
333 }
334
335 let input_names: HashSet<String> =
336 schema.input_types.iter().map(|i| i.name.clone()).collect();
337 if input_names.is_empty() {
338 return;
339 }
340
341 for mutation in &mut schema.mutations {
342 for arg in &mut mutation.arguments {
343 promote(&mut arg.arg_type, &input_names);
344 }
345 }
346 for query in &mut schema.queries {
347 for arg in &mut query.arguments {
348 promote(&mut arg.arg_type, &input_names);
349 }
350 }
351 }
352
353 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
355 info!("Validating compiled schema");
356
357 let mut type_names: HashSet<String> = HashSet::new();
359 for type_def in &schema.types {
360 type_names.insert(type_def.name.to_string());
361 }
362
363 let mut interface_names = HashSet::new();
369 for interface_def in &schema.interfaces {
370 interface_names.insert(interface_def.name.clone());
371 type_names.insert(interface_def.name.clone());
372 }
373
374 for input_type in &schema.input_types {
376 type_names.insert(input_type.name.clone());
377 }
378
379 for union_def in &schema.unions {
381 type_names.insert(union_def.name.clone());
382 }
383
384 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
386 type_names.insert((*scalar).to_string());
387 }
388
389 for type_def in &schema.types {
392 for field in &type_def.fields {
393 let base = Self::extract_type_name(&field.field_type);
394 type_names.insert(base);
395 }
396 }
397
398 for query in &schema.queries {
400 if !type_names.contains(&query.return_type) {
401 warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
402 anyhow::bail!(
403 "Query '{}' references unknown type '{}'",
404 query.name,
405 query.return_type
406 );
407 }
408
409 for arg in &query.arguments {
411 let type_name = Self::extract_type_name(&arg.arg_type);
412 if !type_names.contains(&type_name) {
413 warn!(
414 "Query '{}' argument '{}' references unknown type: {}",
415 query.name, arg.name, type_name
416 );
417 anyhow::bail!(
418 "Query '{}' argument '{}' references unknown type '{}'",
419 query.name,
420 arg.name,
421 type_name
422 );
423 }
424 }
425 }
426
427 for mutation in &schema.mutations {
429 if !type_names.contains(&mutation.return_type) {
430 warn!(
431 "Mutation '{}' references unknown type: {}",
432 mutation.name, mutation.return_type
433 );
434 anyhow::bail!(
435 "Mutation '{}' references unknown type '{}'",
436 mutation.name,
437 mutation.return_type
438 );
439 }
440
441 for arg in &mutation.arguments {
443 let type_name = Self::extract_type_name(&arg.arg_type);
444 if !type_names.contains(&type_name) {
445 warn!(
446 "Mutation '{}' argument '{}' references unknown type: {}",
447 mutation.name, arg.name, type_name
448 );
449 anyhow::bail!(
450 "Mutation '{}' argument '{}' references unknown type '{}'",
451 mutation.name,
452 arg.name,
453 type_name
454 );
455 }
456 }
457 }
458
459 for type_def in &schema.types {
461 for interface_name in &type_def.implements {
462 if !interface_names.contains(interface_name) {
463 warn!(
464 "Type '{}' implements unknown interface: {}",
465 type_def.name, interface_name
466 );
467 anyhow::bail!(
468 "Type '{}' implements unknown interface '{}'",
469 type_def.name,
470 interface_name
471 );
472 }
473
474 if let Some(interface) = schema.find_interface(interface_name) {
476 for interface_field in &interface.fields {
477 let type_has_field = type_def.fields.iter().any(|f| {
478 f.name == interface_field.name
479 && f.field_type == interface_field.field_type
480 });
481 if !type_has_field {
482 warn!(
483 "Type '{}' implements interface '{}' but is missing field: {}",
484 type_def.name, interface_name, interface_field.name
485 );
486 anyhow::bail!(
487 "Type '{}' implements interface '{}' but is missing field '{}'",
488 type_def.name,
489 interface_name,
490 interface_field.name
491 );
492 }
493 }
494 }
495 }
496 }
497
498 info!("Schema validation passed");
499 Ok(())
500 }
501
502 fn extract_type_name(field_type: &FieldType) -> String {
506 match field_type {
507 FieldType::String => "String".to_string(),
508 FieldType::Int => "Int".to_string(),
509 FieldType::Float => "Float".to_string(),
510 FieldType::Boolean => "Boolean".to_string(),
511 FieldType::Id => "ID".to_string(),
512 FieldType::DateTime => "DateTime".to_string(),
513 FieldType::Date => "Date".to_string(),
514 FieldType::Time => "Time".to_string(),
515 FieldType::Json => "Json".to_string(),
516 FieldType::Uuid => "UUID".to_string(),
517 FieldType::Decimal => "Decimal".to_string(),
518 FieldType::Vector => "Vector".to_string(),
519 FieldType::Scalar(name) => name.clone(),
520 FieldType::Object(name) => name.clone(),
521 FieldType::Enum(name) => name.clone(),
522 FieldType::Input(name) => name.clone(),
523 FieldType::Interface(name) => name.clone(),
524 FieldType::Union(name) => name.clone(),
525 FieldType::List(inner) => Self::extract_type_name(inner),
526 _ => "Unknown".to_string(),
528 }
529 }
530
531 fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
533 FactTableMetadata {
534 table_name: ft.table_name,
535 measures: ft
536 .measures
537 .into_iter()
538 .map(|m| MeasureColumn {
539 name: m.name,
540 sql_type: Self::parse_sql_type(&m.sql_type),
541 nullable: m.nullable,
542 })
543 .collect(),
544 dimensions: DimensionColumn {
545 name: ft.dimensions.name,
546 paths: ft
547 .dimensions
548 .paths
549 .into_iter()
550 .map(|p| DimensionPath {
551 name: p.name,
552 json_path: p.json_path,
553 data_type: p.data_type,
554 })
555 .collect(),
556 },
557 denormalized_filters: ft
558 .denormalized_filters
559 .into_iter()
560 .map(|f| FilterColumn {
561 name: f.name,
562 sql_type: Self::parse_sql_type(&f.sql_type),
563 indexed: f.indexed,
564 })
565 .collect(),
566 calendar_dimensions: vec![],
567 partial_period: None,
568 native_measures: ft.native_measures,
569 native_dimension_mapping: ft.native_dimension_mapping,
570 }
571 }
572
573 fn parse_sql_type(s: &str) -> SqlType {
575 match s.to_uppercase().as_str() {
576 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
577 "BIGINT" | "INT8" => SqlType::BigInt,
578 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
579 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
580 SqlType::Float
581 },
582 "JSONB" => SqlType::Jsonb,
583 "JSON" => SqlType::Json,
584 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
585 "UUID" => SqlType::Uuid,
586 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
587 SqlType::Timestamp
588 },
589 "DATE" => SqlType::Date,
590 "BOOLEAN" | "BOOL" => SqlType::Boolean,
591 _ => SqlType::Other(s.to_string()),
592 }
593 }
594}