1use std::{fs, path::Path};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::CompiledSchema;
9use tracing::{info, warn};
10
11use crate::{
12 config::FraiseQLConfig,
13 schema::{
14 IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer, SchemaValidator,
15 },
16};
17
18#[derive(Debug, Default)]
20pub struct CompileOptions<'a> {
21 pub input: &'a str,
23 pub types: Option<&'a str>,
25 pub schema_dir: Option<&'a str>,
27 pub type_files: Vec<String>,
29 pub query_files: Vec<String>,
31 pub mutation_files: Vec<String>,
33 pub database: Option<&'a str>,
35}
36
37impl<'a> CompileOptions<'a> {
38 #[must_use]
40 pub fn new(input: &'a str) -> Self {
41 Self {
42 input,
43 ..Default::default()
44 }
45 }
46
47 #[must_use]
49 pub fn with_types(mut self, types: &'a str) -> Self {
50 self.types = Some(types);
51 self
52 }
53
54 #[must_use]
56 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
57 self.schema_dir = Some(schema_dir);
58 self
59 }
60
61 #[must_use]
63 pub fn with_database(mut self, database: &'a str) -> Self {
64 self.database = Some(database);
65 self
66 }
67}
68
69fn load_intermediate_schema(
77 toml_path: &str,
78 type_files: &[String],
79 query_files: &[String],
80 mutation_files: &[String],
81 schema_dir: Option<&str>,
82 types_path: Option<&str>,
83) -> Result<IntermediateSchema> {
84 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
85 info!("Mode: Explicit file lists");
86 return crate::schema::SchemaMerger::merge_explicit_files(
87 toml_path,
88 type_files,
89 query_files,
90 mutation_files,
91 )
92 .context("Failed to load explicit schema files");
93 }
94 if let Some(dir) = schema_dir {
95 info!("Mode: Auto-discovery from directory: {}", dir);
96 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
97 .context("Failed to load schema from directory");
98 }
99 if let Some(types) = types_path {
100 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
101 return crate::schema::SchemaMerger::merge_files(types, toml_path)
102 .context("Failed to merge types.json with TOML");
103 }
104 info!("Mode: TOML-based (checking for domain discovery...)");
105 if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
106 return Ok(schema);
107 }
108 info!("No domains configured, checking for TOML includes...");
109 if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
110 return Ok(schema);
111 }
112 info!("No includes configured, using TOML-only definitions");
113 crate::schema::SchemaMerger::merge_toml_only(toml_path)
114 .context("Failed to load schema from TOML")
115}
116
117pub async fn compile_to_schema(
131 opts: CompileOptions<'_>,
132) -> Result<(CompiledSchema, OptimizationReport)> {
133 info!("Compiling schema: {}", opts.input);
134
135 let input_path = Path::new(opts.input);
137 if !input_path.exists() {
138 anyhow::bail!("Input file not found: {}", opts.input);
139 }
140
141 let is_toml = input_path
143 .extension()
144 .and_then(|ext| ext.to_str())
145 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
146 let mut intermediate: IntermediateSchema = if is_toml {
147 info!("Using TOML-based workflow");
148 load_intermediate_schema(
149 opts.input,
150 &opts.type_files,
151 &opts.query_files,
152 &opts.mutation_files,
153 opts.schema_dir,
154 opts.types,
155 )?
156 } else {
157 info!("Using legacy JSON workflow");
159 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
160
161 info!("Parsing intermediate schema...");
163 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
164 };
165
166 if !is_toml && Path::new("fraiseql.toml").exists() {
171 info!("Loading security configuration from fraiseql.toml...");
172 match FraiseQLConfig::from_file("fraiseql.toml") {
173 Ok(config) => {
174 info!("Validating security configuration...");
175 config.validate()?;
176
177 info!("Applying security configuration to schema...");
178 let security_json = config.fraiseql.security.to_json();
180 intermediate.security = Some(security_json);
181
182 info!("Security configuration applied successfully");
183 },
184 Err(e) => {
185 anyhow::bail!(
186 "Failed to parse fraiseql.toml: {e}\n\
187 Fix the configuration file or remove it to use defaults."
188 );
189 },
190 }
191 } else {
192 info!("No fraiseql.toml found, using default security configuration");
193 }
194
195 info!("Validating schema structure...");
197 let validation_report =
198 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
199
200 if !validation_report.is_valid() {
201 validation_report.print();
202 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
203 }
204
205 if validation_report.warning_count() > 0 {
207 validation_report.print();
208 }
209
210 info!("Converting to compiled format...");
212 let mut schema = SchemaConverter::convert(intermediate)
213 .context("Failed to convert schema to compiled format")?;
214
215 info!("Analyzing schema for optimization opportunities...");
217 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
218
219 if let Some(db_url) = opts.database {
221 info!("Validating indexed columns against database...");
222 validate_indexed_columns(&schema, db_url).await?;
223 }
224
225 Ok((schema, report))
226}
227
228#[allow(clippy::too_many_arguments)] pub async fn run(
260 input: &str,
261 types: Option<&str>,
262 schema_dir: Option<&str>,
263 type_files: Vec<String>,
264 query_files: Vec<String>,
265 mutation_files: Vec<String>,
266 output: &str,
267 check: bool,
268 database: Option<&str>,
269) -> Result<()> {
270 let opts = CompileOptions {
271 input,
272 types,
273 schema_dir,
274 type_files,
275 query_files,
276 mutation_files,
277 database,
278 };
279 let (schema, optimization_report) = compile_to_schema(opts).await?;
280
281 if check {
283 println!("✓ Schema is valid");
284 println!(" Types: {}", schema.types.len());
285 println!(" Queries: {}", schema.queries.len());
286 println!(" Mutations: {}", schema.mutations.len());
287 optimization_report.print();
288 return Ok(());
289 }
290
291 info!("Writing compiled schema to: {output}");
293 let output_json =
294 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
295 fs::write(output, output_json).context("Failed to write compiled schema")?;
296
297 println!("✓ Schema compiled successfully");
299 println!(" Input: {input}");
300 println!(" Output: {output}");
301 println!(" Types: {}", schema.types.len());
302 println!(" Queries: {}", schema.queries.len());
303 println!(" Mutations: {}", schema.mutations.len());
304 optimization_report.print();
305
306 Ok(())
307}
308
309async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
324 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
325 use fraiseql_core::db::postgres::PostgresIntrospector;
326 use tokio_postgres::NoTls;
327
328 let mut cfg = Config::new();
330 cfg.url = Some(db_url.to_string());
331 cfg.manager = Some(ManagerConfig {
332 recycling_method: RecyclingMethod::Fast,
333 });
334 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
335
336 let pool = cfg
337 .create_pool(Some(Runtime::Tokio1), NoTls)
338 .context("Failed to create connection pool for indexed column validation")?;
339
340 let introspector = PostgresIntrospector::new(pool);
341
342 let mut total_indexed = 0;
343 let mut total_views = 0;
344
345 for query in &schema.queries {
347 if let Some(view_name) = &query.sql_source {
348 total_views += 1;
349
350 match introspector.get_indexed_nested_columns(view_name).await {
352 Ok(indexed_cols) => {
353 if !indexed_cols.is_empty() {
354 info!(
355 "View '{}': found {} indexed column(s): {:?}",
356 view_name,
357 indexed_cols.len(),
358 indexed_cols
359 );
360 total_indexed += indexed_cols.len();
361 }
362 },
363 Err(e) => {
364 warn!(
365 "Could not introspect view '{}': {}. Skipping indexed column check.",
366 view_name, e
367 );
368 },
369 }
370 }
371 }
372
373 println!("✓ Indexed column validation complete");
374 println!(" Views checked: {total_views}");
375 println!(" Indexed columns found: {total_indexed}");
376
377 Ok(())
378}
379
380#[cfg(test)]
381mod tests {
382 use std::collections::HashMap;
383
384 use fraiseql_core::{
385 schema::{
386 AutoParams, CompiledSchema, CursorType, FieldDefinition, FieldDenyPolicy, FieldType,
387 QueryDefinition, TypeDefinition,
388 },
389 validation::CustomTypeRegistry,
390 };
391 use indexmap::IndexMap;
392
393 #[test]
394 fn test_validate_schema_success() {
395 let schema = CompiledSchema {
396 types: vec![TypeDefinition {
397 name: "User".to_string(),
398 fields: vec![
399 FieldDefinition {
400 name: "id".to_string(),
401 field_type: FieldType::Int,
402 nullable: false,
403 default_value: None,
404 description: None,
405 vector_config: None,
406 alias: None,
407 deprecation: None,
408 requires_scope: None,
409 on_deny: FieldDenyPolicy::default(),
410 encryption: None,
411 },
412 FieldDefinition {
413 name: "name".to_string(),
414 field_type: FieldType::String,
415 nullable: false,
416 default_value: None,
417 description: None,
418 vector_config: None,
419 alias: None,
420 deprecation: None,
421 requires_scope: None,
422 on_deny: FieldDenyPolicy::default(),
423 encryption: None,
424 },
425 ],
426 description: Some("User type".to_string()),
427 sql_source: String::new(),
428 jsonb_column: String::new(),
429 sql_projection_hint: None,
430 implements: vec![],
431 requires_role: None,
432 is_error: false,
433 relay: false,
434 }],
435 queries: vec![QueryDefinition {
436 name: "users".to_string(),
437 return_type: "User".to_string(),
438 returns_list: true,
439 nullable: false,
440 arguments: vec![],
441 sql_source: Some("v_user".to_string()),
442 description: Some("Get users".to_string()),
443 auto_params: AutoParams::default(),
444 deprecation: None,
445 jsonb_column: "data".to_string(),
446 relay: false,
447 relay_cursor_column: None,
448 relay_cursor_type: CursorType::default(),
449 inject_params: IndexMap::default(),
450 cache_ttl_seconds: None,
451 additional_views: vec![],
452 requires_role: None,
453 }],
454 enums: vec![],
455 input_types: vec![],
456 interfaces: vec![],
457 unions: vec![],
458 mutations: vec![],
459 subscriptions: vec![],
460 directives: vec![],
461 observers: Vec::new(),
462 fact_tables: HashMap::default(),
463 federation: None,
464 security: None,
465 observers_config: None,
466 subscriptions_config: None,
467 validation_config: None,
468 debug_config: None,
469 mcp_config: None,
470 schema_sdl: None,
471 custom_scalars: CustomTypeRegistry::default(),
472 };
473
474 assert_eq!(schema.types.len(), 1);
477 assert_eq!(schema.queries.len(), 1);
478 }
479
480 #[test]
481 fn test_validate_schema_unknown_type() {
482 let schema = CompiledSchema {
483 types: vec![],
484 enums: vec![],
485 input_types: vec![],
486 interfaces: vec![],
487 unions: vec![],
488 queries: vec![QueryDefinition {
489 name: "users".to_string(),
490 return_type: "UnknownType".to_string(),
491 returns_list: true,
492 nullable: false,
493 arguments: vec![],
494 sql_source: Some("v_user".to_string()),
495 description: Some("Get users".to_string()),
496 auto_params: AutoParams::default(),
497 deprecation: None,
498 jsonb_column: "data".to_string(),
499 relay: false,
500 relay_cursor_column: None,
501 relay_cursor_type: CursorType::default(),
502 inject_params: IndexMap::default(),
503 cache_ttl_seconds: None,
504 additional_views: vec![],
505 requires_role: None,
506 }],
507 mutations: vec![],
508 subscriptions: vec![],
509 directives: vec![],
510 observers: Vec::new(),
511 fact_tables: HashMap::default(),
512 federation: None,
513 security: None,
514 observers_config: None,
515 subscriptions_config: None,
516 validation_config: None,
517 debug_config: None,
518 mcp_config: None,
519 schema_sdl: None,
520 custom_scalars: CustomTypeRegistry::default(),
521 };
522
523 assert_eq!(schema.types.len(), 0);
526 assert_eq!(schema.queries[0].return_type, "UnknownType");
527 }
528}