Skip to main content

fraiseql_cli/commands/
compile.rs

1//! Schema compilation command
2//!
3//! Compiles schema.json (from Python/TypeScript/etc.) into optimized schema.compiled.json
4
5use 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/// Input source configuration for schema compilation.
19#[derive(Debug, Default)]
20pub struct CompileOptions<'a> {
21    /// Path to `fraiseql.toml` (TOML workflow) or `schema.json` (legacy).
22    pub input:          &'a str,
23    /// Optional path to `types.json` (TOML workflow, backward compat).
24    pub types:          Option<&'a str>,
25    /// Optional directory for schema file auto-discovery.
26    pub schema_dir:     Option<&'a str>,
27    /// Explicit type file paths (highest priority).
28    pub type_files:     Vec<String>,
29    /// Explicit query file paths.
30    pub query_files:    Vec<String>,
31    /// Explicit mutation file paths.
32    pub mutation_files: Vec<String>,
33    /// Optional database URL for indexed column validation.
34    pub database:       Option<&'a str>,
35}
36
37impl<'a> CompileOptions<'a> {
38    /// Create new compile options with just the input path.
39    #[must_use]
40    pub fn new(input: &'a str) -> Self {
41        Self {
42            input,
43            ..Default::default()
44        }
45    }
46
47    /// Set the types path.
48    #[must_use]
49    pub fn with_types(mut self, types: &'a str) -> Self {
50        self.types = Some(types);
51        self
52    }
53
54    /// Set the schema directory for auto-discovery.
55    #[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    /// Set the database URL for validation.
62    #[must_use]
63    pub fn with_database(mut self, database: &'a str) -> Self {
64        self.database = Some(database);
65        self
66    }
67}
68
69/// Select and execute the appropriate schema-loading strategy for TOML-based workflows.
70///
71/// Tries strategies in priority order:
72/// 1. Explicit file lists (highest priority)
73/// 2. Directory auto-discovery
74/// 3. Single types file (backward-compatible)
75/// 4. Domain discovery → TOML includes → TOML-only (fallback sequence)
76fn 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
117/// Compile a schema to `CompiledSchema` without writing to disk.
118///
119/// This is the core compilation logic, shared between `compile` (which writes to disk)
120/// and `run` (which serves in-memory without any file artifacts).
121///
122/// # Arguments
123///
124/// * `opts` - Compilation options including input paths and configuration
125///
126/// # Errors
127///
128/// Returns error if input is missing, parsing fails, validation fails, or the database
129/// connection fails (when `database` is provided).
130pub async fn compile_to_schema(
131    opts: CompileOptions<'_>,
132) -> Result<(CompiledSchema, OptimizationReport)> {
133    info!("Compiling schema: {}", opts.input);
134
135    // 1. Determine workflow based on input file and options
136    let input_path = Path::new(opts.input);
137    if !input_path.exists() {
138        anyhow::bail!("Input file not found: {}", opts.input);
139    }
140
141    // Load schema based on file type and options
142    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        // Legacy JSON workflow
158        info!("Using legacy JSON workflow");
159        let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
160
161        // 2. Parse JSON into IntermediateSchema (language-agnostic format)
162        info!("Parsing intermediate schema...");
163        serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
164    };
165
166    // 2a. Load and apply security configuration from fraiseql.toml if it exists.
167    // Skip when the input itself is a TomlSchema file: in that case the security
168    // settings are embedded in the TomlSchema, and the CWD fraiseql.toml uses a
169    // different TOML format (TomlSchema vs FraiseQLConfig) that is not compatible.
170    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                // Merge security config into intermediate schema
179                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    // 3. Validate intermediate schema
196    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    // Print warnings if any
206    if validation_report.warning_count() > 0 {
207        validation_report.print();
208    }
209
210    // 4. Convert to CompiledSchema (validates and normalizes)
211    info!("Converting to compiled format...");
212    let mut schema = SchemaConverter::convert(intermediate)
213        .context("Failed to convert schema to compiled format")?;
214
215    // 5. Optimize schema and generate SQL hints (mutates schema in place, report for display)
216    info!("Analyzing schema for optimization opportunities...");
217    let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
218
219    // 5b. Optional: Validate indexed columns against database
220    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/// Run the compile command
229///
230/// # Arguments
231///
232/// * `input` - Path to fraiseql.toml (TOML) or schema.json (legacy)
233/// * `types` - Optional path to types.json (when using TOML workflow)
234/// * `schema_dir` - Optional directory for auto-discovery of schema files
235/// * `type_files` - Optional vector of explicit type file paths
236/// * `query_files` - Optional vector of explicit query file paths
237/// * `mutation_files` - Optional vector of explicit mutation file paths
238/// * `output` - Path to write schema.compiled.json
239/// * `check` - If true, validate only without writing output
240/// * `database` - Optional database URL for indexed column validation
241///
242/// # Workflows
243///
244/// 1. TOML-only: `fraiseql compile fraiseql.toml`
245/// 2. Language + TOML: `fraiseql compile fraiseql.toml --types types.json`
246/// 3. Multi-file auto-discovery: `fraiseql compile fraiseql.toml --schema-dir schema/`
247/// 4. Multi-file explicit: `fraiseql compile fraiseql.toml --type-file a.json --type-file b.json`
248/// 5. Legacy JSON: `fraiseql compile schema.json`
249///
250/// # Errors
251///
252/// Returns error if:
253/// - Input file doesn't exist or can't be read
254/// - JSON/TOML parsing fails
255/// - Schema validation fails
256/// - Output file can't be written
257/// - Database connection fails (when database URL is provided)
258#[allow(clippy::too_many_arguments)] // Reason: run() is the CLI entry point that receives individual args from clap; keeping them separate for clarity
259pub 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-only mode, stop here
282    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    // Write compiled schema
292    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    // Success message
298    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
309/// Validate indexed columns against database views.
310///
311/// Connects to the database and introspects view columns to verify that
312/// any indexed column naming conventions are properly set up.
313///
314/// # Arguments
315///
316/// * `schema` - The compiled schema to validate
317/// * `db_url` - Database connection URL
318///
319/// # Errors
320///
321/// Returns error if database connection fails. Warnings are printed for
322/// missing indexed columns but don't cause validation to fail.
323async 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    // Create pool for introspection
329    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    // Check each query's sql_source (view)
346    for query in &schema.queries {
347        if let Some(view_name) = &query.sql_source {
348            total_views += 1;
349
350            // Get indexed columns for this view
351            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        // Validation is done inside SchemaConverter::convert, not exposed separately
475        // This test just verifies we can build a valid schema structure
476        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        // Note: Validation is private to SchemaConverter
524        // This test demonstrates the schema structure with an invalid type
525        assert_eq!(schema.types.len(), 0);
526        assert_eq!(schema.queries[0].return_type, "UnknownType");
527    }
528}