Skip to main content

fraiseql_core/compiler/
mod.rs

1//! Schema compiler for FraiseQL v2.
2//!
3//! # Overview
4//!
5//! The compiler transforms GraphQL schema definitions (from authoring-language decorators)
6//! into optimized, executable `CompiledSchema` with pre-generated SQL templates.
7//!
8//! ## Compilation Pipeline
9//!
10//! ```text
11//! JSON Schema (from decorators)
12//!         ↓
13//!    Parser (parse.rs)
14//!         ↓ [Syntax validation]
15//!
16//! Authoring IR (ir.rs)
17//!         ↓
18//!   Validator (validator.rs)
19//!         ↓ [Type checking, name validation]
20//!
21//! Lowering (lowering.rs)
22//!         ↓ [Build optimized IR for code generation]
23//!
24//!   SQL Templates
25//!         ↓ [Database-specific artifact]
26//!
27//!    Codegen (codegen.rs)
28//!         ↓ [Generate runtime schema metadata]
29//!
30//! CompiledSchema JSON
31//!         ↓
32//! Ready for Runtime Execution
33//! ```
34//!
35//! ## Design Principles
36//!
37//! ### 1. Separation of Concerns
38//!
39//! Schema definition (what queries exist?) is kept separate from execution
40//! artifacts (how to execute them?). This allows:
41//! - Different SQL generation strategies (optimize for OLTP vs OLAP)
42//! - Database-specific optimizations without changing schema
43//! - Reuse of schemas across backends
44//! - Testing schema independently from SQL generation
45//!
46//! ### 2. Staged Compilation
47//!
48//! Each phase has a specific responsibility:
49//! - **Parsing**: Convert JSON → AST, syntax validation
50//! - **Validation**: Type checking, semantic validation, circular reference detection
51//! - **Lowering**: Optimize IR, prepare for code generation
52//! - **Codegen**: Generate runtime metadata and schema introspection data
53//!
54//! This separation makes the compiler maintainable, testable, and allows reuse of
55//! phases for different purposes.
56//!
57//! ### 3. Immutable Intermediate State
58//!
59//! Each phase produces immutable data structures (`AuthoringIR`, `CompiledSchema`, etc.)
60//! This ensures:
61//! - Reproducible builds (same input = same output)
62//! - Thread-safe processing
63//! - Clear data flow and dependencies
64//! - Easy debugging and verification
65//!
66//! # Phases
67//!
68//! 1. **Parse** (`parser.rs`): JSON schema → Authoring IR
69//!    - Syntax validation
70//!    - AST construction
71//!
72//! 2. **Validate** (`validator.rs`): Type checking and semantic validation
73//!    - Field type binding
74//!    - Circular reference detection
75//!    - Auth rule validation
76//!
77//! 3. **Lower** (`lowering.rs`): IR optimization for execution
78//!    - Fact table extraction
79//!    - Query optimization
80//!    - Template preparation
81//!
82//! 4. **Codegen** (`codegen.rs`): Generate `CompiledSchema`
83//!    - Runtime metadata
84//!    - Schema introspection data
85//!    - Field mappings
86//!
87//! # Example
88//!
89//! ```rust,no_run
90//! use fraiseql_core::compiler::Compiler;
91//!
92//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
93//! // Create compiler
94//! let compiler = Compiler::new();
95//!
96//! // Compile schema from JSON
97//! let schema_json = r#"{
98//!     "types": [...],
99//!     "queries": [...]
100//! }"#;
101//!
102//! let compiled = compiler.compile(schema_json)?;
103//!
104//! // Output CompiledSchema JSON
105//! let output = compiled.to_json()?;
106//! # Ok(())
107//! # }
108//! ```
109
110pub mod aggregate_types;
111pub mod aggregation;
112mod codegen;
113pub mod compilation_cache;
114pub mod enum_validator;
115pub mod fact_table;
116pub mod ir;
117mod lowering;
118pub mod parser;
119pub mod validator;
120pub mod window_allowlist;
121pub mod window_functions;
122
123pub use aggregate_types::{AggregateType, AggregateTypeGenerator, GroupByInput, HavingInput};
124pub use aggregation::{AggregationPlan, AggregationPlanner, AggregationRequest};
125pub use codegen::CodeGenerator;
126pub use compilation_cache::{CompilationCache, CompilationCacheConfig, CompilationCacheMetrics};
127pub use enum_validator::EnumValidator;
128pub use ir::{
129    AuthoringIR, AutoParams, IRArgument, IRField, IRMutation, IRQuery, IRSubscription, IRType,
130    MutationOperation,
131};
132pub use lowering::{DatabaseTarget, SqlTemplateGenerator};
133pub use parser::SchemaParser;
134pub use validator::{SchemaValidationError, SchemaValidator};
135pub use window_functions::{WindowExecutionPlan, WindowFunction, WindowFunctionPlanner};
136
137use crate::{error::Result, schema::CompiledSchema};
138
139/// Compiler configuration.
140#[derive(Debug, Clone)]
141pub struct CompilerConfig {
142    /// Target database for SQL generation.
143    pub database_target: DatabaseTarget,
144
145    /// Enable SQL template optimization.
146    pub optimize_sql: bool,
147
148    /// Strict mode: Fail on warnings.
149    pub strict_mode: bool,
150
151    /// Enable debug output.
152    pub debug: bool,
153
154    /// Database URL for fact table introspection (optional).
155    /// If provided, compiler will auto-detect fact tables and generate aggregate types.
156    pub database_url: Option<String>,
157}
158
159impl Default for CompilerConfig {
160    fn default() -> Self {
161        Self {
162            database_target: DatabaseTarget::PostgreSQL,
163            optimize_sql:    true,
164            strict_mode:     false,
165            debug:           false,
166            database_url:    None,
167        }
168    }
169}
170
171/// Schema compiler.
172///
173/// Transforms authoring-time schema definitions into runtime-optimized
174/// `CompiledSchema` with pre-generated SQL templates.
175///
176/// # Example
177///
178/// ```rust,no_run
179/// use fraiseql_core::compiler::{Compiler, CompilerConfig, DatabaseTarget};
180///
181/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
182/// let config = CompilerConfig {
183///     database_target: DatabaseTarget::PostgreSQL,
184///     optimize_sql: true,
185///     ..Default::default()
186/// };
187///
188/// let compiler = Compiler::with_config(config);
189/// let compiled = compiler.compile(r#"{"types": [], "queries": []}"#)?;
190/// # Ok(())
191/// # }
192/// ```
193pub struct Compiler {
194    config:    CompilerConfig,
195    parser:    SchemaParser,
196    validator: SchemaValidator,
197    lowering:  SqlTemplateGenerator,
198    codegen:   CodeGenerator,
199}
200
201impl Compiler {
202    /// Create new compiler with default configuration.
203    #[must_use]
204    pub fn new() -> Self {
205        Self::with_config(CompilerConfig::default())
206    }
207
208    /// Create new compiler with custom configuration.
209    #[must_use]
210    pub const fn with_config(config: CompilerConfig) -> Self {
211        Self {
212            parser: SchemaParser::new(),
213            validator: SchemaValidator::new(),
214            lowering: SqlTemplateGenerator::new(config.database_target),
215            codegen: CodeGenerator::new(config.optimize_sql),
216            config,
217        }
218    }
219
220    /// Compile schema from JSON.
221    ///
222    /// # Arguments
223    ///
224    /// * `schema_json` - JSON schema emitted by the authoring-language decorators
225    ///
226    /// # Returns
227    ///
228    /// Compiled schema with pre-generated SQL templates
229    ///
230    /// # Errors
231    ///
232    /// Returns error if:
233    /// - JSON parsing fails
234    /// - Schema validation fails
235    /// - SQL template generation fails
236    ///
237    /// # Example
238    ///
239    /// ```rust,no_run
240    /// use fraiseql_core::compiler::Compiler;
241    ///
242    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
243    /// let compiler = Compiler::new();
244    /// let schema_json = r#"{"types": [], "queries": []}"#;
245    /// let compiled = compiler.compile(schema_json)?;
246    /// # Ok(())
247    /// # }
248    /// ```
249    #[allow(clippy::cognitive_complexity)] // Reason: sequential compilation pipeline (parse → validate → generate SQL → assemble)
250    pub fn compile(&self, schema_json: &str) -> Result<CompiledSchema> {
251        // Parse JSON → Authoring IR
252        tracing::debug!("Parsing schema...");
253        let ir = self.parser.parse(schema_json)?;
254
255        // Validate IR
256        tracing::debug!("Validating schema...");
257        let validated_ir = self.validator.validate(ir)?;
258
259        // Lower IR → SQL templates (validates SQL generation; templates currently unused by
260        // codegen)
261        tracing::debug!("Generating SQL templates...");
262        let _sql_templates = self.lowering.generate(&validated_ir)?;
263
264        // Codegen: IR → CompiledSchema
265        tracing::debug!("Generating CompiledSchema...");
266        let compiled = self.codegen.generate(&validated_ir)?;
267
268        // Note: Fact table metadata will be added by external tools or
269        // through explicit API calls (e.g., from authoring-language decorators)
270
271        tracing::debug!("Compilation complete!");
272
273        Ok(compiled)
274    }
275
276    /// Get compiler configuration.
277    #[must_use]
278    pub const fn config(&self) -> &CompilerConfig {
279        &self.config
280    }
281}
282
283impl Default for Compiler {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
292
293    use super::*;
294    use crate::error::FraiseQLError;
295
296    // ── EP-1: Parse error paths ───────────────────────────────────────────────
297
298    #[test]
299    fn test_compile_rejects_invalid_json() {
300        let err = Compiler::new().compile("not json").unwrap_err();
301        assert!(matches!(err, FraiseQLError::Parse { .. }), "got: {err:?}");
302    }
303
304    #[test]
305    fn test_compile_rejects_non_object_schema() {
306        let err = Compiler::new().compile(r#"["not", "an", "object"]"#).unwrap_err();
307        assert!(matches!(err, FraiseQLError::Parse { .. }), "got: {err:?}");
308    }
309
310    #[test]
311    fn test_compile_rejects_types_not_array() {
312        let err = Compiler::new().compile(r#"{"types": "wrong"}"#).unwrap_err();
313        assert!(matches!(err, FraiseQLError::Parse { .. }), "got: {err:?}");
314    }
315
316    #[test]
317    fn test_compile_rejects_type_without_name() {
318        // A type object that is missing the required "name" field.
319        let schema = r#"{"types": [{"fields": []}]}"#;
320        let err = Compiler::new().compile(schema).unwrap_err();
321        assert!(matches!(err, FraiseQLError::Parse { .. }), "got: {err:?}");
322    }
323
324    // ── EP-2: Validation error paths ─────────────────────────────────────────
325
326    #[test]
327    fn test_compile_rejects_unknown_field_type() {
328        let schema = r#"{"types": [{"name": "User", "fields": [
329            {"name": "id", "type": "NonExistentType"}
330        ]}]}"#;
331        let err = Compiler::new().compile(schema).unwrap_err();
332        assert!(matches!(err, FraiseQLError::Validation { .. }), "got: {err:?}");
333    }
334
335    #[test]
336    fn test_compile_rejects_query_with_unknown_return_type() {
337        // "User" is not defined in types, so the query return type is unknown.
338        let schema = r#"{"types": [], "queries": [
339            {"name": "getUser", "return_type": "User", "returns_list": false}
340        ]}"#;
341        let err = Compiler::new().compile(schema).unwrap_err();
342        assert!(matches!(err, FraiseQLError::Validation { .. }), "got: {err:?}");
343        if let FraiseQLError::Validation { message, .. } = err {
344            assert!(
345                message.contains("User"),
346                "error message should name the unknown type: {message}"
347            );
348        }
349    }
350
351    #[test]
352    fn test_compiler_new() {
353        let compiler = Compiler::new();
354        assert_eq!(compiler.config.database_target, DatabaseTarget::PostgreSQL);
355        assert!(compiler.config.optimize_sql);
356    }
357
358    #[test]
359    fn test_compiler_with_config() {
360        let config = CompilerConfig {
361            database_target: DatabaseTarget::MySQL,
362            optimize_sql:    false,
363            strict_mode:     true,
364            debug:           true,
365            database_url:    None,
366        };
367
368        let compiler = Compiler::with_config(config);
369        assert_eq!(compiler.config.database_target, DatabaseTarget::MySQL);
370        assert!(!compiler.config.optimize_sql);
371        assert!(compiler.config.strict_mode);
372        assert!(compiler.config.debug);
373    }
374
375    #[test]
376    fn test_default_config() {
377        let config = CompilerConfig::default();
378        assert_eq!(config.database_target, DatabaseTarget::PostgreSQL);
379        assert!(config.optimize_sql);
380        assert!(!config.strict_mode);
381        assert!(!config.debug);
382        assert!(config.database_url.is_none());
383    }
384
385    #[test]
386    fn test_compile_schema_with_fact_tables() {
387        let compiler = Compiler::new();
388        let schema_json = r#"{
389            "types": [],
390            "queries": [],
391            "mutations": []
392        }"#;
393
394        let compiled = compiler.compile(schema_json).unwrap_or_else(|e| panic!("expected Ok: {e}"));
395        assert_eq!(compiled.fact_tables.len(), 0);
396    }
397
398    #[test]
399    fn test_compiled_schema_fact_table_operations() {
400        use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata};
401
402        let mut schema = CompiledSchema::new();
403
404        let metadata = FactTableMetadata {
405            table_name:           "tf_sales".to_string(),
406            measures:             vec![],
407            dimensions:           DimensionColumn {
408                name:  "data".to_string(),
409                paths: vec![],
410            },
411            denormalized_filters: vec![],
412            calendar_dimensions:  vec![],
413        };
414
415        schema.add_fact_table("tf_sales".to_string(), metadata.clone());
416
417        assert!(schema.has_fact_tables());
418
419        let tables = schema.list_fact_tables();
420        assert_eq!(tables.len(), 1);
421        assert!(tables.contains(&"tf_sales"));
422
423        let retrieved = schema.get_fact_table("tf_sales");
424        assert!(retrieved.is_some());
425        assert_eq!(retrieved.unwrap(), &metadata);
426
427        assert!(schema.get_fact_table("tf_nonexistent").is_none());
428    }
429}