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 mutation_contract::{Severity, validate_mutation_contract},
21 pg_catalog::PgCatalog,
22 },
23};
24
25#[derive(Debug, Default)]
27pub struct CompileOptions<'a> {
28 pub input: &'a str,
30 pub types: Option<&'a str>,
32 pub schema_dir: Option<&'a str>,
34 pub type_files: Vec<String>,
36 pub query_files: Vec<String>,
38 pub mutation_files: Vec<String>,
40 pub database: Option<&'a str>,
42 pub skip_hash: bool,
44 pub allow_drift: bool,
47 pub config: Option<ConfigSource>,
51}
52
53impl<'a> CompileOptions<'a> {
54 #[must_use]
56 pub fn new(input: &'a str) -> Self {
57 Self {
58 input,
59 ..Default::default()
60 }
61 }
62
63 #[must_use]
65 pub fn with_types(mut self, types: &'a str) -> Self {
66 self.types = Some(types);
67 self
68 }
69
70 #[must_use]
72 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
73 self.schema_dir = Some(schema_dir);
74 self
75 }
76
77 #[must_use]
79 pub fn with_database(mut self, database: &'a str) -> Self {
80 self.database = Some(database);
81 self
82 }
83}
84
85#[allow(clippy::cognitive_complexity)] pub fn load_intermediate_schema(
100 toml_path: &str,
101 type_files: &[String],
102 query_files: &[String],
103 mutation_files: &[String],
104 schema_dir: Option<&str>,
105 types_path: Option<&str>,
106) -> Result<IntermediateSchema> {
107 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
108 info!("Mode: Explicit file lists");
109 return crate::schema::SchemaMerger::merge_explicit_files(
110 toml_path,
111 type_files,
112 query_files,
113 mutation_files,
114 )
115 .context("Failed to load explicit schema files");
116 }
117 if let Some(dir) = schema_dir {
118 info!("Mode: Auto-discovery from directory: {}", dir);
119 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
120 .context("Failed to load schema from directory");
121 }
122 if let Some(types) = types_path {
123 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
124 return crate::schema::SchemaMerger::merge_files(types, toml_path)
125 .context("Failed to merge types.json with TOML");
126 }
127 let toml_schema = crate::config::TomlSchema::from_file(toml_path)
139 .context(format!("Failed to load TOML from {toml_path}"))?;
140
141 if toml_schema.domain_discovery.enabled {
142 info!("Mode: TOML-based with domain discovery");
143 return crate::schema::SchemaMerger::merge_from_domains(toml_path).context(
144 "Failed to load schema from the configured [domain_discovery] section. Fix the \
145 error above, or remove the section to compile from TOML definitions only.",
146 );
147 }
148
149 if !toml_schema.includes.is_empty() {
150 info!("Mode: TOML-based with schema includes");
151 return crate::schema::SchemaMerger::merge_with_includes(toml_path).context(
152 "Failed to load schema from the configured [includes] section. Fix the error \
153 above, or remove the section to compile from TOML definitions only.",
154 );
155 }
156
157 info!("No domains or includes configured, using TOML-only definitions");
158 crate::schema::SchemaMerger::merge_toml_only(toml_path)
159 .context("Failed to load schema from TOML")
160}
161
162#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
177 opts: CompileOptions<'_>,
178) -> Result<(CompiledArtifact, OptimizationReport)> {
179 info!("Compiling schema: {}", opts.input);
180
181 if let Some(db_url) = opts.database {
184 crate::connection::require_postgres(db_url)?;
185 }
186
187 let input_path = Path::new(opts.input);
189 if !input_path.exists() {
190 anyhow::bail!("Input file not found: {}", opts.input);
191 }
192
193 let is_toml = input_path
195 .extension()
196 .and_then(|ext| ext.to_str())
197 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
198 let mut intermediate: IntermediateSchema = if is_toml {
199 info!("Using TOML-based workflow");
200 load_intermediate_schema(
201 opts.input,
202 &opts.type_files,
203 &opts.query_files,
204 &opts.mutation_files,
205 opts.schema_dir,
206 opts.types,
207 )?
208 } else {
209 info!("Using legacy JSON workflow");
211 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
212
213 info!("Parsing intermediate schema...");
218 let raw: serde_json::Value =
219 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?;
220
221 crate::schema::intermediate::reject_drifted_keys(&raw)
226 .map_err(|e| anyhow::anyhow!("{e}"))?;
227
228 let input_has_federation = ["federation", "federation_config"].iter().any(|key| {
229 raw.get(*key)
230 .and_then(serde_json::Value::as_object)
231 .is_some_and(|o| !o.is_empty())
232 });
233 let intermediate: IntermediateSchema =
234 serde_json::from_value(raw).context("Failed to parse schema.json")?;
235 if input_has_federation && intermediate.federation_config.is_none() {
236 anyhow::bail!(
237 "schema.json carries a `federation` block that did not bind into the compiled \
238 schema — refusing to compile a silently non-federated subgraph. This is a \
239 compiler bug; please report it."
240 );
241 }
242 intermediate
243 };
244
245 let config_source = match opts.config {
249 Some(source) => source,
250 None => ConfigSource::resolve(input_path, None)?,
251 };
252 info!("Project config: {config_source}");
253 let mut auto_error_union = false;
255 let mut naming_acronyms: Vec<String> = Vec::new();
257 let mut operation_cost_weights: std::collections::HashMap<String, usize> =
259 std::collections::HashMap::new();
260 let mut naming_convention = NamingConvention::CamelCase;
266 let mut database_tls = if is_toml && opts.database.is_some() {
269 crate::config::TomlSchema::from_file(opts.input)
270 .with_context(|| format!("Failed to load TOML from {}", opts.input))?
271 .database
272 .postgres_tls()?
273 } else {
274 PostgresTlsConfig::default()
275 };
276 if let Some(config_path) = config_source.project_config() {
277 info!("Loading security configuration from {}...", config_path.display());
278 let config_file = config_path.to_str().ok_or_else(|| {
279 anyhow::anyhow!("Config path is not valid UTF-8: {}", config_path.display())
280 })?;
281 match TomlProjectConfig::from_file(config_file) {
282 Ok(config) => {
283 info!("Validating security configuration...");
284 config.validate()?;
285
286 auto_error_union = config.fraiseql.mutations.auto_error_union;
287 naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
288 operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
289 naming_convention = config.fraiseql.naming.convention;
290 database_tls = config.database.postgres_tls()?;
291
292 info!("Applying security configuration to schema...");
293 let mut security_json = config.fraiseql.security.to_json();
295
296 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
301
302 intermediate.security = Some(security_json);
303
304 if config.fraiseql.session_variables
308 != fraiseql_core::schema::SessionVariablesConfig::default()
309 {
310 intermediate.session_variables = Some(config.fraiseql.session_variables);
311 }
312
313 if let Some(locale) = config.locale {
316 if intermediate.locale.as_ref().is_some_and(|l| *l != locale) {
317 anyhow::bail!(
318 "[locale] in {} disagrees with the `locale` the schema document \
319 declares; declare it once, in fraiseql.toml",
320 config_path.display()
321 );
322 }
323 intermediate.locale = Some(locale);
324 }
325
326 intermediate.inject_defaults =
329 crate::config::inject_defaults::InjectDefaultsToml::reconcile(
330 config.inject_defaults.as_ref(),
331 intermediate.inject_defaults.take(),
332 )?;
333
334 info!("Security configuration applied successfully");
335 },
336 Err(e) => {
337 anyhow::bail!(
338 "Failed to parse {}: {e}\n\
339 Fix the configuration file or remove it to use defaults.",
340 config_path.display()
341 );
342 },
343 }
344 }
345
346 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
350
351 if !is_toml {
357 intermediate.naming_convention = naming_convention;
358 }
359
360 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
363 let tenancy = sec.get("tenancy")?;
364 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
365 if mode == "row" {
366 Some(
371 tenancy
372 .get("tenant_claim")
373 .and_then(|c| c.as_str())
374 .unwrap_or("tenant_id")
375 .to_string(),
376 )
377 } else {
378 None
379 }
380 });
381 if let Some(tenant_claim) = &tenancy_row_claim {
382 info!("Validating @tenant_id annotations for row-isolation tenancy...");
383 crate::schema::converter::tenancy::validate_tenant_annotations(
384 &mut intermediate,
385 tenant_claim,
386 )
387 .context("@tenant_id validation failed")?;
388 }
389
390 info!("Validating schema structure...");
392 let validation_report =
393 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
394
395 if !validation_report.is_valid() {
396 validation_report.print();
397 let errors: Vec<String> = validation_report
400 .errors
401 .iter()
402 .filter(|e| e.severity == crate::schema::validator::ErrorSeverity::Error)
403 .map(|e| format!("{} ({})", e.message, e.path))
404 .collect();
405 anyhow::bail!(
406 "Schema validation failed with {} error(s):\n - {}",
407 validation_report.error_count(),
408 errors.join("\n - ")
409 );
410 }
411
412 if validation_report.warning_count() > 0 {
414 validation_report.print();
415 }
416
417 let mutation_defaults =
425 intermediate.inject_defaults.clone().unwrap_or_default().for_mutations();
426 let default_args: std::collections::HashMap<String, Vec<String>> = intermediate
427 .mutations
428 .iter()
429 .filter_map(|m| {
430 let mut inject = m.inject.clone();
431 crate::schema::intermediate::IntermediateInjectDefaults::apply_to(
432 &mutation_defaults,
433 &mut inject,
434 &m.exclude_inject_defaults,
435 &m.name,
436 )
437 .ok()
438 .filter(|added| !added.is_empty())
439 .map(|added| (m.name.clone(), added))
440 })
441 .collect();
442
443 info!("Converting to compiled format...");
444 let CompiledArtifact {
445 mut schema,
446 functions,
447 } = SchemaConverter::convert_artifact(intermediate, &ConvertOptions { auto_error_union })
448 .context("Failed to convert schema to compiled format")?;
449
450 schema.naming_acronyms = naming_acronyms;
453
454 if !operation_cost_weights.is_empty() {
458 schema.operation_cost_weights = operation_cost_weights;
459 }
460
461 info!("Analyzing schema for optimization opportunities...");
463 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
464
465 infer_native_columns_from_arg_types(&mut schema);
468
469 if let Some(db_url) = opts.database {
471 info!("Validating native columns for direct query arguments...");
472 let pool = crate::connection::postgres_pool(db_url, "database validation", &database_tls)
474 .await
475 .context("Failed to connect for database validation")?;
476 let pg_introspector = fraiseql_core::db::postgres::PostgresIntrospector::new(pool.clone());
477 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
478
479 let mut drift_errors: Vec<String> = Vec::new();
482 for w in &db_report.warnings {
483 match w.severity() {
484 Severity::Error => {
485 warn!("{w} [drift error]");
486 drift_errors.push(w.to_string());
487 },
488 Severity::Warn => warn!("{w}"),
489 }
490 }
491 apply_database_report(&mut schema, &db_report);
492
493 info!("Validating mutation contract against the database...");
498 let catalog = PgCatalog::from_pool(pool);
499
500 let sources: Vec<String> = schema
503 .types
504 .iter()
505 .map(|t| t.sql_source.to_string())
506 .filter(|s| !s.is_empty())
507 .collect();
508 match catalog.tview_references(&sources).await {
510 Ok(references) => {
511 let proven =
512 crate::schema::native_joins::apply_native_joins(&mut schema, &references);
513 if proven > 0 {
514 info!(
515 "{proven} relationship(s) correlate on native columns (pg_tviews references)"
516 );
517 }
518 },
519 Err(error) => warn!(
520 "cannot read tviews.registry.references ({error:#}); composed reads correlate on \
521 document keys"
522 ),
523 }
524
525 let contract = validate_mutation_contract(&schema, &catalog).await?;
526 let mut caused_by_default: std::collections::BTreeMap<String, Vec<String>> =
527 std::collections::BTreeMap::new();
528 for m in &contract.mutations {
529 let added = default_args.get(&m.mutation).map_or(&[][..], Vec::as_slice);
530 for v in &m.violations {
531 let kind = match v.severity() {
532 Severity::Error => "contract error",
533 Severity::Warn => "contract warning",
534 };
535 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
536 if v.severity() != Severity::Error {
537 continue;
538 }
539 if let Some(key) = default_caused(v, added) {
540 caused_by_default.entry(key).or_default().push(m.mutation.clone());
541 } else {
542 drift_errors.push(format!(
543 "mutation `{}` (sql_source: {}): {v}",
544 m.mutation, m.sql_source
545 ));
546 }
547 }
548 }
549 for (key, mutations) in caused_by_default {
550 drift_errors.push(default_collision(&key, &mutations));
551 }
552
553 let tviews_sourced: Vec<&str> = schema
556 .mutations
557 .iter()
558 .filter(|m| m.cascade_source == fraiseql_core::schema::CascadeSource::PgTviews)
559 .map(|m| m.name.as_str())
560 .collect();
561 if !tviews_sourced.is_empty() && !catalog.pg_tviews_flush_available().await? {
562 for name in tviews_sourced {
563 drift_errors.push(format!(
564 "mutation `{name}`: cascade_source = \"pg_tviews\", and the database has no \
565 tviews.pg_tviews_flush_and_report(integer, boolean, boolean): install \
566 pg_tviews (preloaded), or drop cascade_source"
567 ));
568 }
569 }
570
571 if !drift_errors.is_empty() {
576 if opts.allow_drift {
577 warn!(
578 "{} schema↔database drift error(s) suppressed by --allow-drift",
579 drift_errors.len()
580 );
581 } else {
582 let list =
583 drift_errors.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n");
584 anyhow::bail!(
585 "schema↔database drift: {} error(s):\n{list}\n\
586 Fix the declarations or the database objects, or pass --allow-drift \
587 to compile anyway (advisory mode).",
588 drift_errors.len()
589 );
590 }
591 }
592 } else {
593 for query in &schema.queries {
596 if query.sql_source.is_none() {
597 continue;
598 }
599 let unresolved: Vec<_> = query
600 .arguments
601 .iter()
602 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
603 .filter(|a| !query.native_columns.contains_key(&a.name))
604 .collect();
605 if !unresolved.is_empty() {
606 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
607 warn!(
608 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
609 columns — no --database URL provided. These filters will use JSONB \
610 extraction. Provide --database or annotate with native_columns.",
611 query.name,
612 names,
613 query.sql_source.as_deref().unwrap_or("?"),
614 );
615 }
616 }
617 }
618
619 warn_wide_cascade_mutations(&schema);
621
622 warn_jsonb_preserve_mismatch(&schema);
625
626 refuse_unattributable_mutations(&schema)?;
628
629 refuse_unenforced_authz_declarations(&schema)?;
631
632 refuse_what_a_server_would_not_load(&schema)?;
634
635 Ok((CompiledArtifact { schema, functions }, report))
636}
637
638pub(crate) fn default_caused(
658 violation: &crate::schema::mutation_contract::ContractViolation,
659 added: &[String],
660) -> Option<String> {
661 use crate::schema::mutation_contract::ContractViolation;
662 match violation {
663 ContractViolation::ArityMismatch { expected, found }
664 if !added.is_empty() && found.contains(&expected.saturating_sub(added.len())) =>
665 {
666 Some(added.join(", "))
667 },
668 ContractViolation::InjectNameMismatch { expected, .. } if added.contains(expected) => {
669 Some(expected.clone())
670 },
671 _ => None,
672 }
673}
674
675pub(crate) fn default_collision(key: &str, mutations: &[String]) -> String {
677 const SHOWN: usize = 5;
678 let shown = mutations.iter().take(SHOWN).map(|m| format!("`{m}`")).collect::<Vec<_>>();
679 let more = mutations.len().saturating_sub(SHOWN);
680 let list = if more == 0 {
681 shown.join(", ")
682 } else {
683 format!("{}, and {more} more", shown.join(", "))
684 };
685 format!(
686 "[inject_defaults] adds `{key}` to {} mutation(s) whose functions do not take it: \
687 {list}. Exclude it on those mutations (`exclude_inject_defaults = [\"{key}\"]`), or \
688 move it from the base [inject_defaults] table to [inject_defaults.queries].",
689 mutations.len()
690 )
691}
692
693fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
694 let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
695 let mut value: serde_json::Value = serde_json::from_str(&body)?;
696 let hash = content_hash_of(&value);
697 value
698 .as_object_mut()
699 .context("schema must serialise as JSON object")?
700 .insert("_content_hash".to_string(), serde_json::Value::String(hash));
701 CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
702 anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
703 })?;
704 Ok(())
705}
706
707fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
725 let Some(security) = schema.security.as_ref() else {
726 return Ok(());
727 };
728
729 let mut declared: Vec<String> = Vec::new();
730 if !security.rules.is_empty() {
731 declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
732 }
733 if !security.field_auth.is_empty() {
734 declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
735 }
736 if let Some(policy) = security.default_policy.as_deref() {
737 declared.push(format!("`security.default_policy` (\"{policy}\")"));
738 }
739 if declared.is_empty() {
740 return Ok(());
741 }
742
743 anyhow::bail!(
744 "authorization declaration(s) with no enforcer: {}.\n\n\
745 FraiseQL enforces field-level authorization through `security.role_definitions` \
746 (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
747 rule written here is not applied to any request. Remove them, or express the same \
748 intent with role definitions and `requires_scope`.\n\n\
749 (A general authorization-rule engine is tracked separately; until it exists, a \
750 declaration that silently enforces nothing is refused rather than accepted.)",
751 declared.join(", ")
752 )
753}
754
755fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
779 if !fraiseql_core::cache::declares_cacheable_views(schema) {
780 return Ok(());
781 }
782 let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
783 if unattributable.is_empty() {
784 return Ok(());
785 }
786
787 let cacheable: Vec<&str> = schema
788 .queries
789 .iter()
790 .filter(|q| q.cache_ttl_seconds.is_some())
791 .filter_map(|q| q.sql_source.as_deref())
792 .collect();
793 let list = unattributable.iter().map(|m| format!(" - {m}")).collect::<Vec<_>>().join("\n");
794 anyhow::bail!(
795 "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
796 This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
797 view invalidates nothing when it succeeds — permanently, for any view annotated \
798 `cache_ttl_seconds = 0`.\n\n\
799 Fix by declaring what each mutation writes:\n\
800 \x20 invalidates_views = [\"v_price\"]\n\n\
801 A mutation whose return type is backed by a view, or whose payload wraps an entity \
802 that is, already resolves and needs no annotation.",
803 cacheable.len(),
804 cacheable.join(", "),
805 )
806}
807
808#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
838#[doc(hidden)] pub async fn run(
842 input: &str,
843 types: Option<&str>,
844 schema_dir: Option<&str>,
845 type_files: Vec<String>,
846 query_files: Vec<String>,
847 mutation_files: Vec<String>,
848 output: &str,
849 check: bool,
850 database: Option<&str>,
851 emit_ddl: Option<&str>,
852 check_migrations: bool,
853 skip_hash: bool,
854 allow_drift: bool,
855 config: Option<&str>,
856) -> Result<()> {
857 if !check && Path::new(output) == Path::new(input) {
862 anyhow::bail!(
863 "Refusing to write compiled output over the input file '{input}'. \
864 Use a distinct --output path (e.g. schema.compiled.json)."
865 );
866 }
867
868 let config = ConfigSource::resolve(Path::new(input), config.map(Path::new))?;
871 println!("Config: {config}");
872
873 let opts = CompileOptions {
874 input,
875 types,
876 schema_dir,
877 type_files,
878 query_files,
879 mutation_files,
880 database,
881 skip_hash,
882 allow_drift,
883 config: Some(config),
884 };
885 let (artifact, optimization_report) = compile_to_schema(opts).await?;
886 let schema = &artifact.schema;
887
888 if check {
890 println!("✓ Schema is valid");
891 println!(" Types: {}", schema.types.len());
892 println!(" Queries: {}", schema.queries.len());
893 println!(" Mutations: {}", schema.mutations.len());
894 optimization_report.print();
895 print!("{}", localized_index_report_text(schema));
896 return Ok(());
897 }
898
899 info!("Writing compiled schema to: {output}");
901 let value = artifact.to_json_value()?;
904
905 let output_json = if skip_hash {
906 serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
907 } else {
908 let hash_hex = content_hash_of(&value);
911
912 let obj = value.as_object().context("schema must serialise as JSON object")?;
913
914 let mut new_obj = serde_json::Map::new();
916 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
917 for (k, v) in obj {
918 new_obj.insert(k.clone(), v.clone());
919 }
920 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
921 };
922
923 fs::write(output, output_json).context("Failed to write compiled schema")?;
924
925 println!("✓ Schema compiled successfully");
927 println!(" Input: {input}");
928 println!(" Output: {output}");
929 println!(" Types: {}", schema.types.len());
930 println!(" Queries: {}", schema.queries.len());
931 println!(" Mutations: {}", schema.mutations.len());
932 optimization_report.print();
933 print!("{}", localized_index_report_text(schema));
934
935 if let Some(ddl_dir) = emit_ddl {
937 emit_ddl_to_dir(schema, ddl_dir)?;
938 }
939
940 if check_migrations {
942 run_check_migrations(schema)?;
943 }
944
945 Ok(())
946}
947
948#[must_use]
956pub fn localized_index_report_text(schema: &fraiseql_core::schema::CompiledSchema) -> String {
957 use std::fmt::Write as _;
958 let report = schema.localized_index_report();
959 if report.is_empty() {
960 return String::new();
961 }
962 let mut text = String::from(
963 "\nLocalized field indexes (one per field and allowed locale; a filter or sort on the \
964 field reads it):\n",
965 );
966 for advice in &report {
967 let subject = format!(
968 "{}.{} [{}] on {}",
969 advice.type_name, advice.field, advice.locale, advice.table
970 );
971 let _ = match &advice.index {
973 Some(index) => writeln!(text, " {subject}:\n {}", index.ddl),
974 None => writeln!(
975 text,
976 " {subject}: a view; create the index on its base table, on the expression the \
977 query reads"
978 ),
979 };
980 }
981 text
982}
983
984pub const EMIT_DDL_FORMAT: u32 = 1;
987
988pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
1004 let mut files: std::collections::BTreeMap<String, (&str, String)> =
1006 std::collections::BTreeMap::new();
1007 for type_def in &schema.types {
1008 let table_name = to_snake_case(type_def.name.as_str());
1009 let ddl = build_create_table_ddl(&table_name, type_def)?;
1010 if let Some((other, _)) = files.get(&table_name) {
1011 let (first, second) = if *other < type_def.name.as_str() {
1012 (*other, type_def.name.as_str())
1013 } else {
1014 (type_def.name.as_str(), *other)
1015 };
1016 anyhow::bail!(
1017 "--emit-ddl: types '{first}' and '{second}' both map to the table \
1018 `tb_{table_name}` and the file `{table_name}.sql`; rename one of them"
1019 );
1020 }
1021 files.insert(table_name, (type_def.name.as_str(), ddl));
1022 }
1023
1024 fs::create_dir_all(output_dir)
1025 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
1026 for (table_name, (type_name, ddl)) in &files {
1027 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
1028 fs::write(&file_path, ddl)
1029 .context(format!("Failed to write DDL for type '{type_name}'"))?;
1030 }
1031
1032 println!("✓ DDL emitted to {output_dir}/ ({} table(s))", files.len());
1033 Ok(())
1034}
1035
1036fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
1046 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
1047 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
1048
1049 emit_ddl_to_dir(schema, tmp_path)?;
1050
1051 info!("Running confiture migrate validate for drift detection...");
1052
1053 let status = Command::new("confiture").args(["migrate", "validate"]).status();
1054
1055 match status {
1056 Err(_) => {
1057 eprintln!(
1059 "WARN: confiture is not installed; skipping migration drift check.\n\
1060 Install it with: {}",
1061 super::migrate::CONFITURE_INSTALL_COMMANDS.join(" (or) ")
1062 );
1063 Ok(())
1064 },
1065 Ok(s) if s.success() => {
1066 println!("✓ No migration drift detected.");
1067 Ok(())
1068 },
1069 Ok(_) => {
1070 eprintln!(
1071 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
1072 );
1073 anyhow::bail!(
1074 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
1075 )
1076 },
1077 }
1078}
1079
1080pub(crate) fn to_snake_case(name: &str) -> String {
1088 fraiseql_core::utils::to_snake_case(name)
1089}
1090
1091fn build_create_table_ddl(
1098 table_name: &str,
1099 type_def: &fraiseql_core::schema::TypeDefinition,
1100) -> Result<String> {
1101 let mut lines: Vec<String> = Vec::new();
1102 lines.push(format!("-- fraiseql emit-ddl format {EMIT_DDL_FORMAT}"));
1103 lines.push(format!("-- Generated by fraiseql-cli {}", env!("CARGO_PKG_VERSION")));
1104 lines.push(format!("-- Type: {}", type_def.name));
1105 if let Some(desc) = &type_def.description {
1106 lines.push(format!("-- {desc}"));
1107 }
1108 lines.push(String::new());
1109 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
1110
1111 let col_lines: Vec<String> = type_def
1112 .fields
1113 .iter()
1114 .map(|field| {
1115 let col_name = to_snake_case(field.name.as_str());
1116 let pg_type =
1121 if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
1122 field.field_type.to_sql_type(field.vector_config.as_ref())
1123 } else {
1124 field_type_to_pg(&field.field_type)
1125 };
1126 let nullable = if field.nullable { "" } else { " NOT NULL" };
1127 format!(" {col_name} {pg_type}{nullable}")
1128 })
1129 .collect();
1130
1131 let last = col_lines.len().saturating_sub(1);
1132 for (i, col) in col_lines.iter().enumerate() {
1133 if i < last {
1134 lines.push(format!("{col},"));
1135 } else {
1136 lines.push(col.clone());
1137 }
1138 }
1139
1140 lines.push(");".to_string());
1141
1142 let vector_fields: Vec<_> = type_def
1146 .fields
1147 .iter()
1148 .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
1149 .collect();
1150 if !vector_fields.is_empty() {
1151 lines.push(String::new());
1152 lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
1153 for (field, config) in vector_fields {
1154 let col_name = to_snake_case(field.name.as_str());
1155 let index_ddl = config
1156 .index_type
1157 .index_sql(
1158 &format!("tb_{table_name}"),
1159 &col_name,
1160 &field.field_type,
1161 config.distance_metric,
1162 )
1163 .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
1164 if let Some(index_ddl) = index_ddl {
1165 lines.push(format!("{index_ddl};"));
1166 }
1167 }
1168 }
1169
1170 lines.push(String::new());
1171 Ok(lines.join("\n"))
1172}
1173
1174pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
1176 match ft {
1177 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
1178 FieldType::Int => "INTEGER".to_string(),
1179 FieldType::Float => "DOUBLE PRECISION".to_string(),
1180 FieldType::Boolean => "BOOLEAN".to_string(),
1181 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
1182 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
1183 FieldType::Date => "DATE".to_string(),
1184 FieldType::Time => "TIME".to_string(),
1185 FieldType::Json | FieldType::List { item: _, .. } | FieldType::Object(_) => {
1186 "JSONB".to_string()
1187 },
1188 FieldType::Decimal => "NUMERIC".to_string(),
1189 FieldType::Vector => "VECTOR".to_string(),
1190 FieldType::BitVector => "VARBIT".to_string(),
1195 FieldType::HalfVector => "HALFVEC".to_string(),
1199 FieldType::SparseVector => "SPARSEVEC".to_string(),
1200 FieldType::Enum(name) => name.clone(),
1202 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
1203 _ => "TEXT".to_string(),
1205 }
1206}
1207
1208pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1211
1212pub(crate) fn wide_cascade_mutations(
1218 schema: &CompiledSchema,
1219 threshold: usize,
1220) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1221 schema
1222 .mutations
1223 .iter()
1224 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1225 .collect()
1226}
1227
1228fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1241 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1242 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1243
1244 let mut targets: Vec<&str> = mutation
1246 .invalidates_views
1247 .iter()
1248 .chain(mutation.invalidates_fact_tables.iter())
1249 .map(String::as_str)
1250 .collect();
1251 targets.sort_unstable();
1252 targets.dedup();
1253
1254 let alter_stmts: Vec<String> = targets
1257 .iter()
1258 .map(|&name| {
1259 let table = name
1260 .strip_prefix("tv_")
1261 .or_else(|| name.strip_prefix("v_"))
1262 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1263 format!("ALTER TABLE {table} SET (fillfactor = 75);")
1264 })
1265 .collect();
1266
1267 warn!(
1268 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1269 Under high write load, HOT-update page slots on these tables may be \
1270 exhausted, forcing full-page writes and reducing mutation throughput. \
1271 Set fillfactor=70-80 on the backing tables: {} \
1272 Monitor HOT efficiency: SELECT relname, \
1273 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1274 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1275 mutation.name,
1276 total,
1277 targets.join(", "),
1278 alter_stmts.join(" "),
1279 );
1280 }
1281}
1282
1283pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1296 if schema.naming_convention != NamingConvention::Preserve {
1297 return Vec::new();
1298 }
1299 let mut out = Vec::new();
1300 for mutation in &schema.mutations {
1301 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1304 || matches!(mutation.operation, MutationOperation::Update { .. });
1305 if !single_jsonb {
1306 continue;
1307 }
1308 let input_type_name = match mutation.arguments.as_slice() {
1315 [arg] if arg.name == "input" => match &arg.arg_type {
1316 FieldType::Input(name) => name.as_str(),
1317 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1318 _ => continue,
1319 },
1320 _ => continue,
1321 };
1322 let Some(input_type) = schema.find_input_type(input_type_name) else {
1323 continue;
1324 };
1325 let camel_fields: Vec<String> = input_type
1328 .fields
1329 .iter()
1330 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1331 .map(|f| f.name.clone())
1332 .collect();
1333 if !camel_fields.is_empty() {
1334 out.push((mutation.name.clone(), camel_fields));
1335 }
1336 }
1337 out
1338}
1339
1340fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1344 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1345 warn!(
1346 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1347 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1348 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1349 recasing), so a SQL function reading payload->>'snake_field' will receive these \
1350 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1351 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1352 fields.join(", "),
1353 );
1354 }
1355}
1356
1357pub(crate) async fn build_postgres_introspector(
1364 db_url: &str,
1365 tls: &PostgresTlsConfig,
1366) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1367 let pool = crate::connection::postgres_pool(db_url, "database validation", tls).await?;
1368 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1369}
1370
1371const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1373 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1374];
1375
1376pub(crate) fn apply_database_report(
1388 schema: &mut fraiseql_core::schema::CompiledSchema,
1389 report: &crate::schema::database_validator::DatabaseValidationReport,
1390) {
1391 for query in &mut schema.queries {
1392 if let Some(cols) = report.native_columns.get(&query.name) {
1393 query.native_columns = cols.clone();
1394 }
1395 if let Some(order) = report.pagination_orders.get(&query.name) {
1396 query.pagination_order = Some(order.clone());
1397 }
1398 }
1399}
1400
1401pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1413 for query in &mut schema.queries {
1414 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
1415 continue;
1416 }
1417 for arg in &query.arguments {
1418 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1419 continue;
1420 }
1421 if query.native_columns.contains_key(&arg.name) {
1422 continue; }
1424 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1425 query.native_columns.insert(
1427 arg.name.clone(),
1428 fraiseql_core::schema::NativeColumn::nullable("uuid"),
1429 );
1430 }
1431 }
1432 }
1433}