1use std::{fs, path::Path, process::Command};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{
9 CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType, NamingConvention, canonicalize_json,
10};
11use tracing::{info, warn};
12
13use crate::{
14 config::TomlProjectConfig,
15 schema::{
16 ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer,
17 SchemaValidator, 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 let mut auto_error_union = false;
179 let mut naming_acronyms: Vec<String> = Vec::new();
181 let mut naming_convention = NamingConvention::CamelCase;
187 if !is_toml && Path::new("fraiseql.toml").exists() {
188 info!("Loading security configuration from fraiseql.toml...");
189 match TomlProjectConfig::from_file("fraiseql.toml") {
190 Ok(config) => {
191 info!("Validating security configuration...");
192 config.validate()?;
193
194 auto_error_union = config.fraiseql.mutations.auto_error_union;
195 naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
196 naming_convention = config.fraiseql.naming.convention;
197
198 info!("Applying security configuration to schema...");
199 let mut security_json = config.fraiseql.security.to_json();
201
202 if !matches!(
204 config.fraiseql.tenancy.mode,
205 crate::config::security::TenancyModeConfig::None
206 ) {
207 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
208 }
209
210 intermediate.security = Some(security_json);
211
212 info!("Security configuration applied successfully");
213 },
214 Err(e) => {
215 anyhow::bail!(
216 "Failed to parse fraiseql.toml: {e}\n\
217 Fix the configuration file or remove it to use defaults."
218 );
219 },
220 }
221 } else {
222 info!("No fraiseql.toml found, using default security configuration");
223 }
224
225 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
229
230 if !is_toml {
236 intermediate.naming_convention = naming_convention;
237 }
238
239 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
242 let tenancy = sec.get("tenancy")?;
243 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
244 if mode == "row" {
245 Some(
246 tenancy
247 .get("tenantClaim")
248 .and_then(|c| c.as_str())
249 .unwrap_or("tenant_id")
250 .to_string(),
251 )
252 } else {
253 None
254 }
255 });
256 if let Some(tenant_claim) = &tenancy_row_claim {
257 info!("Validating @tenant_id annotations for row-isolation tenancy...");
258 crate::schema::converter::tenancy::validate_tenant_annotations(
259 &mut intermediate,
260 tenant_claim,
261 )
262 .context("@tenant_id validation failed")?;
263 }
264
265 info!("Validating schema structure...");
267 let validation_report =
268 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
269
270 if !validation_report.is_valid() {
271 validation_report.print();
272 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
273 }
274
275 if validation_report.warning_count() > 0 {
277 validation_report.print();
278 }
279
280 info!("Converting to compiled format...");
282 let mut schema =
283 SchemaConverter::convert_with_options(intermediate, &ConvertOptions { auto_error_union })
284 .context("Failed to convert schema to compiled format")?;
285
286 schema.naming_acronyms = naming_acronyms;
289
290 info!("Analyzing schema for optimization opportunities...");
292 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
293
294 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
296
297 infer_native_columns_from_arg_types(&mut schema);
300
301 if let Some(db_url) = opts.database {
303 info!("Validating indexed columns against database...");
304 validate_indexed_columns(&schema, db_url).await?;
305
306 info!("Validating native columns for direct query arguments...");
307 let pg_introspector = build_postgres_introspector(db_url)
308 .context("Failed to connect for native column validation")?;
309 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
310 for w in &db_report.warnings {
311 warn!("{w}");
312 }
313 for query in &mut schema.queries {
315 if let Some(cols) = db_report.native_columns.get(&query.name) {
316 query.native_columns = cols.clone();
317 }
318 }
319 } else {
320 for query in &schema.queries {
323 if query.sql_source.is_none() {
324 continue;
325 }
326 let unresolved: Vec<_> = query
327 .arguments
328 .iter()
329 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
330 .filter(|a| !query.native_columns.contains_key(&a.name))
331 .collect();
332 if !unresolved.is_empty() {
333 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
334 warn!(
335 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
336 columns — no --database URL provided. These filters will use JSONB \
337 extraction. Provide --database or annotate with native_columns.",
338 query.name,
339 names,
340 query.sql_source.as_deref().unwrap_or("?"),
341 );
342 }
343 }
344 }
345
346 check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
348
349 warn_wide_cascade_mutations(&schema);
351
352 Ok((schema, report))
353}
354
355#[allow(clippy::too_many_arguments)]
384#[doc(hidden)] pub async fn run(
388 input: &str,
389 types: Option<&str>,
390 schema_dir: Option<&str>,
391 type_files: Vec<String>,
392 query_files: Vec<String>,
393 mutation_files: Vec<String>,
394 output: &str,
395 check: bool,
396 database: Option<&str>,
397 emit_ddl: Option<&str>,
398 check_migrations: bool,
399 skip_hash: bool,
400) -> Result<()> {
401 if !check && Path::new(output) == Path::new(input) {
406 anyhow::bail!(
407 "Refusing to write compiled output over the input file '{input}'. \
408 Use a distinct --output path (e.g. schema.compiled.json)."
409 );
410 }
411
412 let opts = CompileOptions {
413 input,
414 types,
415 schema_dir,
416 type_files,
417 query_files,
418 mutation_files,
419 database,
420 skip_hash,
421 };
422 let (schema, optimization_report) = compile_to_schema(opts).await?;
423
424 if check {
426 println!("✓ Schema is valid");
427 println!(" Types: {}", schema.types.len());
428 println!(" Queries: {}", schema.queries.len());
429 println!(" Mutations: {}", schema.mutations.len());
430 optimization_report.print();
431 return Ok(());
432 }
433
434 info!("Writing compiled schema to: {output}");
436 let output_json = if skip_hash {
437 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?
438 } else {
439 use sha2::{Digest, Sha256};
440
441 let body =
442 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
443 let value: serde_json::Value = serde_json::from_str(&body)?;
444 let canonical = serde_json::to_string_pretty(&canonicalize_json(&value))?;
446 let hash = Sha256::digest(canonical.as_bytes());
447 let hash_hex = hex::encode(&hash[..16]);
448
449 let obj = value.as_object().context("schema must serialise as JSON object")?;
450
451 let mut new_obj = serde_json::Map::new();
453 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
454 for (k, v) in obj {
455 new_obj.insert(k.clone(), v.clone());
456 }
457 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
458 };
459
460 fs::write(output, output_json).context("Failed to write compiled schema")?;
461
462 println!("✓ Schema compiled successfully");
464 println!(" Input: {input}");
465 println!(" Output: {output}");
466 println!(" Types: {}", schema.types.len());
467 println!(" Queries: {}", schema.queries.len());
468 println!(" Mutations: {}", schema.mutations.len());
469 optimization_report.print();
470
471 if let Some(ddl_dir) = emit_ddl {
473 emit_ddl_to_dir(&schema, ddl_dir)?;
474 }
475
476 if check_migrations {
478 run_check_migrations(&schema)?;
479 }
480
481 Ok(())
482}
483
484pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
498 fs::create_dir_all(output_dir)
499 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
500
501 let mut count = 0;
502 for type_def in &schema.types {
503 let table_name = to_snake_case(type_def.name.as_str());
504 let ddl = build_create_table_ddl(&table_name, type_def);
505
506 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
507 fs::write(&file_path, ddl)
508 .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
509 count += 1;
510 }
511
512 println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
513 Ok(())
514}
515
516fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
526 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
527 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
528
529 emit_ddl_to_dir(schema, tmp_path)?;
530
531 info!("Running confiture migrate validate for drift detection...");
532
533 let status = Command::new("confiture").args(["migrate", "validate"]).status();
534
535 match status {
536 Err(_) => {
537 eprintln!(
539 "WARN: confiture is not installed; skipping migration drift check.\n\
540 Install it with: cargo install confiture"
541 );
542 Ok(())
543 },
544 Ok(s) if s.success() => {
545 println!("✓ No migration drift detected.");
546 Ok(())
547 },
548 Ok(_) => {
549 eprintln!(
550 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
551 );
552 anyhow::bail!(
553 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
554 )
555 },
556 }
557}
558
559pub(crate) fn to_snake_case(name: &str) -> String {
561 let mut result = String::with_capacity(name.len() + 4);
562 for (i, ch) in name.chars().enumerate() {
563 if ch.is_uppercase() && i > 0 {
564 result.push('_');
565 }
566 result.extend(ch.to_lowercase());
567 }
568 result
569}
570
571fn build_create_table_ddl(
573 table_name: &str,
574 type_def: &fraiseql_core::schema::TypeDefinition,
575) -> String {
576 let mut lines: Vec<String> = Vec::new();
577 lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
578 lines.push(format!("-- Type: {}", type_def.name));
579 if let Some(desc) = &type_def.description {
580 lines.push(format!("-- {desc}"));
581 }
582 lines.push(String::new());
583 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
584
585 let col_lines: Vec<String> = type_def
586 .fields
587 .iter()
588 .map(|field| {
589 let col_name = to_snake_case(field.name.as_str());
590 let pg_type = field_type_to_pg(&field.field_type);
591 let nullable = if field.nullable { "" } else { " NOT NULL" };
592 format!(" {col_name} {pg_type}{nullable}")
593 })
594 .collect();
595
596 let last = col_lines.len().saturating_sub(1);
597 for (i, col) in col_lines.iter().enumerate() {
598 if i < last {
599 lines.push(format!("{col},"));
600 } else {
601 lines.push(col.clone());
602 }
603 }
604
605 lines.push(");".to_string());
606 lines.push(String::new());
607 lines.join("\n")
608}
609
610pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
612 match ft {
613 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
614 FieldType::Int => "INTEGER".to_string(),
615 FieldType::Float => "DOUBLE PRECISION".to_string(),
616 FieldType::Boolean => "BOOLEAN".to_string(),
617 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
618 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
619 FieldType::Date => "DATE".to_string(),
620 FieldType::Time => "TIME".to_string(),
621 FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
622 FieldType::Decimal => "NUMERIC".to_string(),
623 FieldType::Vector => "VECTOR".to_string(),
624 FieldType::Enum(name) => name.clone(),
626 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
627 _ => "TEXT".to_string(),
629 }
630}
631
632fn check_sqlite_compatibility_warnings(
637 schema: &CompiledSchema,
638 input_path: &str,
639 is_toml: bool,
640 database_url: Option<&str>,
641) {
642 let target_is_sqlite = database_url
643 .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
644 || is_toml && detect_sqlite_target_in_toml(input_path);
645
646 if !target_is_sqlite {
647 return;
648 }
649
650 let mutation_count = schema.mutations.len();
651 let relay_count = schema.queries.iter().filter(|q| q.relay).count();
652 let subscription_count = schema.subscriptions.len();
653
654 if mutation_count > 0 {
655 warn!(
656 "Schema contains {} mutation(s) but target database is SQLite. \
657 Mutations are not supported on SQLite. \
658 See: https://fraiseql.dev/docs/database-compatibility",
659 mutation_count,
660 );
661 }
662 if relay_count > 0 {
663 warn!(
664 "Schema contains {} relay query/queries but target database is SQLite. \
665 Relay (keyset pagination) is not supported on SQLite. \
666 See: https://fraiseql.dev/docs/database-compatibility",
667 relay_count,
668 );
669 }
670 if subscription_count > 0 {
671 warn!(
672 "Schema contains {} subscription(s) but target database is SQLite. \
673 Subscriptions are not supported on SQLite. \
674 See: https://fraiseql.dev/docs/database-compatibility",
675 subscription_count,
676 );
677 }
678}
679
680fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
685 let Ok(content) = fs::read_to_string(toml_path) else {
686 return false;
687 };
688 let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
689 return false;
690 };
691 toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
692}
693
694pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
697
698pub(crate) fn wide_cascade_mutations(
704 schema: &CompiledSchema,
705 threshold: usize,
706) -> Vec<&fraiseql_core::schema::MutationDefinition> {
707 schema
708 .mutations
709 .iter()
710 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
711 .collect()
712}
713
714fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
727 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
728 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
729
730 let mut targets: Vec<&str> = mutation
732 .invalidates_views
733 .iter()
734 .chain(mutation.invalidates_fact_tables.iter())
735 .map(String::as_str)
736 .collect();
737 targets.sort_unstable();
738 targets.dedup();
739
740 let alter_stmts: Vec<String> = targets
743 .iter()
744 .map(|&name| {
745 let table = name
746 .strip_prefix("tv_")
747 .or_else(|| name.strip_prefix("v_"))
748 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
749 format!("ALTER TABLE {table} SET (fillfactor = 75);")
750 })
751 .collect();
752
753 warn!(
754 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
755 Under high write load, HOT-update page slots on these tables may be \
756 exhausted, forcing full-page writes and reducing mutation throughput. \
757 Set fillfactor=70-80 on the backing tables: {} \
758 Monitor HOT efficiency: SELECT relname, \
759 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
760 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
761 mutation.name,
762 total,
763 targets.join(", "),
764 alter_stmts.join(" "),
765 );
766 }
767}
768
769fn build_postgres_introspector(
777 db_url: &str,
778) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
779 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
780 use tokio_postgres::NoTls;
781
782 let mut cfg = Config::new();
783 cfg.url = Some(db_url.to_string());
784 cfg.manager = Some(ManagerConfig {
785 recycling_method: RecyclingMethod::Fast,
786 });
787 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
788
789 let pool = cfg
790 .create_pool(Some(Runtime::Tokio1), NoTls)
791 .context("Failed to create connection pool for database validation")?;
792
793 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
794}
795
796async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
811 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
812 use fraiseql_core::db::postgres::PostgresIntrospector;
813 use tokio_postgres::NoTls;
814
815 let mut cfg = Config::new();
817 cfg.url = Some(db_url.to_string());
818 cfg.manager = Some(ManagerConfig {
819 recycling_method: RecyclingMethod::Fast,
820 });
821 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
822
823 let pool = cfg
824 .create_pool(Some(Runtime::Tokio1), NoTls)
825 .context("Failed to create connection pool for indexed column validation")?;
826
827 let introspector = PostgresIntrospector::new(pool);
828
829 let mut total_indexed = 0;
830 let mut total_views = 0;
831
832 for query in &schema.queries {
834 if let Some(view_name) = &query.sql_source {
835 total_views += 1;
836
837 match introspector.get_indexed_nested_columns(view_name).await {
839 Ok(indexed_cols) => {
840 if !indexed_cols.is_empty() {
841 info!(
842 "View '{}': found {} indexed column(s): {:?}",
843 view_name,
844 indexed_cols.len(),
845 indexed_cols
846 );
847 total_indexed += indexed_cols.len();
848 }
849 },
850 Err(e) => {
851 warn!(
852 "Could not introspect view '{}': {}. Skipping indexed column check.",
853 view_name, e
854 );
855 },
856 }
857 }
858 }
859
860 println!("✓ Indexed column validation complete");
861 println!(" Views checked: {total_views}");
862 println!(" Indexed columns found: {total_indexed}");
863
864 Ok(())
865}
866
867const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
869 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
870];
871
872pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
884 for query in &mut schema.queries {
885 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
886 continue;
887 }
888 for arg in &query.arguments {
889 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
890 continue;
891 }
892 if query.native_columns.contains_key(&arg.name) {
893 continue; }
895 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
896 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
897 }
898 }
899 }
900}