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::{IntermediateSchema, SchemaConverter, SchemaOptimizer, SchemaValidator},
14};
15
16#[allow(clippy::too_many_arguments)]
47pub async fn run(
48 input: &str,
49 types: Option<&str>,
50 schema_dir: Option<&str>,
51 type_files: Vec<String>,
52 query_files: Vec<String>,
53 mutation_files: Vec<String>,
54 output: &str,
55 check: bool,
56 database: Option<&str>,
57) -> Result<()> {
58 info!("Compiling schema: {input}");
59
60 let input_path = Path::new(input);
62 if !input_path.exists() {
63 anyhow::bail!("Input file not found: {input}");
64 }
65
66 let is_toml = input_path
68 .extension()
69 .and_then(|ext| ext.to_str())
70 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
71 let mut intermediate: IntermediateSchema = if is_toml {
72 info!("Using TOML-based workflow");
74
75 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
84 info!("Mode: Explicit file lists");
86 crate::schema::SchemaMerger::merge_explicit_files(
87 input,
88 &type_files,
89 &query_files,
90 &mutation_files,
91 )
92 .context("Failed to load explicit schema files")?
93 } else if let Some(dir) = schema_dir {
94 info!("Mode: Auto-discovery from directory: {}", dir);
96 crate::schema::SchemaMerger::merge_from_directory(input, dir)
97 .context("Failed to load schema from directory")?
98 } else if let Some(types_path) = types {
99 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
101 crate::schema::SchemaMerger::merge_files(types_path, input)
102 .context("Failed to merge types.json with TOML")?
103 } else {
104 info!("Mode: TOML-based (checking for domain discovery...)");
106 if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(input) {
107 schema
108 } else {
109 info!("No domains configured, checking for TOML includes...");
110 if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(input) {
111 schema
112 } else {
113 info!("No includes configured, using TOML-only definitions");
114 crate::schema::SchemaMerger::merge_toml_only(input)
115 .context("Failed to load schema from TOML")?
116 }
117 }
118 }
119 } else {
120 info!("Using legacy JSON workflow");
122 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
123
124 info!("Parsing intermediate schema...");
126 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
127 };
128
129 if Path::new("fraiseql.toml").exists() {
131 info!("Loading security configuration from fraiseql.toml...");
132 match FraiseQLConfig::from_file("fraiseql.toml") {
133 Ok(config) => {
134 info!("Validating security configuration...");
135 config.validate()?;
136
137 info!("Applying security configuration to schema...");
138 let security_json = config.fraiseql.security.to_json();
140 intermediate.security = Some(security_json);
141
142 info!("Security configuration applied successfully");
143 },
144 Err(e) => {
145 warn!("Failed to load fraiseql.toml: {e}");
146 warn!("Continuing with default security configuration");
147 },
148 }
149 } else {
150 info!("No fraiseql.toml found, using default security configuration");
151 }
152
153 info!("Validating schema structure...");
155 let validation_report =
156 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
157
158 if !validation_report.is_valid() {
159 validation_report.print();
160 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
161 }
162
163 if validation_report.warning_count() > 0 {
165 validation_report.print();
166 }
167
168 info!("Converting to compiled format...");
170 let mut schema = SchemaConverter::convert(intermediate)
171 .context("Failed to convert schema to compiled format")?;
172
173 info!("Analyzing schema for optimization opportunities...");
175 let optimization_report =
176 SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
177
178 if let Some(db_url) = database {
180 info!("Validating indexed columns against database...");
181 validate_indexed_columns(&schema, db_url).await?;
182 }
183
184 if check {
186 println!("✓ Schema is valid");
187 println!(" Types: {}", schema.types.len());
188 println!(" Queries: {}", schema.queries.len());
189 println!(" Mutations: {}", schema.mutations.len());
190
191 optimization_report.print();
193
194 return Ok(());
195 }
196
197 info!("Writing compiled schema to: {output}");
199 let output_json =
200 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
201
202 fs::write(output, output_json).context("Failed to write compiled schema")?;
203
204 println!("✓ Schema compiled successfully");
206 println!(" Input: {input}");
207 println!(" Output: {output}");
208 println!(" Types: {}", schema.types.len());
209 println!(" Queries: {}", schema.queries.len());
210 println!(" Mutations: {}", schema.mutations.len());
211
212 optimization_report.print();
214
215 Ok(())
216}
217
218async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
233 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
234 use fraiseql_core::db::postgres::PostgresIntrospector;
235 use tokio_postgres::NoTls;
236
237 let mut cfg = Config::new();
239 cfg.url = Some(db_url.to_string());
240 cfg.manager = Some(ManagerConfig {
241 recycling_method: RecyclingMethod::Fast,
242 });
243 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
244
245 let pool = cfg
246 .create_pool(Some(Runtime::Tokio1), NoTls)
247 .context("Failed to create connection pool for indexed column validation")?;
248
249 let introspector = PostgresIntrospector::new(pool);
250
251 let mut total_indexed = 0;
252 let mut total_views = 0;
253
254 for query in &schema.queries {
256 if let Some(view_name) = &query.sql_source {
257 total_views += 1;
258
259 match introspector.get_indexed_nested_columns(view_name).await {
261 Ok(indexed_cols) => {
262 if !indexed_cols.is_empty() {
263 info!(
264 "View '{}': found {} indexed column(s): {:?}",
265 view_name,
266 indexed_cols.len(),
267 indexed_cols
268 );
269 total_indexed += indexed_cols.len();
270 }
271 },
272 Err(e) => {
273 warn!(
274 "Could not introspect view '{}': {}. Skipping indexed column check.",
275 view_name, e
276 );
277 },
278 }
279 }
280 }
281
282 println!("✓ Indexed column validation complete");
283 println!(" Views checked: {total_views}");
284 println!(" Indexed columns found: {total_indexed}");
285
286 Ok(())
287}
288
289#[cfg(test)]
290mod tests {
291 use std::collections::HashMap;
292
293 use fraiseql_core::schema::{
294 AutoParams, CompiledSchema, FieldDefinition, FieldType, QueryDefinition, TypeDefinition,
295 };
296 use fraiseql_core::validation::CustomTypeRegistry;
297
298 #[test]
299 fn test_validate_schema_success() {
300 let schema = CompiledSchema {
301 types: vec![TypeDefinition {
302 name: "User".to_string(),
303 fields: vec![
304 FieldDefinition {
305 name: "id".to_string(),
306 field_type: FieldType::Int,
307 nullable: false,
308 default_value: None,
309 description: None,
310 vector_config: None,
311 alias: None,
312 deprecation: None,
313 requires_scope: None,
314 },
315 FieldDefinition {
316 name: "name".to_string(),
317 field_type: FieldType::String,
318 nullable: false,
319 default_value: None,
320 description: None,
321 vector_config: None,
322 alias: None,
323 deprecation: None,
324 requires_scope: None,
325 },
326 ],
327 description: Some("User type".to_string()),
328 sql_source: String::new(),
329 jsonb_column: String::new(),
330 sql_projection_hint: None,
331 implements: vec![],
332 }],
333 queries: vec![QueryDefinition {
334 name: "users".to_string(),
335 return_type: "User".to_string(),
336 returns_list: true,
337 nullable: false,
338 arguments: vec![],
339 sql_source: Some("v_user".to_string()),
340 description: Some("Get users".to_string()),
341 auto_params: AutoParams::default(),
342 deprecation: None,
343 jsonb_column: "data".to_string(),
344 }],
345 enums: vec![],
346 input_types: vec![],
347 interfaces: vec![],
348 unions: vec![],
349 mutations: vec![],
350 subscriptions: vec![],
351 directives: vec![],
352 observers: Vec::new(),
353 fact_tables: HashMap::default(),
354 federation: None,
355 security: None,
356 schema_sdl: None,
357 custom_scalars: CustomTypeRegistry::default(),
358 };
359
360 assert_eq!(schema.types.len(), 1);
363 assert_eq!(schema.queries.len(), 1);
364 }
365
366 #[test]
367 fn test_validate_schema_unknown_type() {
368 let schema = CompiledSchema {
369 types: vec![],
370 enums: vec![],
371 input_types: vec![],
372 interfaces: vec![],
373 unions: vec![],
374 queries: vec![QueryDefinition {
375 name: "users".to_string(),
376 return_type: "UnknownType".to_string(),
377 returns_list: true,
378 nullable: false,
379 arguments: vec![],
380 sql_source: Some("v_user".to_string()),
381 description: Some("Get users".to_string()),
382 auto_params: AutoParams::default(),
383 deprecation: None,
384 jsonb_column: "data".to_string(),
385 }],
386 mutations: vec![],
387 subscriptions: vec![],
388 directives: vec![],
389 observers: Vec::new(),
390 fact_tables: HashMap::default(),
391 federation: None,
392 security: None,
393 schema_sdl: None,
394 custom_scalars: CustomTypeRegistry::default(),
395 };
396
397 assert_eq!(schema.types.len(), 0);
400 assert_eq!(schema.queries[0].return_type, "UnknownType");
401 }
402}