1use std::{fs, path::Path, process::Command};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{
9 CompiledSchema, FieldType, InputStyle, MutationOperation, NamingConvention, content_hash_of,
10};
11use fraiseql_db::postgres::PostgresTlsConfig;
12use tracing::{info, warn};
13
14use crate::{
15 config::{ConfigSource, TomlProjectConfig},
16 schema::{
17 CompiledArtifact, ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter,
18 SchemaOptimizer, SchemaValidator,
19 database_validator::validate_schema_against_database,
20 function_root_contract::validate_function_roots,
21 mutation_contract::{Severity, validate_mutation_contract},
22 pg_catalog::PgCatalog,
23 },
24};
25
26#[derive(Debug, Default)]
28pub struct CompileOptions<'a> {
29 pub input: &'a str,
31 pub types: Option<&'a str>,
33 pub schema_dir: Option<&'a str>,
35 pub type_files: Vec<String>,
37 pub query_files: Vec<String>,
39 pub mutation_files: Vec<String>,
41 pub database: Option<&'a str>,
43 pub skip_hash: bool,
45 pub allow_drift: bool,
48 pub config: Option<ConfigSource>,
52}
53
54impl<'a> CompileOptions<'a> {
55 #[must_use]
57 pub fn new(input: &'a str) -> Self {
58 Self {
59 input,
60 ..Default::default()
61 }
62 }
63
64 #[must_use]
66 pub fn with_types(mut self, types: &'a str) -> Self {
67 self.types = Some(types);
68 self
69 }
70
71 #[must_use]
73 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
74 self.schema_dir = Some(schema_dir);
75 self
76 }
77
78 #[must_use]
80 pub fn with_database(mut self, database: &'a str) -> Self {
81 self.database = Some(database);
82 self
83 }
84}
85
86#[allow(clippy::cognitive_complexity)] pub fn load_intermediate_schema(
101 toml_path: &str,
102 type_files: &[String],
103 query_files: &[String],
104 mutation_files: &[String],
105 schema_dir: Option<&str>,
106 types_path: Option<&str>,
107) -> Result<IntermediateSchema> {
108 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
109 info!("Mode: Explicit file lists");
110 return crate::schema::SchemaMerger::merge_explicit_files(
111 toml_path,
112 type_files,
113 query_files,
114 mutation_files,
115 )
116 .context("Failed to load explicit schema files");
117 }
118 if let Some(dir) = schema_dir {
119 info!("Mode: Auto-discovery from directory: {}", dir);
120 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
121 .context("Failed to load schema from directory");
122 }
123 if let Some(types) = types_path {
124 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
125 return crate::schema::SchemaMerger::merge_files(types, toml_path)
126 .context("Failed to merge types.json with TOML");
127 }
128 let toml_schema = crate::config::TomlSchema::from_file(toml_path)
140 .context(format!("Failed to load TOML from {toml_path}"))?;
141
142 if toml_schema.domain_discovery.enabled {
143 info!("Mode: TOML-based with domain discovery");
144 return crate::schema::SchemaMerger::merge_from_domains(toml_path).context(
145 "Failed to load schema from the configured [domain_discovery] section. Fix the \
146 error above, or remove the section to compile from TOML definitions only.",
147 );
148 }
149
150 if !toml_schema.includes.is_empty() {
151 info!("Mode: TOML-based with schema includes");
152 return crate::schema::SchemaMerger::merge_with_includes(toml_path).context(
153 "Failed to load schema from the configured [includes] section. Fix the error \
154 above, or remove the section to compile from TOML definitions only.",
155 );
156 }
157
158 info!("No domains or includes configured, using TOML-only definitions");
159 crate::schema::SchemaMerger::merge_toml_only(toml_path)
160 .context("Failed to load schema from TOML")
161}
162
163#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
178 opts: CompileOptions<'_>,
179) -> Result<(CompiledArtifact, OptimizationReport)> {
180 info!("Compiling schema: {}", opts.input);
181
182 if let Some(db_url) = opts.database {
185 crate::connection::require_postgres(db_url)?;
186 }
187
188 let input_path = Path::new(opts.input);
190 if !input_path.exists() {
191 anyhow::bail!("Input file not found: {}", opts.input);
192 }
193
194 let is_toml = input_path
196 .extension()
197 .and_then(|ext| ext.to_str())
198 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
199 let mut intermediate: IntermediateSchema = if is_toml {
200 info!("Using TOML-based workflow");
201 load_intermediate_schema(
202 opts.input,
203 &opts.type_files,
204 &opts.query_files,
205 &opts.mutation_files,
206 opts.schema_dir,
207 opts.types,
208 )?
209 } else {
210 info!("Using legacy JSON workflow");
212 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
213
214 info!("Parsing intermediate schema...");
219 let raw: serde_json::Value =
220 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?;
221
222 crate::schema::intermediate::reject_drifted_keys(&raw)
227 .map_err(|e| anyhow::anyhow!("{e}"))?;
228
229 let input_has_federation = ["federation", "federation_config"].iter().any(|key| {
230 raw.get(*key)
231 .and_then(serde_json::Value::as_object)
232 .is_some_and(|o| !o.is_empty())
233 });
234 let intermediate: IntermediateSchema =
235 serde_json::from_value(raw).context("Failed to parse schema.json")?;
236 if input_has_federation && intermediate.federation_config.is_none() {
237 anyhow::bail!(
238 "schema.json carries a `federation` block that did not bind into the compiled \
239 schema — refusing to compile a silently non-federated subgraph. This is a \
240 compiler bug; please report it."
241 );
242 }
243 intermediate
244 };
245
246 let config_source = match opts.config {
250 Some(source) => source,
251 None => ConfigSource::resolve(input_path, None)?,
252 };
253 info!("Project config: {config_source}");
254 let mut auto_error_union = false;
256 let mut naming_acronyms: Vec<String> = Vec::new();
258 let mut operation_cost_weights: std::collections::HashMap<String, usize> =
260 std::collections::HashMap::new();
261 let mut naming_convention = NamingConvention::CamelCase;
267 let mut database_tls = if is_toml && opts.database.is_some() {
270 crate::config::TomlSchema::from_file(opts.input)
271 .with_context(|| format!("Failed to load TOML from {}", opts.input))?
272 .database
273 .postgres_tls()?
274 } else {
275 PostgresTlsConfig::default()
276 };
277 if let Some(config_path) = config_source.project_config() {
278 info!("Loading security configuration from {}...", config_path.display());
279 let config_file = config_path.to_str().ok_or_else(|| {
280 anyhow::anyhow!("Config path is not valid UTF-8: {}", config_path.display())
281 })?;
282 match TomlProjectConfig::from_file(config_file) {
283 Ok(config) => {
284 info!("Validating security configuration...");
285 config.validate()?;
286
287 auto_error_union = config.fraiseql.mutations.auto_error_union;
288 naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
289 operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
290 naming_convention = config.fraiseql.naming.convention;
291 database_tls = config.database.postgres_tls()?;
292
293 info!("Applying security configuration to schema...");
294 let mut security_json = config.fraiseql.security.to_json();
296
297 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
302
303 intermediate.security = Some(security_json);
304
305 if config.fraiseql.session_variables
309 != fraiseql_core::schema::SessionVariablesConfig::default()
310 {
311 intermediate.session_variables = Some(config.fraiseql.session_variables);
312 }
313
314 if let Some(locale) = config.locale {
317 if intermediate.locale.as_ref().is_some_and(|l| *l != locale) {
318 anyhow::bail!(
319 "[locale] in {} disagrees with the `locale` the schema document \
320 declares; declare it once, in fraiseql.toml",
321 config_path.display()
322 );
323 }
324 intermediate.locale = Some(locale);
325 }
326
327 intermediate.functions_config =
330 config.functions.reconcile(intermediate.functions_config.take())?;
331
332 intermediate.observers_config =
335 crate::config::toml_schema::ObserversConfig::reconcile(
336 config.observers.as_ref(),
337 intermediate.observers_config.take(),
338 )?;
339
340 intermediate.inject_defaults =
343 crate::config::inject_defaults::InjectDefaultsToml::reconcile(
344 config.inject_defaults.as_ref(),
345 intermediate.inject_defaults.take(),
346 )?;
347
348 info!("Security configuration applied successfully");
349 },
350 Err(e) => {
351 anyhow::bail!(
352 "Failed to parse {}: {e}\n\
353 Fix the configuration file or remove it to use defaults.",
354 config_path.display()
355 );
356 },
357 }
358 }
359
360 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
364
365 if !is_toml {
371 intermediate.naming_convention = naming_convention;
372 }
373
374 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
377 let tenancy = sec.get("tenancy")?;
378 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
379 if mode == "row" {
380 Some(
385 tenancy
386 .get("tenant_claim")
387 .and_then(|c| c.as_str())
388 .unwrap_or("tenant_id")
389 .to_string(),
390 )
391 } else {
392 None
393 }
394 });
395 if let Some(tenant_claim) = &tenancy_row_claim {
396 info!("Validating @tenant_id annotations for row-isolation tenancy...");
397 crate::schema::converter::tenancy::validate_tenant_annotations(
398 &mut intermediate,
399 tenant_claim,
400 )
401 .context("@tenant_id validation failed")?;
402 }
403
404 info!("Validating schema structure...");
406 let validation_report =
407 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
408
409 if !validation_report.is_valid() {
410 validation_report.print();
411 let errors: Vec<String> = validation_report
414 .errors
415 .iter()
416 .filter(|e| e.severity == crate::schema::validator::ErrorSeverity::Error)
417 .map(|e| format!("{} ({})", e.message, e.path))
418 .collect();
419 anyhow::bail!(
420 "Schema validation failed with {} error(s):\n - {}",
421 validation_report.error_count(),
422 errors.join("\n - ")
423 );
424 }
425
426 if validation_report.warning_count() > 0 {
428 validation_report.print();
429 }
430
431 let mutation_defaults =
439 intermediate.inject_defaults.clone().unwrap_or_default().for_mutations();
440 let default_args: std::collections::HashMap<String, Vec<String>> = intermediate
441 .mutations
442 .iter()
443 .filter_map(|m| {
444 let mut inject = m.inject.clone();
445 crate::schema::intermediate::IntermediateInjectDefaults::apply_to(
446 &mutation_defaults,
447 &mut inject,
448 &m.exclude_inject_defaults,
449 &m.name,
450 )
451 .ok()
452 .filter(|added| !added.is_empty())
453 .map(|added| (m.name.clone(), added))
454 })
455 .collect();
456
457 info!("Converting to compiled format...");
458 let CompiledArtifact {
459 mut schema,
460 functions,
461 } = SchemaConverter::convert_artifact(intermediate, &ConvertOptions { auto_error_union })
462 .context("Failed to convert schema to compiled format")?;
463
464 schema.naming_acronyms = naming_acronyms;
467
468 if !operation_cost_weights.is_empty() {
472 schema.operation_cost_weights = operation_cost_weights;
473 }
474
475 info!("Analyzing schema for optimization opportunities...");
477 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
478
479 infer_native_columns_from_arg_types(&mut schema);
482
483 if let Some(db_url) = opts.database {
485 info!("Validating native columns for direct query arguments...");
486 let pool = crate::connection::postgres_pool(db_url, "database validation", &database_tls)
488 .await
489 .context("Failed to connect for database validation")?;
490 let pg_introspector = fraiseql_core::db::postgres::PostgresIntrospector::new(pool.clone());
491 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
492
493 let mut drift_errors: Vec<String> = Vec::new();
496 for w in &db_report.warnings {
497 match w.severity() {
498 Severity::Error => {
499 warn!("{w} [drift error]");
500 drift_errors.push(w.to_string());
501 },
502 Severity::Warn => warn!("{w}"),
503 }
504 }
505 apply_database_report(&mut schema, &db_report);
506
507 info!("Validating mutation contract against the database...");
512 let catalog = PgCatalog::from_pool(pool);
513
514 let sources: Vec<String> = schema
517 .types
518 .iter()
519 .map(|t| t.sql_source.to_string())
520 .filter(|s| !s.is_empty())
521 .collect();
522 match catalog.tview_references(&sources).await {
524 Ok(references) => {
525 let proven =
526 crate::schema::native_joins::apply_native_joins(&mut schema, &references);
527 if proven > 0 {
528 info!(
529 "{proven} relationship(s) correlate on native columns (pg_tviews references)"
530 );
531 }
532 },
533 Err(error) => warn!(
534 "cannot read tviews.registry.references ({error:#}); composed reads correlate on \
535 document keys"
536 ),
537 }
538
539 drift_errors.extend(validate_function_roots(&schema, &catalog).await?);
541
542 let contract = validate_mutation_contract(&schema, &catalog).await?;
543 let mut caused_by_default: std::collections::BTreeMap<String, Vec<String>> =
544 std::collections::BTreeMap::new();
545 for m in &contract.mutations {
546 let added = default_args.get(&m.mutation).map_or(&[][..], Vec::as_slice);
547 for v in &m.violations {
548 let kind = match v.severity() {
549 Severity::Error => "contract error",
550 Severity::Warn => "contract warning",
551 };
552 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
553 if v.severity() != Severity::Error {
554 continue;
555 }
556 if let Some(key) = default_caused(v, added) {
557 caused_by_default.entry(key).or_default().push(m.mutation.clone());
558 } else {
559 drift_errors.push(format!(
560 "mutation `{}` (sql_source: {}): {v}",
561 m.mutation, m.sql_source
562 ));
563 }
564 }
565 }
566 for (key, mutations) in caused_by_default {
567 drift_errors.push(default_collision(&key, &mutations));
568 }
569
570 let tviews_sourced: Vec<&str> = schema
573 .mutations
574 .iter()
575 .filter(|m| m.cascade_source == fraiseql_core::schema::CascadeSource::PgTviews)
576 .map(|m| m.name.as_str())
577 .collect();
578 if !tviews_sourced.is_empty() && !catalog.pg_tviews_flush_available().await? {
579 for name in tviews_sourced {
580 drift_errors.push(format!(
581 "mutation `{name}`: cascade_source = \"pg_tviews\", and the database has no \
582 tviews.pg_tviews_flush_and_report(integer, boolean, boolean): install \
583 pg_tviews (preloaded), or drop cascade_source"
584 ));
585 }
586 }
587
588 if !drift_errors.is_empty() {
593 if opts.allow_drift {
594 warn!(
595 "{} schema↔database drift error(s) suppressed by --allow-drift",
596 drift_errors.len()
597 );
598 } else {
599 let list =
600 drift_errors.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n");
601 anyhow::bail!(
602 "schema↔database drift: {} error(s):\n{list}\n\
603 Fix the declarations or the database objects, or pass --allow-drift \
604 to compile anyway (advisory mode).",
605 drift_errors.len()
606 );
607 }
608 }
609 } else {
610 for query in &schema.queries {
613 if query.sql_source.is_none() {
614 continue;
615 }
616 let unresolved: Vec<_> = query
617 .arguments
618 .iter()
619 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
620 .filter(|a| !query.native_columns.contains_key(&a.name))
621 .collect();
622 if !unresolved.is_empty() {
623 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
624 warn!(
625 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
626 columns — no --database URL provided. These filters will use JSONB \
627 extraction. Provide --database or annotate with native_columns.",
628 query.name,
629 names,
630 query.sql_source.as_deref().unwrap_or("?"),
631 );
632 }
633 }
634 }
635
636 warn_wide_cascade_mutations(&schema);
638
639 warn_jsonb_preserve_mismatch(&schema);
642
643 refuse_unattributable_mutations(&schema)?;
645
646 refuse_unenforced_authz_declarations(&schema)?;
648
649 refuse_unscoped_fact_tables(&schema)?;
651
652 refuse_what_a_server_would_not_load(&schema)?;
654
655 Ok((CompiledArtifact { schema, functions }, report))
656}
657
658pub(crate) fn default_caused(
678 violation: &crate::schema::mutation_contract::ContractViolation,
679 added: &[String],
680) -> Option<String> {
681 use crate::schema::mutation_contract::ContractViolation;
682 match violation {
683 ContractViolation::ArityMismatch { expected, found }
684 if !added.is_empty() && found.contains(&expected.saturating_sub(added.len())) =>
685 {
686 Some(added.join(", "))
687 },
688 ContractViolation::InjectNameMismatch { expected, .. } if added.contains(expected) => {
689 Some(expected.clone())
690 },
691 _ => None,
692 }
693}
694
695pub(crate) fn default_collision(key: &str, mutations: &[String]) -> String {
697 const SHOWN: usize = 5;
698 let shown = mutations.iter().take(SHOWN).map(|m| format!("`{m}`")).collect::<Vec<_>>();
699 let more = mutations.len().saturating_sub(SHOWN);
700 let list = if more == 0 {
701 shown.join(", ")
702 } else {
703 format!("{}, and {more} more", shown.join(", "))
704 };
705 format!(
706 "[inject_defaults] adds `{key}` to {} mutation(s) whose functions do not take it: \
707 {list}. Exclude it on those mutations (`exclude_inject_defaults = [\"{key}\"]`), or \
708 move it from the base [inject_defaults] table to [inject_defaults.queries].",
709 mutations.len()
710 )
711}
712
713fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
714 let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
715 let mut value: serde_json::Value = serde_json::from_str(&body)?;
716 let hash = content_hash_of(&value);
717 value
718 .as_object_mut()
719 .context("schema must serialise as JSON object")?
720 .insert("_content_hash".to_string(), serde_json::Value::String(hash));
721 CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
722 anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
723 })?;
724 Ok(())
725}
726
727fn refuse_unscoped_fact_tables(schema: &CompiledSchema) -> Result<()> {
743 let violations = schema.fact_table_scope_violations();
744 if violations.is_empty() {
745 return Ok(());
746 }
747 anyhow::bail!(
748 "tenant-scoped fact table(s) whose _aggregate and _window roots nothing scopes:\n - \
749 {}\n\n\
750 Nothing filters these roots' rows by tenant, so the server refuses them rather than \
751 read across tenants. Declare `inject_params` on the fact table, one per denormalized \
752 column the scoped reads filter on (`{{\"tenant_id\": \"jwt:<claim>\"}}`), as a query \
753 declares them. If the relation filters its own rows by tenant (on a \
754 current_setting(...) the server sets from [session_variables], never on client \
755 input), declare `row_scope = \"relation\"` instead.",
756 violations.join("\n - ")
757 )
758}
759
760fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
778 let Some(security) = schema.security.as_ref() else {
779 return Ok(());
780 };
781
782 let mut declared: Vec<String> = Vec::new();
783 if !security.rules.is_empty() {
784 declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
785 }
786 if !security.field_auth.is_empty() {
787 declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
788 }
789 if let Some(policy) = security.default_policy.as_deref() {
790 declared.push(format!("`security.default_policy` (\"{policy}\")"));
791 }
792 if declared.is_empty() {
793 return Ok(());
794 }
795
796 anyhow::bail!(
797 "authorization declaration(s) with no enforcer: {}.\n\n\
798 FraiseQL enforces field-level authorization through `security.role_definitions` \
799 (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
800 rule written here is not applied to any request. Remove them, or express the same \
801 intent with role definitions and `requires_scope`.\n\n\
802 (A general authorization-rule engine is tracked separately; until it exists, a \
803 declaration that silently enforces nothing is refused rather than accepted.)",
804 declared.join(", ")
805 )
806}
807
808fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
832 if !fraiseql_core::cache::declares_cacheable_views(schema) {
833 return Ok(());
834 }
835 let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
836 if unattributable.is_empty() {
837 return Ok(());
838 }
839
840 let cacheable: Vec<&str> = schema
841 .queries
842 .iter()
843 .filter(|q| q.cache_ttl_seconds.is_some())
844 .filter_map(|q| q.sql_source.as_deref())
845 .collect();
846 let list = unattributable.iter().map(|m| format!(" - {m}")).collect::<Vec<_>>().join("\n");
847 anyhow::bail!(
848 "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
849 This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
850 view invalidates nothing when it succeeds — permanently, for any view annotated \
851 `cache_ttl_seconds = 0`.\n\n\
852 Fix by declaring what each mutation writes:\n\
853 \x20 invalidates_views = [\"v_price\"]\n\n\
854 A mutation whose return type is backed by a view, or whose payload wraps an entity \
855 that is, already resolves and needs no annotation.",
856 cacheable.len(),
857 cacheable.join(", "),
858 )
859}
860
861#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
891#[doc(hidden)] pub async fn run(
895 input: &str,
896 types: Option<&str>,
897 schema_dir: Option<&str>,
898 type_files: Vec<String>,
899 query_files: Vec<String>,
900 mutation_files: Vec<String>,
901 output: &str,
902 check: bool,
903 database: Option<&str>,
904 emit_ddl: Option<&str>,
905 check_migrations: bool,
906 skip_hash: bool,
907 allow_drift: bool,
908 config: Option<&str>,
909) -> Result<()> {
910 if !check && Path::new(output) == Path::new(input) {
915 anyhow::bail!(
916 "Refusing to write compiled output over the input file '{input}'. \
917 Use a distinct --output path (e.g. schema.compiled.json)."
918 );
919 }
920
921 let config = ConfigSource::resolve(Path::new(input), config.map(Path::new))?;
924 println!("Config: {config}");
925
926 let opts = CompileOptions {
927 input,
928 types,
929 schema_dir,
930 type_files,
931 query_files,
932 mutation_files,
933 database,
934 skip_hash,
935 allow_drift,
936 config: Some(config),
937 };
938 let (artifact, optimization_report) = compile_to_schema(opts).await?;
939 let schema = &artifact.schema;
940
941 if check {
943 println!("✓ Schema is valid");
944 println!(" Types: {}", schema.types.len());
945 println!(" Queries: {}", schema.queries.len());
946 println!(" Mutations: {}", schema.mutations.len());
947 optimization_report.print();
948 print!("{}", localized_index_report_text(schema));
949 return Ok(());
950 }
951
952 info!("Writing compiled schema to: {output}");
954 let value = artifact.to_json_value()?;
957
958 let output_json = if skip_hash {
959 serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
960 } else {
961 let hash_hex = content_hash_of(&value);
964
965 let obj = value.as_object().context("schema must serialise as JSON object")?;
966
967 let mut new_obj = serde_json::Map::new();
969 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
970 for (k, v) in obj {
971 new_obj.insert(k.clone(), v.clone());
972 }
973 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
974 };
975
976 fs::write(output, output_json).context("Failed to write compiled schema")?;
977
978 println!("✓ Schema compiled successfully");
980 println!(" Input: {input}");
981 println!(" Output: {output}");
982 println!(" Types: {}", schema.types.len());
983 println!(" Queries: {}", schema.queries.len());
984 println!(" Mutations: {}", schema.mutations.len());
985 optimization_report.print();
986 print!("{}", localized_index_report_text(schema));
987
988 if let Some(ddl_dir) = emit_ddl {
990 emit_ddl_to_dir(schema, ddl_dir)?;
991 }
992
993 if check_migrations {
995 run_check_migrations(schema)?;
996 }
997
998 Ok(())
999}
1000
1001#[must_use]
1009pub fn localized_index_report_text(schema: &fraiseql_core::schema::CompiledSchema) -> String {
1010 use std::fmt::Write as _;
1011 let report = schema.localized_index_report();
1012 if report.is_empty() {
1013 return String::new();
1014 }
1015 let mut text = String::from(
1016 "\nLocalized field indexes (one per field and allowed locale; a filter or sort on the \
1017 field reads it):\n",
1018 );
1019 for advice in &report {
1020 let subject = format!(
1021 "{}.{} [{}] on {}",
1022 advice.type_name, advice.field, advice.locale, advice.table
1023 );
1024 let _ = match &advice.index {
1026 Some(index) => writeln!(text, " {subject}:\n {}", index.ddl),
1027 None => writeln!(
1028 text,
1029 " {subject}: a view; create the index on its base table, on the expression the \
1030 query reads"
1031 ),
1032 };
1033 }
1034 text
1035}
1036
1037pub const EMIT_DDL_FORMAT: u32 = 1;
1040
1041pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
1057 let mut files: std::collections::BTreeMap<String, (&str, String)> =
1059 std::collections::BTreeMap::new();
1060 for type_def in &schema.types {
1061 let table_name = to_snake_case(type_def.name.as_str());
1062 let ddl = build_create_table_ddl(&table_name, type_def)?;
1063 if let Some((other, _)) = files.get(&table_name) {
1064 let (first, second) = if *other < type_def.name.as_str() {
1065 (*other, type_def.name.as_str())
1066 } else {
1067 (type_def.name.as_str(), *other)
1068 };
1069 anyhow::bail!(
1070 "--emit-ddl: types '{first}' and '{second}' both map to the table \
1071 `tb_{table_name}` and the file `{table_name}.sql`; rename one of them"
1072 );
1073 }
1074 files.insert(table_name, (type_def.name.as_str(), ddl));
1075 }
1076
1077 fs::create_dir_all(output_dir)
1078 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
1079 for (table_name, (type_name, ddl)) in &files {
1080 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
1081 fs::write(&file_path, ddl)
1082 .context(format!("Failed to write DDL for type '{type_name}'"))?;
1083 }
1084
1085 println!("✓ DDL emitted to {output_dir}/ ({} table(s))", files.len());
1086 Ok(())
1087}
1088
1089fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
1099 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
1100 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
1101
1102 emit_ddl_to_dir(schema, tmp_path)?;
1103
1104 info!("Running confiture migrate validate for drift detection...");
1105
1106 let status = Command::new("confiture").args(["migrate", "validate"]).status();
1107
1108 match status {
1109 Err(_) => {
1110 eprintln!(
1112 "WARN: confiture is not installed; skipping migration drift check.\n\
1113 Install it with: {}",
1114 super::migrate::CONFITURE_INSTALL_COMMANDS.join(" (or) ")
1115 );
1116 Ok(())
1117 },
1118 Ok(s) if s.success() => {
1119 println!("✓ No migration drift detected.");
1120 Ok(())
1121 },
1122 Ok(_) => {
1123 eprintln!(
1124 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
1125 );
1126 anyhow::bail!(
1127 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
1128 )
1129 },
1130 }
1131}
1132
1133pub(crate) fn to_snake_case(name: &str) -> String {
1141 fraiseql_core::utils::to_snake_case(name)
1142}
1143
1144fn build_create_table_ddl(
1151 table_name: &str,
1152 type_def: &fraiseql_core::schema::TypeDefinition,
1153) -> Result<String> {
1154 let mut lines: Vec<String> = Vec::new();
1155 lines.push(format!("-- fraiseql emit-ddl format {EMIT_DDL_FORMAT}"));
1156 lines.push(format!("-- Generated by fraiseql-cli {}", env!("CARGO_PKG_VERSION")));
1157 lines.push(format!("-- Type: {}", type_def.name));
1158 if let Some(desc) = &type_def.description {
1159 lines.push(format!("-- {desc}"));
1160 }
1161 lines.push(String::new());
1162 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
1163
1164 let col_lines: Vec<String> = type_def
1165 .fields
1166 .iter()
1167 .map(|field| {
1168 let col_name = to_snake_case(field.name.as_str());
1169 let pg_type =
1174 if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
1175 field.field_type.to_sql_type(field.vector_config.as_ref())
1176 } else {
1177 field_type_to_pg(&field.field_type)
1178 };
1179 let nullable = if field.nullable { "" } else { " NOT NULL" };
1180 format!(" {col_name} {pg_type}{nullable}")
1181 })
1182 .collect();
1183
1184 let last = col_lines.len().saturating_sub(1);
1185 for (i, col) in col_lines.iter().enumerate() {
1186 if i < last {
1187 lines.push(format!("{col},"));
1188 } else {
1189 lines.push(col.clone());
1190 }
1191 }
1192
1193 lines.push(");".to_string());
1194
1195 let vector_fields: Vec<_> = type_def
1199 .fields
1200 .iter()
1201 .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
1202 .collect();
1203 if !vector_fields.is_empty() {
1204 lines.push(String::new());
1205 lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
1206 for (field, config) in vector_fields {
1207 let col_name = to_snake_case(field.name.as_str());
1208 let index_ddl = config
1209 .index_type
1210 .index_sql(
1211 &format!("tb_{table_name}"),
1212 &col_name,
1213 &field.field_type,
1214 config.distance_metric,
1215 )
1216 .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
1217 if let Some(index_ddl) = index_ddl {
1218 lines.push(format!("{index_ddl};"));
1219 }
1220 }
1221 }
1222
1223 lines.push(String::new());
1224 Ok(lines.join("\n"))
1225}
1226
1227pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
1229 match ft {
1230 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
1231 FieldType::Int => "INTEGER".to_string(),
1232 FieldType::Float => "DOUBLE PRECISION".to_string(),
1233 FieldType::Boolean => "BOOLEAN".to_string(),
1234 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
1235 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
1236 FieldType::Date => "DATE".to_string(),
1237 FieldType::Time => "TIME".to_string(),
1238 FieldType::Json | FieldType::List { item: _, .. } | FieldType::Object(_) => {
1239 "JSONB".to_string()
1240 },
1241 FieldType::Decimal => "NUMERIC".to_string(),
1242 FieldType::Vector => "VECTOR".to_string(),
1243 FieldType::BitVector => "VARBIT".to_string(),
1248 FieldType::HalfVector => "HALFVEC".to_string(),
1252 FieldType::SparseVector => "SPARSEVEC".to_string(),
1253 FieldType::Enum(name) => name.clone(),
1255 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
1256 _ => "TEXT".to_string(),
1258 }
1259}
1260
1261pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1264
1265pub(crate) fn wide_cascade_mutations(
1271 schema: &CompiledSchema,
1272 threshold: usize,
1273) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1274 schema
1275 .mutations
1276 .iter()
1277 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1278 .collect()
1279}
1280
1281fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1294 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1295 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1296
1297 let mut targets: Vec<&str> = mutation
1299 .invalidates_views
1300 .iter()
1301 .chain(mutation.invalidates_fact_tables.iter())
1302 .map(String::as_str)
1303 .collect();
1304 targets.sort_unstable();
1305 targets.dedup();
1306
1307 let alter_stmts: Vec<String> = targets
1310 .iter()
1311 .map(|&name| {
1312 let table = name
1313 .strip_prefix("tv_")
1314 .or_else(|| name.strip_prefix("v_"))
1315 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1316 format!("ALTER TABLE {table} SET (fillfactor = 75);")
1317 })
1318 .collect();
1319
1320 warn!(
1321 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1322 Under high write load, HOT-update page slots on these tables may be \
1323 exhausted, forcing full-page writes and reducing mutation throughput. \
1324 Set fillfactor=70-80 on the backing tables: {} \
1325 Monitor HOT efficiency: SELECT relname, \
1326 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1327 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1328 mutation.name,
1329 total,
1330 targets.join(", "),
1331 alter_stmts.join(" "),
1332 );
1333 }
1334}
1335
1336pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1349 if schema.naming_convention != NamingConvention::Preserve {
1350 return Vec::new();
1351 }
1352 let mut out = Vec::new();
1353 for mutation in &schema.mutations {
1354 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1357 || matches!(mutation.operation, MutationOperation::Update { .. });
1358 if !single_jsonb {
1359 continue;
1360 }
1361 let input_type_name = match mutation.arguments.as_slice() {
1368 [arg] if arg.name == "input" => match &arg.arg_type {
1369 FieldType::Input(name) => name.as_str(),
1370 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1371 _ => continue,
1372 },
1373 _ => continue,
1374 };
1375 let Some(input_type) = schema.find_input_type(input_type_name) else {
1376 continue;
1377 };
1378 let camel_fields: Vec<String> = input_type
1381 .fields
1382 .iter()
1383 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1384 .map(|f| f.name.clone())
1385 .collect();
1386 if !camel_fields.is_empty() {
1387 out.push((mutation.name.clone(), camel_fields));
1388 }
1389 }
1390 out
1391}
1392
1393fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1397 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1398 warn!(
1399 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1400 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1401 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1402 recasing), so a SQL function reading payload->>'snake_field' will receive these \
1403 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1404 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1405 fields.join(", "),
1406 );
1407 }
1408}
1409
1410pub(crate) async fn build_postgres_introspector(
1417 db_url: &str,
1418 tls: &PostgresTlsConfig,
1419) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1420 let pool = crate::connection::postgres_pool(db_url, "database validation", tls).await?;
1421 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1422}
1423
1424const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1426 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1427];
1428
1429pub(crate) fn apply_database_report(
1441 schema: &mut fraiseql_core::schema::CompiledSchema,
1442 report: &crate::schema::database_validator::DatabaseValidationReport,
1443) {
1444 for query in &mut schema.queries {
1445 if let Some(cols) = report.native_columns.get(&query.name) {
1446 query.native_columns = cols.clone();
1447 }
1448 if let Some(order) = report.pagination_orders.get(&query.name) {
1449 query.pagination_order = Some(order.clone());
1450 }
1451 }
1452}
1453
1454pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1466 for query in &mut schema.queries {
1467 if query.sql_source.is_none()
1469 || query.jsonb_column.is_empty()
1470 || !query.sql_source_kind.is_relation()
1471 {
1472 continue;
1473 }
1474 for arg in &query.arguments {
1475 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1476 continue;
1477 }
1478 if query.native_columns.contains_key(&arg.name) {
1479 continue; }
1481 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1482 query.native_columns.insert(
1484 arg.name.clone(),
1485 fraiseql_core::schema::NativeColumn::nullable("uuid"),
1486 );
1487 }
1488 }
1489 }
1490}