fraiseql_cli/schema/converter/
mod.rs1mod cascade_types;
6mod directives;
7mod mutation_error_union;
8mod mutations;
9mod queries;
10mod relay;
11mod subscriptions;
12pub(crate) mod tenancy;
13mod types;
14
15#[cfg(test)]
16mod tests;
17
18use std::collections::HashSet;
19
20use anyhow::{Context, Result};
21use fraiseql_core::{
22 compiler::fact_table::{
23 DimensionColumn, DimensionPath, FactTableMetadata, FilterColumn, MeasureColumn, SqlType,
24 },
25 schema::{CompiledSchema, FieldType},
26 validation::CustomTypeRegistry,
27};
28use tracing::{info, warn};
29
30use super::{
31 intermediate::{IntermediateFactTable, IntermediateSchema},
32 rich_filters::{RichFilterConfig, compile_rich_filters},
33};
34
35pub struct SchemaConverter;
37
38#[derive(Debug, Clone, Default)]
44pub struct ConvertOptions {
45 pub auto_error_union: bool,
49}
50
51impl SchemaConverter {
52 pub fn convert(intermediate: IntermediateSchema) -> Result<CompiledSchema> {
58 Self::convert_with_options(intermediate, &ConvertOptions::default())
59 }
60
61 pub fn convert_with_options(
76 intermediate: IntermediateSchema,
77 options: &ConvertOptions,
78 ) -> Result<CompiledSchema> {
79 info!("Converting intermediate schema to compiled format");
80
81 let subscribable: Vec<fraiseql_core::schema::SubscribableEntity> = intermediate
85 .types
86 .iter()
87 .filter_map(|t| {
88 t.subscribable_tables
89 .as_ref()
90 .filter(|tables| !tables.is_empty())
91 .map(|tables| fraiseql_core::schema::SubscribableEntity {
92 entity_type: t.name.clone(),
93 tables: tables.clone(),
94 pre_image: t.subscribable_pre_image,
95 })
96 })
97 .collect();
98
99 let types = intermediate
101 .types
102 .into_iter()
103 .map(Self::convert_type)
104 .collect::<Result<Vec<_>>>()
105 .context("Failed to convert types")?;
106
107 let defaults = intermediate.query_defaults.unwrap_or_default();
111
112 let queries = intermediate
114 .queries
115 .into_iter()
116 .map(|q| Self::convert_query(q, &defaults))
117 .collect::<Result<Vec<_>>>()
118 .context("Failed to convert queries")?;
119
120 let mutations = intermediate
122 .mutations
123 .into_iter()
124 .map(Self::convert_mutation)
125 .collect::<Result<Vec<_>>>()
126 .context("Failed to convert mutations")?;
127
128 let enums = intermediate.enums.into_iter().map(Self::convert_enum).collect::<Vec<_>>();
130
131 let input_types = intermediate
133 .input_types
134 .into_iter()
135 .map(Self::convert_input_object)
136 .collect::<Vec<_>>();
137
138 let interfaces = intermediate
140 .interfaces
141 .into_iter()
142 .map(Self::convert_interface)
143 .collect::<Result<Vec<_>>>()
144 .context("Failed to convert interfaces")?;
145
146 let unions = intermediate.unions.into_iter().map(Self::convert_union).collect::<Vec<_>>();
148
149 let subscriptions = intermediate
151 .subscriptions
152 .into_iter()
153 .map(Self::convert_subscription)
154 .collect::<Result<Vec<_>>>()
155 .context("Failed to convert subscriptions")?;
156
157 let directives = intermediate
159 .directives
160 .unwrap_or_default()
161 .into_iter()
162 .map(Self::convert_directive)
163 .collect::<Result<Vec<_>>>()
164 .context("Failed to convert directives")?;
165
166 let fact_tables = intermediate
168 .fact_tables
169 .unwrap_or_default()
170 .into_iter()
171 .map(|ft| {
172 let name = ft.table_name.clone();
173 let metadata = Self::convert_fact_table(ft);
174 (name, metadata)
175 })
176 .collect();
177
178 let mut compiled = CompiledSchema {
179 types,
180 enums,
181 input_types,
182 interfaces,
183 unions,
184 queries,
185 mutations,
186 subscriptions,
187 directives,
188 fact_tables, observers: Vec::new(), subscribable, federation: intermediate
193 .federation_config
194 .map(serde_json::from_value)
195 .transpose()
196 .context("federation_config: invalid JSON structure")?,
197 security: intermediate
198 .security
199 .map(serde_json::from_value)
200 .transpose()
201 .context("security: invalid JSON structure")?,
202 observers_config: intermediate
203 .observers_config
204 .map(serde_json::from_value)
205 .transpose()
206 .context("observers_config: invalid JSON structure")?,
207 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(),
215 hierarchies_config: intermediate.hierarchies_config,
216 changelog: intermediate.changelog_config, schema_sdl: None, custom_scalars: CustomTypeRegistry::default(), schema_format_version: Some(fraiseql_core::schema::CURRENT_SCHEMA_FORMAT_VERSION),
220 ..Default::default()
221 };
222
223 if let Some(custom_scalars_vec) = intermediate.custom_scalars {
225 for scalar_def in custom_scalars_vec {
226 let custom_type = Self::convert_custom_scalar(scalar_def)?;
227 compiled
228 .custom_scalars
229 .register(custom_type.name.clone(), custom_type)
230 .context("Failed to register custom scalar")?;
231 }
232 }
233
234 if let Some(ref cl) = compiled.changelog {
239 if cl.expose && !compiled.observers_config.as_ref().is_some_and(|o| o.enabled) {
240 anyhow::bail!(
241 "[changelog] expose = true requires [observers] to be enabled: the tables \
242 it exposes (tb_entity_change_log, tb_transport_checkpoint) are installed by \
243 the observer system."
244 );
245 }
246 if cl.expose && compiled.federation.as_ref().is_some_and(|f| f.enabled) {
255 warn!(
256 "[changelog] expose = true on a federation subgraph: EntityChangeLog / \
257 entity_change_logs are not @shareable, so expose the change-log in exactly \
258 one subgraph per supergraph (others keep capturing via [changelog] \
259 write_enabled). Two exposing subgraphs fail `rover supergraph compose` with \
260 INVALID_FIELD_SHARING. See the changelog-graphql guide for the single-owner \
261 pattern."
262 );
263 }
264 }
265
266 relay::inject_relay_types(&mut compiled);
268
269 let rich_filter_config = RichFilterConfig::default();
271 compile_rich_filters(&mut compiled, &rich_filter_config)
272 .context("Failed to compile rich filter types")?;
273
274 fraiseql_core::schema::inject_changelog(&mut compiled);
277
278 cascade_types::synthesize_cascade_types(&mut compiled);
285
286 if options.auto_error_union {
290 mutation_error_union::synthesize_mutation_error_unions(&mut compiled);
291 }
292
293 Self::promote_input_type_args(&mut compiled);
302
303 Self::validate(&compiled)?;
305
306 info!("Schema conversion successful");
307 Ok(compiled)
308 }
309
310 fn promote_input_type_args(schema: &mut CompiledSchema) {
316 fn promote(ft: &mut FieldType, input_names: &HashSet<String>) {
317 match ft {
318 FieldType::Object(name) if input_names.contains(name) => {
319 *ft = FieldType::Input(name.clone());
320 },
321 FieldType::List(inner) => promote(inner, input_names),
322 _ => {},
323 }
324 }
325
326 let input_names: HashSet<String> =
327 schema.input_types.iter().map(|i| i.name.clone()).collect();
328 if input_names.is_empty() {
329 return;
330 }
331
332 for mutation in &mut schema.mutations {
333 for arg in &mut mutation.arguments {
334 promote(&mut arg.arg_type, &input_names);
335 }
336 }
337 for query in &mut schema.queries {
338 for arg in &mut query.arguments {
339 promote(&mut arg.arg_type, &input_names);
340 }
341 }
342 }
343
344 #[allow(clippy::cognitive_complexity)] fn validate(schema: &CompiledSchema) -> Result<()> {
346 info!("Validating compiled schema");
347
348 let mut type_names: HashSet<String> = HashSet::new();
350 for type_def in &schema.types {
351 type_names.insert(type_def.name.to_string());
352 }
353
354 let mut interface_names = HashSet::new();
356 for interface_def in &schema.interfaces {
357 interface_names.insert(interface_def.name.clone());
358 }
359
360 for input_type in &schema.input_types {
362 type_names.insert(input_type.name.clone());
363 }
364
365 for union_def in &schema.unions {
367 type_names.insert(union_def.name.clone());
368 }
369
370 for scalar in crate::schema::BUILTIN_SCALAR_NAMES {
372 type_names.insert((*scalar).to_string());
373 }
374
375 for type_def in &schema.types {
378 for field in &type_def.fields {
379 let base = Self::extract_type_name(&field.field_type);
380 type_names.insert(base);
381 }
382 }
383
384 for query in &schema.queries {
386 if !type_names.contains(&query.return_type) {
387 warn!("Query '{}' references unknown type: {}", query.name, query.return_type);
388 anyhow::bail!(
389 "Query '{}' references unknown type '{}'",
390 query.name,
391 query.return_type
392 );
393 }
394
395 for arg in &query.arguments {
397 let type_name = Self::extract_type_name(&arg.arg_type);
398 if !type_names.contains(&type_name) {
399 anyhow::bail!(
400 "Query '{}' argument '{}' references unknown type '{}'",
401 query.name,
402 arg.name,
403 type_name
404 );
405 }
406 }
407 }
408
409 for mutation in &schema.mutations {
411 if !type_names.contains(&mutation.return_type) {
412 anyhow::bail!(
413 "Mutation '{}' references unknown type '{}'",
414 mutation.name,
415 mutation.return_type
416 );
417 }
418
419 for arg in &mutation.arguments {
421 let type_name = Self::extract_type_name(&arg.arg_type);
422 if !type_names.contains(&type_name) {
423 anyhow::bail!(
424 "Mutation '{}' argument '{}' references unknown type '{}'",
425 mutation.name,
426 arg.name,
427 type_name
428 );
429 }
430 }
431 }
432
433 for type_def in &schema.types {
435 for interface_name in &type_def.implements {
436 if !interface_names.contains(interface_name) {
437 anyhow::bail!(
438 "Type '{}' implements unknown interface '{}'",
439 type_def.name,
440 interface_name
441 );
442 }
443
444 if let Some(interface) = schema.find_interface(interface_name) {
446 for interface_field in &interface.fields {
447 let type_has_field = type_def.fields.iter().any(|f| {
448 f.name == interface_field.name
449 && f.field_type == interface_field.field_type
450 });
451 if !type_has_field {
452 anyhow::bail!(
453 "Type '{}' implements interface '{}' but is missing field '{}'",
454 type_def.name,
455 interface_name,
456 interface_field.name
457 );
458 }
459 }
460 }
461 }
462 }
463
464 info!("Schema validation passed");
465 Ok(())
466 }
467
468 fn extract_type_name(field_type: &FieldType) -> String {
472 match field_type {
473 FieldType::String => "String".to_string(),
474 FieldType::Int => "Int".to_string(),
475 FieldType::Float => "Float".to_string(),
476 FieldType::Boolean => "Boolean".to_string(),
477 FieldType::Id => "ID".to_string(),
478 FieldType::DateTime => "DateTime".to_string(),
479 FieldType::Date => "Date".to_string(),
480 FieldType::Time => "Time".to_string(),
481 FieldType::Json => "Json".to_string(),
482 FieldType::Uuid => "UUID".to_string(),
483 FieldType::Decimal => "Decimal".to_string(),
484 FieldType::Vector => "Vector".to_string(),
485 FieldType::Scalar(name) => name.clone(),
486 FieldType::Object(name) => name.clone(),
487 FieldType::Enum(name) => name.clone(),
488 FieldType::Input(name) => name.clone(),
489 FieldType::Interface(name) => name.clone(),
490 FieldType::Union(name) => name.clone(),
491 FieldType::List(inner) => Self::extract_type_name(inner),
492 _ => "Unknown".to_string(),
494 }
495 }
496
497 fn convert_fact_table(ft: IntermediateFactTable) -> FactTableMetadata {
499 FactTableMetadata {
500 table_name: ft.table_name,
501 measures: ft
502 .measures
503 .into_iter()
504 .map(|m| MeasureColumn {
505 name: m.name,
506 sql_type: Self::parse_sql_type(&m.sql_type),
507 nullable: m.nullable,
508 })
509 .collect(),
510 dimensions: DimensionColumn {
511 name: ft.dimensions.name,
512 paths: ft
513 .dimensions
514 .paths
515 .into_iter()
516 .map(|p| DimensionPath {
517 name: p.name,
518 json_path: p.json_path,
519 data_type: p.data_type,
520 })
521 .collect(),
522 },
523 denormalized_filters: ft
524 .denormalized_filters
525 .into_iter()
526 .map(|f| FilterColumn {
527 name: f.name,
528 sql_type: Self::parse_sql_type(&f.sql_type),
529 indexed: f.indexed,
530 })
531 .collect(),
532 calendar_dimensions: vec![],
533 partial_period: None,
534 native_measures: ft.native_measures,
535 native_dimension_mapping: ft.native_dimension_mapping,
536 }
537 }
538
539 fn parse_sql_type(s: &str) -> SqlType {
541 match s.to_uppercase().as_str() {
542 "INT" | "INTEGER" | "SMALLINT" | "INT4" | "INT2" => SqlType::Int,
543 "BIGINT" | "INT8" => SqlType::BigInt,
544 "DECIMAL" | "NUMERIC" | "MONEY" => SqlType::Decimal,
545 "REAL" | "FLOAT" | "DOUBLE" | "FLOAT8" | "FLOAT4" | "DOUBLE PRECISION" => {
546 SqlType::Float
547 },
548 "JSONB" => SqlType::Jsonb,
549 "JSON" => SqlType::Json,
550 "TEXT" | "VARCHAR" | "STRING" | "CHAR" | "CHARACTER VARYING" => SqlType::Text,
551 "UUID" => SqlType::Uuid,
552 "TIMESTAMP" | "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "DATETIME" => {
553 SqlType::Timestamp
554 },
555 "DATE" => SqlType::Date,
556 "BOOLEAN" | "BOOL" => SqlType::Boolean,
557 _ => SqlType::Other(s.to_string()),
558 }
559 }
560}