1use std::{fs, path::Path, process::Command};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{
9 CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType, canonicalize_json,
10};
11use tracing::{info, warn};
12
13use crate::{
14 config::TomlProjectConfig,
15 schema::{
16 IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer, SchemaValidator,
17 database_validator::validate_schema_against_database,
18 },
19};
20
21#[derive(Debug, Default)]
23pub struct CompileOptions<'a> {
24 pub input: &'a str,
26 pub types: Option<&'a str>,
28 pub schema_dir: Option<&'a str>,
30 pub type_files: Vec<String>,
32 pub query_files: Vec<String>,
34 pub mutation_files: Vec<String>,
36 pub database: Option<&'a str>,
38 pub skip_hash: bool,
40}
41
42impl<'a> CompileOptions<'a> {
43 #[must_use]
45 pub fn new(input: &'a str) -> Self {
46 Self {
47 input,
48 ..Default::default()
49 }
50 }
51
52 #[must_use]
54 pub fn with_types(mut self, types: &'a str) -> Self {
55 self.types = Some(types);
56 self
57 }
58
59 #[must_use]
61 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
62 self.schema_dir = Some(schema_dir);
63 self
64 }
65
66 #[must_use]
68 pub fn with_database(mut self, database: &'a str) -> Self {
69 self.database = Some(database);
70 self
71 }
72}
73
74#[allow(clippy::cognitive_complexity)] fn load_intermediate_schema(
83 toml_path: &str,
84 type_files: &[String],
85 query_files: &[String],
86 mutation_files: &[String],
87 schema_dir: Option<&str>,
88 types_path: Option<&str>,
89) -> Result<IntermediateSchema> {
90 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
91 info!("Mode: Explicit file lists");
92 return crate::schema::SchemaMerger::merge_explicit_files(
93 toml_path,
94 type_files,
95 query_files,
96 mutation_files,
97 )
98 .context("Failed to load explicit schema files");
99 }
100 if let Some(dir) = schema_dir {
101 info!("Mode: Auto-discovery from directory: {}", dir);
102 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
103 .context("Failed to load schema from directory");
104 }
105 if let Some(types) = types_path {
106 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
107 return crate::schema::SchemaMerger::merge_files(types, toml_path)
108 .context("Failed to merge types.json with TOML");
109 }
110 info!("Mode: TOML-based (checking for domain discovery...)");
111 if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
112 return Ok(schema);
113 }
114 info!("No domains configured, checking for TOML includes...");
115 if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
116 return Ok(schema);
117 }
118 info!("No includes configured, using TOML-only definitions");
119 crate::schema::SchemaMerger::merge_toml_only(toml_path)
120 .context("Failed to load schema from TOML")
121}
122
123#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
138 opts: CompileOptions<'_>,
139) -> Result<(CompiledSchema, OptimizationReport)> {
140 info!("Compiling schema: {}", opts.input);
141
142 let input_path = Path::new(opts.input);
144 if !input_path.exists() {
145 anyhow::bail!("Input file not found: {}", opts.input);
146 }
147
148 let is_toml = input_path
150 .extension()
151 .and_then(|ext| ext.to_str())
152 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
153 let mut intermediate: IntermediateSchema = if is_toml {
154 info!("Using TOML-based workflow");
155 load_intermediate_schema(
156 opts.input,
157 &opts.type_files,
158 &opts.query_files,
159 &opts.mutation_files,
160 opts.schema_dir,
161 opts.types,
162 )?
163 } else {
164 info!("Using legacy JSON workflow");
166 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
167
168 info!("Parsing intermediate schema...");
170 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
171 };
172
173 if !is_toml && Path::new("fraiseql.toml").exists() {
178 info!("Loading security configuration from fraiseql.toml...");
179 match TomlProjectConfig::from_file("fraiseql.toml") {
180 Ok(config) => {
181 info!("Validating security configuration...");
182 config.validate()?;
183
184 info!("Applying security configuration to schema...");
185 let mut security_json = config.fraiseql.security.to_json();
187
188 if !matches!(
190 config.fraiseql.tenancy.mode,
191 crate::config::security::TenancyModeConfig::None
192 ) {
193 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
194 }
195
196 intermediate.security = Some(security_json);
197
198 info!("Security configuration applied successfully");
199 },
200 Err(e) => {
201 anyhow::bail!(
202 "Failed to parse fraiseql.toml: {e}\n\
203 Fix the configuration file or remove it to use defaults."
204 );
205 },
206 }
207 } else {
208 info!("No fraiseql.toml found, using default security configuration");
209 }
210
211 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
214 let tenancy = sec.get("tenancy")?;
215 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
216 if mode == "row" {
217 Some(
218 tenancy
219 .get("tenantClaim")
220 .and_then(|c| c.as_str())
221 .unwrap_or("tenant_id")
222 .to_string(),
223 )
224 } else {
225 None
226 }
227 });
228 if let Some(tenant_claim) = &tenancy_row_claim {
229 info!("Validating @tenant_id annotations for row-isolation tenancy...");
230 crate::schema::converter::tenancy::validate_tenant_annotations(
231 &mut intermediate,
232 tenant_claim,
233 )
234 .context("@tenant_id validation failed")?;
235 }
236
237 info!("Validating schema structure...");
239 let validation_report =
240 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
241
242 if !validation_report.is_valid() {
243 validation_report.print();
244 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
245 }
246
247 if validation_report.warning_count() > 0 {
249 validation_report.print();
250 }
251
252 info!("Converting to compiled format...");
254 let mut schema = SchemaConverter::convert(intermediate)
255 .context("Failed to convert schema to compiled format")?;
256
257 info!("Analyzing schema for optimization opportunities...");
259 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
260
261 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
263
264 infer_native_columns_from_arg_types(&mut schema);
267
268 if let Some(db_url) = opts.database {
270 info!("Validating indexed columns against database...");
271 validate_indexed_columns(&schema, db_url).await?;
272
273 info!("Validating native columns for direct query arguments...");
274 let pg_introspector = build_postgres_introspector(db_url)
275 .context("Failed to connect for native column validation")?;
276 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
277 for w in &db_report.warnings {
278 warn!("{w}");
279 }
280 for query in &mut schema.queries {
282 if let Some(cols) = db_report.native_columns.get(&query.name) {
283 query.native_columns = cols.clone();
284 }
285 }
286 } else {
287 for query in &schema.queries {
290 if query.sql_source.is_none() {
291 continue;
292 }
293 let unresolved: Vec<_> = query
294 .arguments
295 .iter()
296 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
297 .filter(|a| !query.native_columns.contains_key(&a.name))
298 .collect();
299 if !unresolved.is_empty() {
300 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
301 warn!(
302 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
303 columns — no --database URL provided. These filters will use JSONB \
304 extraction. Provide --database or annotate with native_columns.",
305 query.name,
306 names,
307 query.sql_source.as_deref().unwrap_or("?"),
308 );
309 }
310 }
311 }
312
313 check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
315
316 warn_wide_cascade_mutations(&schema);
318
319 Ok((schema, report))
320}
321
322#[allow(clippy::too_many_arguments)]
351#[doc(hidden)] pub async fn run(
355 input: &str,
356 types: Option<&str>,
357 schema_dir: Option<&str>,
358 type_files: Vec<String>,
359 query_files: Vec<String>,
360 mutation_files: Vec<String>,
361 output: &str,
362 check: bool,
363 database: Option<&str>,
364 emit_ddl: Option<&str>,
365 check_migrations: bool,
366 skip_hash: bool,
367) -> Result<()> {
368 let opts = CompileOptions {
369 input,
370 types,
371 schema_dir,
372 type_files,
373 query_files,
374 mutation_files,
375 database,
376 skip_hash,
377 };
378 let (schema, optimization_report) = compile_to_schema(opts).await?;
379
380 if check {
382 println!("✓ Schema is valid");
383 println!(" Types: {}", schema.types.len());
384 println!(" Queries: {}", schema.queries.len());
385 println!(" Mutations: {}", schema.mutations.len());
386 optimization_report.print();
387 return Ok(());
388 }
389
390 info!("Writing compiled schema to: {output}");
392 let output_json = if skip_hash {
393 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?
394 } else {
395 use sha2::{Digest, Sha256};
396
397 let body =
398 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
399 let value: serde_json::Value = serde_json::from_str(&body)?;
400 let canonical = serde_json::to_string_pretty(&canonicalize_json(&value))?;
402 let hash = Sha256::digest(canonical.as_bytes());
403 let hash_hex = hex::encode(&hash[..16]);
404
405 let obj = value.as_object().context("schema must serialise as JSON object")?;
406
407 let mut new_obj = serde_json::Map::new();
409 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
410 for (k, v) in obj {
411 new_obj.insert(k.clone(), v.clone());
412 }
413 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
414 };
415
416 fs::write(output, output_json).context("Failed to write compiled schema")?;
417
418 println!("✓ Schema compiled successfully");
420 println!(" Input: {input}");
421 println!(" Output: {output}");
422 println!(" Types: {}", schema.types.len());
423 println!(" Queries: {}", schema.queries.len());
424 println!(" Mutations: {}", schema.mutations.len());
425 optimization_report.print();
426
427 if let Some(ddl_dir) = emit_ddl {
429 emit_ddl_to_dir(&schema, ddl_dir)?;
430 }
431
432 if check_migrations {
434 run_check_migrations(&schema)?;
435 }
436
437 Ok(())
438}
439
440pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
454 fs::create_dir_all(output_dir)
455 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
456
457 let mut count = 0;
458 for type_def in &schema.types {
459 let table_name = to_snake_case(type_def.name.as_str());
460 let ddl = build_create_table_ddl(&table_name, type_def);
461
462 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
463 fs::write(&file_path, ddl)
464 .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
465 count += 1;
466 }
467
468 println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
469 Ok(())
470}
471
472fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
482 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
483 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
484
485 emit_ddl_to_dir(schema, tmp_path)?;
486
487 info!("Running confiture migrate validate for drift detection...");
488
489 let status = Command::new("confiture").args(["migrate", "validate"]).status();
490
491 match status {
492 Err(_) => {
493 eprintln!(
495 "WARN: confiture is not installed; skipping migration drift check.\n\
496 Install it with: cargo install confiture"
497 );
498 Ok(())
499 },
500 Ok(s) if s.success() => {
501 println!("✓ No migration drift detected.");
502 Ok(())
503 },
504 Ok(_) => {
505 eprintln!(
506 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
507 );
508 anyhow::bail!(
509 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
510 )
511 },
512 }
513}
514
515pub(crate) fn to_snake_case(name: &str) -> String {
517 let mut result = String::with_capacity(name.len() + 4);
518 for (i, ch) in name.chars().enumerate() {
519 if ch.is_uppercase() && i > 0 {
520 result.push('_');
521 }
522 result.extend(ch.to_lowercase());
523 }
524 result
525}
526
527fn build_create_table_ddl(
529 table_name: &str,
530 type_def: &fraiseql_core::schema::TypeDefinition,
531) -> String {
532 let mut lines: Vec<String> = Vec::new();
533 lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
534 lines.push(format!("-- Type: {}", type_def.name));
535 if let Some(desc) = &type_def.description {
536 lines.push(format!("-- {desc}"));
537 }
538 lines.push(String::new());
539 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
540
541 let col_lines: Vec<String> = type_def
542 .fields
543 .iter()
544 .map(|field| {
545 let col_name = to_snake_case(field.name.as_str());
546 let pg_type = field_type_to_pg(&field.field_type);
547 let nullable = if field.nullable { "" } else { " NOT NULL" };
548 format!(" {col_name} {pg_type}{nullable}")
549 })
550 .collect();
551
552 let last = col_lines.len().saturating_sub(1);
553 for (i, col) in col_lines.iter().enumerate() {
554 if i < last {
555 lines.push(format!("{col},"));
556 } else {
557 lines.push(col.clone());
558 }
559 }
560
561 lines.push(");".to_string());
562 lines.push(String::new());
563 lines.join("\n")
564}
565
566pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
568 match ft {
569 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
570 FieldType::Int => "INTEGER".to_string(),
571 FieldType::Float => "DOUBLE PRECISION".to_string(),
572 FieldType::Boolean => "BOOLEAN".to_string(),
573 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
574 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
575 FieldType::Date => "DATE".to_string(),
576 FieldType::Time => "TIME".to_string(),
577 FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
578 FieldType::Decimal => "NUMERIC".to_string(),
579 FieldType::Vector => "VECTOR".to_string(),
580 FieldType::Enum(name) => name.clone(),
582 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
583 _ => "TEXT".to_string(),
585 }
586}
587
588fn check_sqlite_compatibility_warnings(
593 schema: &CompiledSchema,
594 input_path: &str,
595 is_toml: bool,
596 database_url: Option<&str>,
597) {
598 let target_is_sqlite = database_url
599 .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
600 || is_toml && detect_sqlite_target_in_toml(input_path);
601
602 if !target_is_sqlite {
603 return;
604 }
605
606 let mutation_count = schema.mutations.len();
607 let relay_count = schema.queries.iter().filter(|q| q.relay).count();
608 let subscription_count = schema.subscriptions.len();
609
610 if mutation_count > 0 {
611 warn!(
612 "Schema contains {} mutation(s) but target database is SQLite. \
613 Mutations are not supported on SQLite. \
614 See: https://fraiseql.dev/docs/database-compatibility",
615 mutation_count,
616 );
617 }
618 if relay_count > 0 {
619 warn!(
620 "Schema contains {} relay query/queries but target database is SQLite. \
621 Relay (keyset pagination) is not supported on SQLite. \
622 See: https://fraiseql.dev/docs/database-compatibility",
623 relay_count,
624 );
625 }
626 if subscription_count > 0 {
627 warn!(
628 "Schema contains {} subscription(s) but target database is SQLite. \
629 Subscriptions are not supported on SQLite. \
630 See: https://fraiseql.dev/docs/database-compatibility",
631 subscription_count,
632 );
633 }
634}
635
636fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
641 let Ok(content) = fs::read_to_string(toml_path) else {
642 return false;
643 };
644 let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
645 return false;
646 };
647 toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
648}
649
650pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
653
654pub(crate) fn wide_cascade_mutations(
660 schema: &CompiledSchema,
661 threshold: usize,
662) -> Vec<&fraiseql_core::schema::MutationDefinition> {
663 schema
664 .mutations
665 .iter()
666 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
667 .collect()
668}
669
670fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
683 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
684 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
685
686 let mut targets: Vec<&str> = mutation
688 .invalidates_views
689 .iter()
690 .chain(mutation.invalidates_fact_tables.iter())
691 .map(String::as_str)
692 .collect();
693 targets.sort_unstable();
694 targets.dedup();
695
696 let alter_stmts: Vec<String> = targets
699 .iter()
700 .map(|&name| {
701 let table = name
702 .strip_prefix("tv_")
703 .or_else(|| name.strip_prefix("v_"))
704 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
705 format!("ALTER TABLE {table} SET (fillfactor = 75);")
706 })
707 .collect();
708
709 warn!(
710 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
711 Under high write load, HOT-update page slots on these tables may be \
712 exhausted, forcing full-page writes and reducing mutation throughput. \
713 Set fillfactor=70-80 on the backing tables: {} \
714 Monitor HOT efficiency: SELECT relname, \
715 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
716 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
717 mutation.name,
718 total,
719 targets.join(", "),
720 alter_stmts.join(" "),
721 );
722 }
723}
724
725fn build_postgres_introspector(
733 db_url: &str,
734) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
735 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
736 use tokio_postgres::NoTls;
737
738 let mut cfg = Config::new();
739 cfg.url = Some(db_url.to_string());
740 cfg.manager = Some(ManagerConfig {
741 recycling_method: RecyclingMethod::Fast,
742 });
743 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
744
745 let pool = cfg
746 .create_pool(Some(Runtime::Tokio1), NoTls)
747 .context("Failed to create connection pool for database validation")?;
748
749 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
750}
751
752async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
767 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
768 use fraiseql_core::db::postgres::PostgresIntrospector;
769 use tokio_postgres::NoTls;
770
771 let mut cfg = Config::new();
773 cfg.url = Some(db_url.to_string());
774 cfg.manager = Some(ManagerConfig {
775 recycling_method: RecyclingMethod::Fast,
776 });
777 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
778
779 let pool = cfg
780 .create_pool(Some(Runtime::Tokio1), NoTls)
781 .context("Failed to create connection pool for indexed column validation")?;
782
783 let introspector = PostgresIntrospector::new(pool);
784
785 let mut total_indexed = 0;
786 let mut total_views = 0;
787
788 for query in &schema.queries {
790 if let Some(view_name) = &query.sql_source {
791 total_views += 1;
792
793 match introspector.get_indexed_nested_columns(view_name).await {
795 Ok(indexed_cols) => {
796 if !indexed_cols.is_empty() {
797 info!(
798 "View '{}': found {} indexed column(s): {:?}",
799 view_name,
800 indexed_cols.len(),
801 indexed_cols
802 );
803 total_indexed += indexed_cols.len();
804 }
805 },
806 Err(e) => {
807 warn!(
808 "Could not introspect view '{}': {}. Skipping indexed column check.",
809 view_name, e
810 );
811 },
812 }
813 }
814 }
815
816 println!("✓ Indexed column validation complete");
817 println!(" Views checked: {total_views}");
818 println!(" Indexed columns found: {total_indexed}");
819
820 Ok(())
821}
822
823const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
825 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
826];
827
828pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
840 for query in &mut schema.queries {
841 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
842 continue;
843 }
844 for arg in &query.arguments {
845 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
846 continue;
847 }
848 if query.native_columns.contains_key(&arg.name) {
849 continue; }
851 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
852 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
853 }
854 }
855 }
856}