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}