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_what_a_server_would_not_load(&schema)?;
651
652 Ok((CompiledArtifact { schema, functions }, report))
653}
654
655pub(crate) fn default_caused(
675 violation: &crate::schema::mutation_contract::ContractViolation,
676 added: &[String],
677) -> Option<String> {
678 use crate::schema::mutation_contract::ContractViolation;
679 match violation {
680 ContractViolation::ArityMismatch { expected, found }
681 if !added.is_empty() && found.contains(&expected.saturating_sub(added.len())) =>
682 {
683 Some(added.join(", "))
684 },
685 ContractViolation::InjectNameMismatch { expected, .. } if added.contains(expected) => {
686 Some(expected.clone())
687 },
688 _ => None,
689 }
690}
691
692pub(crate) fn default_collision(key: &str, mutations: &[String]) -> String {
694 const SHOWN: usize = 5;
695 let shown = mutations.iter().take(SHOWN).map(|m| format!("`{m}`")).collect::<Vec<_>>();
696 let more = mutations.len().saturating_sub(SHOWN);
697 let list = if more == 0 {
698 shown.join(", ")
699 } else {
700 format!("{}, and {more} more", shown.join(", "))
701 };
702 format!(
703 "[inject_defaults] adds `{key}` to {} mutation(s) whose functions do not take it: \
704 {list}. Exclude it on those mutations (`exclude_inject_defaults = [\"{key}\"]`), or \
705 move it from the base [inject_defaults] table to [inject_defaults.queries].",
706 mutations.len()
707 )
708}
709
710fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
711 let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
712 let mut value: serde_json::Value = serde_json::from_str(&body)?;
713 let hash = content_hash_of(&value);
714 value
715 .as_object_mut()
716 .context("schema must serialise as JSON object")?
717 .insert("_content_hash".to_string(), serde_json::Value::String(hash));
718 CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
719 anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
720 })?;
721 Ok(())
722}
723
724fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
742 let Some(security) = schema.security.as_ref() else {
743 return Ok(());
744 };
745
746 let mut declared: Vec<String> = Vec::new();
747 if !security.rules.is_empty() {
748 declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
749 }
750 if !security.field_auth.is_empty() {
751 declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
752 }
753 if let Some(policy) = security.default_policy.as_deref() {
754 declared.push(format!("`security.default_policy` (\"{policy}\")"));
755 }
756 if declared.is_empty() {
757 return Ok(());
758 }
759
760 anyhow::bail!(
761 "authorization declaration(s) with no enforcer: {}.\n\n\
762 FraiseQL enforces field-level authorization through `security.role_definitions` \
763 (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
764 rule written here is not applied to any request. Remove them, or express the same \
765 intent with role definitions and `requires_scope`.\n\n\
766 (A general authorization-rule engine is tracked separately; until it exists, a \
767 declaration that silently enforces nothing is refused rather than accepted.)",
768 declared.join(", ")
769 )
770}
771
772fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
796 if !fraiseql_core::cache::declares_cacheable_views(schema) {
797 return Ok(());
798 }
799 let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
800 if unattributable.is_empty() {
801 return Ok(());
802 }
803
804 let cacheable: Vec<&str> = schema
805 .queries
806 .iter()
807 .filter(|q| q.cache_ttl_seconds.is_some())
808 .filter_map(|q| q.sql_source.as_deref())
809 .collect();
810 let list = unattributable.iter().map(|m| format!(" - {m}")).collect::<Vec<_>>().join("\n");
811 anyhow::bail!(
812 "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
813 This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
814 view invalidates nothing when it succeeds — permanently, for any view annotated \
815 `cache_ttl_seconds = 0`.\n\n\
816 Fix by declaring what each mutation writes:\n\
817 \x20 invalidates_views = [\"v_price\"]\n\n\
818 A mutation whose return type is backed by a view, or whose payload wraps an entity \
819 that is, already resolves and needs no annotation.",
820 cacheable.len(),
821 cacheable.join(", "),
822 )
823}
824
825#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
855#[doc(hidden)] pub async fn run(
859 input: &str,
860 types: Option<&str>,
861 schema_dir: Option<&str>,
862 type_files: Vec<String>,
863 query_files: Vec<String>,
864 mutation_files: Vec<String>,
865 output: &str,
866 check: bool,
867 database: Option<&str>,
868 emit_ddl: Option<&str>,
869 check_migrations: bool,
870 skip_hash: bool,
871 allow_drift: bool,
872 config: Option<&str>,
873) -> Result<()> {
874 if !check && Path::new(output) == Path::new(input) {
879 anyhow::bail!(
880 "Refusing to write compiled output over the input file '{input}'. \
881 Use a distinct --output path (e.g. schema.compiled.json)."
882 );
883 }
884
885 let config = ConfigSource::resolve(Path::new(input), config.map(Path::new))?;
888 println!("Config: {config}");
889
890 let opts = CompileOptions {
891 input,
892 types,
893 schema_dir,
894 type_files,
895 query_files,
896 mutation_files,
897 database,
898 skip_hash,
899 allow_drift,
900 config: Some(config),
901 };
902 let (artifact, optimization_report) = compile_to_schema(opts).await?;
903 let schema = &artifact.schema;
904
905 if check {
907 println!("✓ Schema is valid");
908 println!(" Types: {}", schema.types.len());
909 println!(" Queries: {}", schema.queries.len());
910 println!(" Mutations: {}", schema.mutations.len());
911 optimization_report.print();
912 print!("{}", localized_index_report_text(schema));
913 return Ok(());
914 }
915
916 info!("Writing compiled schema to: {output}");
918 let value = artifact.to_json_value()?;
921
922 let output_json = if skip_hash {
923 serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
924 } else {
925 let hash_hex = content_hash_of(&value);
928
929 let obj = value.as_object().context("schema must serialise as JSON object")?;
930
931 let mut new_obj = serde_json::Map::new();
933 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
934 for (k, v) in obj {
935 new_obj.insert(k.clone(), v.clone());
936 }
937 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
938 };
939
940 fs::write(output, output_json).context("Failed to write compiled schema")?;
941
942 println!("✓ Schema compiled successfully");
944 println!(" Input: {input}");
945 println!(" Output: {output}");
946 println!(" Types: {}", schema.types.len());
947 println!(" Queries: {}", schema.queries.len());
948 println!(" Mutations: {}", schema.mutations.len());
949 optimization_report.print();
950 print!("{}", localized_index_report_text(schema));
951
952 if let Some(ddl_dir) = emit_ddl {
954 emit_ddl_to_dir(schema, ddl_dir)?;
955 }
956
957 if check_migrations {
959 run_check_migrations(schema)?;
960 }
961
962 Ok(())
963}
964
965#[must_use]
973pub fn localized_index_report_text(schema: &fraiseql_core::schema::CompiledSchema) -> String {
974 use std::fmt::Write as _;
975 let report = schema.localized_index_report();
976 if report.is_empty() {
977 return String::new();
978 }
979 let mut text = String::from(
980 "\nLocalized field indexes (one per field and allowed locale; a filter or sort on the \
981 field reads it):\n",
982 );
983 for advice in &report {
984 let subject = format!(
985 "{}.{} [{}] on {}",
986 advice.type_name, advice.field, advice.locale, advice.table
987 );
988 let _ = match &advice.index {
990 Some(index) => writeln!(text, " {subject}:\n {}", index.ddl),
991 None => writeln!(
992 text,
993 " {subject}: a view; create the index on its base table, on the expression the \
994 query reads"
995 ),
996 };
997 }
998 text
999}
1000
1001pub const EMIT_DDL_FORMAT: u32 = 1;
1004
1005pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
1021 let mut files: std::collections::BTreeMap<String, (&str, String)> =
1023 std::collections::BTreeMap::new();
1024 for type_def in &schema.types {
1025 let table_name = to_snake_case(type_def.name.as_str());
1026 let ddl = build_create_table_ddl(&table_name, type_def)?;
1027 if let Some((other, _)) = files.get(&table_name) {
1028 let (first, second) = if *other < type_def.name.as_str() {
1029 (*other, type_def.name.as_str())
1030 } else {
1031 (type_def.name.as_str(), *other)
1032 };
1033 anyhow::bail!(
1034 "--emit-ddl: types '{first}' and '{second}' both map to the table \
1035 `tb_{table_name}` and the file `{table_name}.sql`; rename one of them"
1036 );
1037 }
1038 files.insert(table_name, (type_def.name.as_str(), ddl));
1039 }
1040
1041 fs::create_dir_all(output_dir)
1042 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
1043 for (table_name, (type_name, ddl)) in &files {
1044 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
1045 fs::write(&file_path, ddl)
1046 .context(format!("Failed to write DDL for type '{type_name}'"))?;
1047 }
1048
1049 println!("✓ DDL emitted to {output_dir}/ ({} table(s))", files.len());
1050 Ok(())
1051}
1052
1053fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
1063 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
1064 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
1065
1066 emit_ddl_to_dir(schema, tmp_path)?;
1067
1068 info!("Running confiture migrate validate for drift detection...");
1069
1070 let status = Command::new("confiture").args(["migrate", "validate"]).status();
1071
1072 match status {
1073 Err(_) => {
1074 eprintln!(
1076 "WARN: confiture is not installed; skipping migration drift check.\n\
1077 Install it with: {}",
1078 super::migrate::CONFITURE_INSTALL_COMMANDS.join(" (or) ")
1079 );
1080 Ok(())
1081 },
1082 Ok(s) if s.success() => {
1083 println!("✓ No migration drift detected.");
1084 Ok(())
1085 },
1086 Ok(_) => {
1087 eprintln!(
1088 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
1089 );
1090 anyhow::bail!(
1091 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
1092 )
1093 },
1094 }
1095}
1096
1097pub(crate) fn to_snake_case(name: &str) -> String {
1105 fraiseql_core::utils::to_snake_case(name)
1106}
1107
1108fn build_create_table_ddl(
1115 table_name: &str,
1116 type_def: &fraiseql_core::schema::TypeDefinition,
1117) -> Result<String> {
1118 let mut lines: Vec<String> = Vec::new();
1119 lines.push(format!("-- fraiseql emit-ddl format {EMIT_DDL_FORMAT}"));
1120 lines.push(format!("-- Generated by fraiseql-cli {}", env!("CARGO_PKG_VERSION")));
1121 lines.push(format!("-- Type: {}", type_def.name));
1122 if let Some(desc) = &type_def.description {
1123 lines.push(format!("-- {desc}"));
1124 }
1125 lines.push(String::new());
1126 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
1127
1128 let col_lines: Vec<String> = type_def
1129 .fields
1130 .iter()
1131 .map(|field| {
1132 let col_name = to_snake_case(field.name.as_str());
1133 let pg_type =
1138 if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
1139 field.field_type.to_sql_type(field.vector_config.as_ref())
1140 } else {
1141 field_type_to_pg(&field.field_type)
1142 };
1143 let nullable = if field.nullable { "" } else { " NOT NULL" };
1144 format!(" {col_name} {pg_type}{nullable}")
1145 })
1146 .collect();
1147
1148 let last = col_lines.len().saturating_sub(1);
1149 for (i, col) in col_lines.iter().enumerate() {
1150 if i < last {
1151 lines.push(format!("{col},"));
1152 } else {
1153 lines.push(col.clone());
1154 }
1155 }
1156
1157 lines.push(");".to_string());
1158
1159 let vector_fields: Vec<_> = type_def
1163 .fields
1164 .iter()
1165 .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
1166 .collect();
1167 if !vector_fields.is_empty() {
1168 lines.push(String::new());
1169 lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
1170 for (field, config) in vector_fields {
1171 let col_name = to_snake_case(field.name.as_str());
1172 let index_ddl = config
1173 .index_type
1174 .index_sql(
1175 &format!("tb_{table_name}"),
1176 &col_name,
1177 &field.field_type,
1178 config.distance_metric,
1179 )
1180 .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
1181 if let Some(index_ddl) = index_ddl {
1182 lines.push(format!("{index_ddl};"));
1183 }
1184 }
1185 }
1186
1187 lines.push(String::new());
1188 Ok(lines.join("\n"))
1189}
1190
1191pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
1193 match ft {
1194 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
1195 FieldType::Int => "INTEGER".to_string(),
1196 FieldType::Float => "DOUBLE PRECISION".to_string(),
1197 FieldType::Boolean => "BOOLEAN".to_string(),
1198 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
1199 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
1200 FieldType::Date => "DATE".to_string(),
1201 FieldType::Time => "TIME".to_string(),
1202 FieldType::Json | FieldType::List { item: _, .. } | FieldType::Object(_) => {
1203 "JSONB".to_string()
1204 },
1205 FieldType::Decimal => "NUMERIC".to_string(),
1206 FieldType::Vector => "VECTOR".to_string(),
1207 FieldType::BitVector => "VARBIT".to_string(),
1212 FieldType::HalfVector => "HALFVEC".to_string(),
1216 FieldType::SparseVector => "SPARSEVEC".to_string(),
1217 FieldType::Enum(name) => name.clone(),
1219 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
1220 _ => "TEXT".to_string(),
1222 }
1223}
1224
1225pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1228
1229pub(crate) fn wide_cascade_mutations(
1235 schema: &CompiledSchema,
1236 threshold: usize,
1237) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1238 schema
1239 .mutations
1240 .iter()
1241 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1242 .collect()
1243}
1244
1245fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1258 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1259 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1260
1261 let mut targets: Vec<&str> = mutation
1263 .invalidates_views
1264 .iter()
1265 .chain(mutation.invalidates_fact_tables.iter())
1266 .map(String::as_str)
1267 .collect();
1268 targets.sort_unstable();
1269 targets.dedup();
1270
1271 let alter_stmts: Vec<String> = targets
1274 .iter()
1275 .map(|&name| {
1276 let table = name
1277 .strip_prefix("tv_")
1278 .or_else(|| name.strip_prefix("v_"))
1279 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1280 format!("ALTER TABLE {table} SET (fillfactor = 75);")
1281 })
1282 .collect();
1283
1284 warn!(
1285 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1286 Under high write load, HOT-update page slots on these tables may be \
1287 exhausted, forcing full-page writes and reducing mutation throughput. \
1288 Set fillfactor=70-80 on the backing tables: {} \
1289 Monitor HOT efficiency: SELECT relname, \
1290 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1291 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1292 mutation.name,
1293 total,
1294 targets.join(", "),
1295 alter_stmts.join(" "),
1296 );
1297 }
1298}
1299
1300pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1313 if schema.naming_convention != NamingConvention::Preserve {
1314 return Vec::new();
1315 }
1316 let mut out = Vec::new();
1317 for mutation in &schema.mutations {
1318 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1321 || matches!(mutation.operation, MutationOperation::Update { .. });
1322 if !single_jsonb {
1323 continue;
1324 }
1325 let input_type_name = match mutation.arguments.as_slice() {
1332 [arg] if arg.name == "input" => match &arg.arg_type {
1333 FieldType::Input(name) => name.as_str(),
1334 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1335 _ => continue,
1336 },
1337 _ => continue,
1338 };
1339 let Some(input_type) = schema.find_input_type(input_type_name) else {
1340 continue;
1341 };
1342 let camel_fields: Vec<String> = input_type
1345 .fields
1346 .iter()
1347 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1348 .map(|f| f.name.clone())
1349 .collect();
1350 if !camel_fields.is_empty() {
1351 out.push((mutation.name.clone(), camel_fields));
1352 }
1353 }
1354 out
1355}
1356
1357fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1361 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1362 warn!(
1363 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1364 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1365 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1366 recasing), so a SQL function reading payload->>'snake_field' will receive these \
1367 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1368 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1369 fields.join(", "),
1370 );
1371 }
1372}
1373
1374pub(crate) async fn build_postgres_introspector(
1381 db_url: &str,
1382 tls: &PostgresTlsConfig,
1383) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1384 let pool = crate::connection::postgres_pool(db_url, "database validation", tls).await?;
1385 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1386}
1387
1388const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1390 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1391];
1392
1393pub(crate) fn apply_database_report(
1405 schema: &mut fraiseql_core::schema::CompiledSchema,
1406 report: &crate::schema::database_validator::DatabaseValidationReport,
1407) {
1408 for query in &mut schema.queries {
1409 if let Some(cols) = report.native_columns.get(&query.name) {
1410 query.native_columns = cols.clone();
1411 }
1412 if let Some(order) = report.pagination_orders.get(&query.name) {
1413 query.pagination_order = Some(order.clone());
1414 }
1415 }
1416}
1417
1418pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1430 for query in &mut schema.queries {
1431 if query.sql_source.is_none()
1433 || query.jsonb_column.is_empty()
1434 || !query.sql_source_kind.is_relation()
1435 {
1436 continue;
1437 }
1438 for arg in &query.arguments {
1439 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1440 continue;
1441 }
1442 if query.native_columns.contains_key(&arg.name) {
1443 continue; }
1445 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1446 query.native_columns.insert(
1448 arg.name.clone(),
1449 fraiseql_core::schema::NativeColumn::nullable("uuid"),
1450 );
1451 }
1452 }
1453 }
1454}