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.inject_defaults =
330 crate::config::inject_defaults::InjectDefaultsToml::reconcile(
331 config.inject_defaults.as_ref(),
332 intermediate.inject_defaults.take(),
333 )?;
334
335 info!("Security configuration applied successfully");
336 },
337 Err(e) => {
338 anyhow::bail!(
339 "Failed to parse {}: {e}\n\
340 Fix the configuration file or remove it to use defaults.",
341 config_path.display()
342 );
343 },
344 }
345 }
346
347 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
351
352 if !is_toml {
358 intermediate.naming_convention = naming_convention;
359 }
360
361 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
364 let tenancy = sec.get("tenancy")?;
365 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
366 if mode == "row" {
367 Some(
372 tenancy
373 .get("tenant_claim")
374 .and_then(|c| c.as_str())
375 .unwrap_or("tenant_id")
376 .to_string(),
377 )
378 } else {
379 None
380 }
381 });
382 if let Some(tenant_claim) = &tenancy_row_claim {
383 info!("Validating @tenant_id annotations for row-isolation tenancy...");
384 crate::schema::converter::tenancy::validate_tenant_annotations(
385 &mut intermediate,
386 tenant_claim,
387 )
388 .context("@tenant_id validation failed")?;
389 }
390
391 info!("Validating schema structure...");
393 let validation_report =
394 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
395
396 if !validation_report.is_valid() {
397 validation_report.print();
398 let errors: Vec<String> = validation_report
401 .errors
402 .iter()
403 .filter(|e| e.severity == crate::schema::validator::ErrorSeverity::Error)
404 .map(|e| format!("{} ({})", e.message, e.path))
405 .collect();
406 anyhow::bail!(
407 "Schema validation failed with {} error(s):\n - {}",
408 validation_report.error_count(),
409 errors.join("\n - ")
410 );
411 }
412
413 if validation_report.warning_count() > 0 {
415 validation_report.print();
416 }
417
418 let mutation_defaults =
426 intermediate.inject_defaults.clone().unwrap_or_default().for_mutations();
427 let default_args: std::collections::HashMap<String, Vec<String>> = intermediate
428 .mutations
429 .iter()
430 .filter_map(|m| {
431 let mut inject = m.inject.clone();
432 crate::schema::intermediate::IntermediateInjectDefaults::apply_to(
433 &mutation_defaults,
434 &mut inject,
435 &m.exclude_inject_defaults,
436 &m.name,
437 )
438 .ok()
439 .filter(|added| !added.is_empty())
440 .map(|added| (m.name.clone(), added))
441 })
442 .collect();
443
444 info!("Converting to compiled format...");
445 let CompiledArtifact {
446 mut schema,
447 functions,
448 } = SchemaConverter::convert_artifact(intermediate, &ConvertOptions { auto_error_union })
449 .context("Failed to convert schema to compiled format")?;
450
451 schema.naming_acronyms = naming_acronyms;
454
455 if !operation_cost_weights.is_empty() {
459 schema.operation_cost_weights = operation_cost_weights;
460 }
461
462 info!("Analyzing schema for optimization opportunities...");
464 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
465
466 infer_native_columns_from_arg_types(&mut schema);
469
470 if let Some(db_url) = opts.database {
472 info!("Validating native columns for direct query arguments...");
473 let pool = crate::connection::postgres_pool(db_url, "database validation", &database_tls)
475 .await
476 .context("Failed to connect for database validation")?;
477 let pg_introspector = fraiseql_core::db::postgres::PostgresIntrospector::new(pool.clone());
478 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
479
480 let mut drift_errors: Vec<String> = Vec::new();
483 for w in &db_report.warnings {
484 match w.severity() {
485 Severity::Error => {
486 warn!("{w} [drift error]");
487 drift_errors.push(w.to_string());
488 },
489 Severity::Warn => warn!("{w}"),
490 }
491 }
492 apply_database_report(&mut schema, &db_report);
493
494 info!("Validating mutation contract against the database...");
499 let catalog = PgCatalog::from_pool(pool);
500
501 let sources: Vec<String> = schema
504 .types
505 .iter()
506 .map(|t| t.sql_source.to_string())
507 .filter(|s| !s.is_empty())
508 .collect();
509 match catalog.tview_references(&sources).await {
511 Ok(references) => {
512 let proven =
513 crate::schema::native_joins::apply_native_joins(&mut schema, &references);
514 if proven > 0 {
515 info!(
516 "{proven} relationship(s) correlate on native columns (pg_tviews references)"
517 );
518 }
519 },
520 Err(error) => warn!(
521 "cannot read tviews.registry.references ({error:#}); composed reads correlate on \
522 document keys"
523 ),
524 }
525
526 drift_errors.extend(validate_function_roots(&schema, &catalog).await?);
528
529 let contract = validate_mutation_contract(&schema, &catalog).await?;
530 let mut caused_by_default: std::collections::BTreeMap<String, Vec<String>> =
531 std::collections::BTreeMap::new();
532 for m in &contract.mutations {
533 let added = default_args.get(&m.mutation).map_or(&[][..], Vec::as_slice);
534 for v in &m.violations {
535 let kind = match v.severity() {
536 Severity::Error => "contract error",
537 Severity::Warn => "contract warning",
538 };
539 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
540 if v.severity() != Severity::Error {
541 continue;
542 }
543 if let Some(key) = default_caused(v, added) {
544 caused_by_default.entry(key).or_default().push(m.mutation.clone());
545 } else {
546 drift_errors.push(format!(
547 "mutation `{}` (sql_source: {}): {v}",
548 m.mutation, m.sql_source
549 ));
550 }
551 }
552 }
553 for (key, mutations) in caused_by_default {
554 drift_errors.push(default_collision(&key, &mutations));
555 }
556
557 let tviews_sourced: Vec<&str> = schema
560 .mutations
561 .iter()
562 .filter(|m| m.cascade_source == fraiseql_core::schema::CascadeSource::PgTviews)
563 .map(|m| m.name.as_str())
564 .collect();
565 if !tviews_sourced.is_empty() && !catalog.pg_tviews_flush_available().await? {
566 for name in tviews_sourced {
567 drift_errors.push(format!(
568 "mutation `{name}`: cascade_source = \"pg_tviews\", and the database has no \
569 tviews.pg_tviews_flush_and_report(integer, boolean, boolean): install \
570 pg_tviews (preloaded), or drop cascade_source"
571 ));
572 }
573 }
574
575 if !drift_errors.is_empty() {
580 if opts.allow_drift {
581 warn!(
582 "{} schema↔database drift error(s) suppressed by --allow-drift",
583 drift_errors.len()
584 );
585 } else {
586 let list =
587 drift_errors.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n");
588 anyhow::bail!(
589 "schema↔database drift: {} error(s):\n{list}\n\
590 Fix the declarations or the database objects, or pass --allow-drift \
591 to compile anyway (advisory mode).",
592 drift_errors.len()
593 );
594 }
595 }
596 } else {
597 for query in &schema.queries {
600 if query.sql_source.is_none() {
601 continue;
602 }
603 let unresolved: Vec<_> = query
604 .arguments
605 .iter()
606 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
607 .filter(|a| !query.native_columns.contains_key(&a.name))
608 .collect();
609 if !unresolved.is_empty() {
610 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
611 warn!(
612 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
613 columns — no --database URL provided. These filters will use JSONB \
614 extraction. Provide --database or annotate with native_columns.",
615 query.name,
616 names,
617 query.sql_source.as_deref().unwrap_or("?"),
618 );
619 }
620 }
621 }
622
623 warn_wide_cascade_mutations(&schema);
625
626 warn_jsonb_preserve_mismatch(&schema);
629
630 refuse_unattributable_mutations(&schema)?;
632
633 refuse_unenforced_authz_declarations(&schema)?;
635
636 refuse_what_a_server_would_not_load(&schema)?;
638
639 Ok((CompiledArtifact { schema, functions }, report))
640}
641
642pub(crate) fn default_caused(
662 violation: &crate::schema::mutation_contract::ContractViolation,
663 added: &[String],
664) -> Option<String> {
665 use crate::schema::mutation_contract::ContractViolation;
666 match violation {
667 ContractViolation::ArityMismatch { expected, found }
668 if !added.is_empty() && found.contains(&expected.saturating_sub(added.len())) =>
669 {
670 Some(added.join(", "))
671 },
672 ContractViolation::InjectNameMismatch { expected, .. } if added.contains(expected) => {
673 Some(expected.clone())
674 },
675 _ => None,
676 }
677}
678
679pub(crate) fn default_collision(key: &str, mutations: &[String]) -> String {
681 const SHOWN: usize = 5;
682 let shown = mutations.iter().take(SHOWN).map(|m| format!("`{m}`")).collect::<Vec<_>>();
683 let more = mutations.len().saturating_sub(SHOWN);
684 let list = if more == 0 {
685 shown.join(", ")
686 } else {
687 format!("{}, and {more} more", shown.join(", "))
688 };
689 format!(
690 "[inject_defaults] adds `{key}` to {} mutation(s) whose functions do not take it: \
691 {list}. Exclude it on those mutations (`exclude_inject_defaults = [\"{key}\"]`), or \
692 move it from the base [inject_defaults] table to [inject_defaults.queries].",
693 mutations.len()
694 )
695}
696
697fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
698 let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
699 let mut value: serde_json::Value = serde_json::from_str(&body)?;
700 let hash = content_hash_of(&value);
701 value
702 .as_object_mut()
703 .context("schema must serialise as JSON object")?
704 .insert("_content_hash".to_string(), serde_json::Value::String(hash));
705 CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
706 anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
707 })?;
708 Ok(())
709}
710
711fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
729 let Some(security) = schema.security.as_ref() else {
730 return Ok(());
731 };
732
733 let mut declared: Vec<String> = Vec::new();
734 if !security.rules.is_empty() {
735 declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
736 }
737 if !security.field_auth.is_empty() {
738 declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
739 }
740 if let Some(policy) = security.default_policy.as_deref() {
741 declared.push(format!("`security.default_policy` (\"{policy}\")"));
742 }
743 if declared.is_empty() {
744 return Ok(());
745 }
746
747 anyhow::bail!(
748 "authorization declaration(s) with no enforcer: {}.\n\n\
749 FraiseQL enforces field-level authorization through `security.role_definitions` \
750 (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
751 rule written here is not applied to any request. Remove them, or express the same \
752 intent with role definitions and `requires_scope`.\n\n\
753 (A general authorization-rule engine is tracked separately; until it exists, a \
754 declaration that silently enforces nothing is refused rather than accepted.)",
755 declared.join(", ")
756 )
757}
758
759fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
783 if !fraiseql_core::cache::declares_cacheable_views(schema) {
784 return Ok(());
785 }
786 let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
787 if unattributable.is_empty() {
788 return Ok(());
789 }
790
791 let cacheable: Vec<&str> = schema
792 .queries
793 .iter()
794 .filter(|q| q.cache_ttl_seconds.is_some())
795 .filter_map(|q| q.sql_source.as_deref())
796 .collect();
797 let list = unattributable.iter().map(|m| format!(" - {m}")).collect::<Vec<_>>().join("\n");
798 anyhow::bail!(
799 "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
800 This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
801 view invalidates nothing when it succeeds — permanently, for any view annotated \
802 `cache_ttl_seconds = 0`.\n\n\
803 Fix by declaring what each mutation writes:\n\
804 \x20 invalidates_views = [\"v_price\"]\n\n\
805 A mutation whose return type is backed by a view, or whose payload wraps an entity \
806 that is, already resolves and needs no annotation.",
807 cacheable.len(),
808 cacheable.join(", "),
809 )
810}
811
812#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
842#[doc(hidden)] pub async fn run(
846 input: &str,
847 types: Option<&str>,
848 schema_dir: Option<&str>,
849 type_files: Vec<String>,
850 query_files: Vec<String>,
851 mutation_files: Vec<String>,
852 output: &str,
853 check: bool,
854 database: Option<&str>,
855 emit_ddl: Option<&str>,
856 check_migrations: bool,
857 skip_hash: bool,
858 allow_drift: bool,
859 config: Option<&str>,
860) -> Result<()> {
861 if !check && Path::new(output) == Path::new(input) {
866 anyhow::bail!(
867 "Refusing to write compiled output over the input file '{input}'. \
868 Use a distinct --output path (e.g. schema.compiled.json)."
869 );
870 }
871
872 let config = ConfigSource::resolve(Path::new(input), config.map(Path::new))?;
875 println!("Config: {config}");
876
877 let opts = CompileOptions {
878 input,
879 types,
880 schema_dir,
881 type_files,
882 query_files,
883 mutation_files,
884 database,
885 skip_hash,
886 allow_drift,
887 config: Some(config),
888 };
889 let (artifact, optimization_report) = compile_to_schema(opts).await?;
890 let schema = &artifact.schema;
891
892 if check {
894 println!("✓ Schema is valid");
895 println!(" Types: {}", schema.types.len());
896 println!(" Queries: {}", schema.queries.len());
897 println!(" Mutations: {}", schema.mutations.len());
898 optimization_report.print();
899 print!("{}", localized_index_report_text(schema));
900 return Ok(());
901 }
902
903 info!("Writing compiled schema to: {output}");
905 let value = artifact.to_json_value()?;
908
909 let output_json = if skip_hash {
910 serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
911 } else {
912 let hash_hex = content_hash_of(&value);
915
916 let obj = value.as_object().context("schema must serialise as JSON object")?;
917
918 let mut new_obj = serde_json::Map::new();
920 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
921 for (k, v) in obj {
922 new_obj.insert(k.clone(), v.clone());
923 }
924 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
925 };
926
927 fs::write(output, output_json).context("Failed to write compiled schema")?;
928
929 println!("✓ Schema compiled successfully");
931 println!(" Input: {input}");
932 println!(" Output: {output}");
933 println!(" Types: {}", schema.types.len());
934 println!(" Queries: {}", schema.queries.len());
935 println!(" Mutations: {}", schema.mutations.len());
936 optimization_report.print();
937 print!("{}", localized_index_report_text(schema));
938
939 if let Some(ddl_dir) = emit_ddl {
941 emit_ddl_to_dir(schema, ddl_dir)?;
942 }
943
944 if check_migrations {
946 run_check_migrations(schema)?;
947 }
948
949 Ok(())
950}
951
952#[must_use]
960pub fn localized_index_report_text(schema: &fraiseql_core::schema::CompiledSchema) -> String {
961 use std::fmt::Write as _;
962 let report = schema.localized_index_report();
963 if report.is_empty() {
964 return String::new();
965 }
966 let mut text = String::from(
967 "\nLocalized field indexes (one per field and allowed locale; a filter or sort on the \
968 field reads it):\n",
969 );
970 for advice in &report {
971 let subject = format!(
972 "{}.{} [{}] on {}",
973 advice.type_name, advice.field, advice.locale, advice.table
974 );
975 let _ = match &advice.index {
977 Some(index) => writeln!(text, " {subject}:\n {}", index.ddl),
978 None => writeln!(
979 text,
980 " {subject}: a view; create the index on its base table, on the expression the \
981 query reads"
982 ),
983 };
984 }
985 text
986}
987
988pub const EMIT_DDL_FORMAT: u32 = 1;
991
992pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
1008 let mut files: std::collections::BTreeMap<String, (&str, String)> =
1010 std::collections::BTreeMap::new();
1011 for type_def in &schema.types {
1012 let table_name = to_snake_case(type_def.name.as_str());
1013 let ddl = build_create_table_ddl(&table_name, type_def)?;
1014 if let Some((other, _)) = files.get(&table_name) {
1015 let (first, second) = if *other < type_def.name.as_str() {
1016 (*other, type_def.name.as_str())
1017 } else {
1018 (type_def.name.as_str(), *other)
1019 };
1020 anyhow::bail!(
1021 "--emit-ddl: types '{first}' and '{second}' both map to the table \
1022 `tb_{table_name}` and the file `{table_name}.sql`; rename one of them"
1023 );
1024 }
1025 files.insert(table_name, (type_def.name.as_str(), ddl));
1026 }
1027
1028 fs::create_dir_all(output_dir)
1029 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
1030 for (table_name, (type_name, ddl)) in &files {
1031 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
1032 fs::write(&file_path, ddl)
1033 .context(format!("Failed to write DDL for type '{type_name}'"))?;
1034 }
1035
1036 println!("✓ DDL emitted to {output_dir}/ ({} table(s))", files.len());
1037 Ok(())
1038}
1039
1040fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
1050 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
1051 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
1052
1053 emit_ddl_to_dir(schema, tmp_path)?;
1054
1055 info!("Running confiture migrate validate for drift detection...");
1056
1057 let status = Command::new("confiture").args(["migrate", "validate"]).status();
1058
1059 match status {
1060 Err(_) => {
1061 eprintln!(
1063 "WARN: confiture is not installed; skipping migration drift check.\n\
1064 Install it with: {}",
1065 super::migrate::CONFITURE_INSTALL_COMMANDS.join(" (or) ")
1066 );
1067 Ok(())
1068 },
1069 Ok(s) if s.success() => {
1070 println!("✓ No migration drift detected.");
1071 Ok(())
1072 },
1073 Ok(_) => {
1074 eprintln!(
1075 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
1076 );
1077 anyhow::bail!(
1078 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
1079 )
1080 },
1081 }
1082}
1083
1084pub(crate) fn to_snake_case(name: &str) -> String {
1092 fraiseql_core::utils::to_snake_case(name)
1093}
1094
1095fn build_create_table_ddl(
1102 table_name: &str,
1103 type_def: &fraiseql_core::schema::TypeDefinition,
1104) -> Result<String> {
1105 let mut lines: Vec<String> = Vec::new();
1106 lines.push(format!("-- fraiseql emit-ddl format {EMIT_DDL_FORMAT}"));
1107 lines.push(format!("-- Generated by fraiseql-cli {}", env!("CARGO_PKG_VERSION")));
1108 lines.push(format!("-- Type: {}", type_def.name));
1109 if let Some(desc) = &type_def.description {
1110 lines.push(format!("-- {desc}"));
1111 }
1112 lines.push(String::new());
1113 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
1114
1115 let col_lines: Vec<String> = type_def
1116 .fields
1117 .iter()
1118 .map(|field| {
1119 let col_name = to_snake_case(field.name.as_str());
1120 let pg_type =
1125 if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
1126 field.field_type.to_sql_type(field.vector_config.as_ref())
1127 } else {
1128 field_type_to_pg(&field.field_type)
1129 };
1130 let nullable = if field.nullable { "" } else { " NOT NULL" };
1131 format!(" {col_name} {pg_type}{nullable}")
1132 })
1133 .collect();
1134
1135 let last = col_lines.len().saturating_sub(1);
1136 for (i, col) in col_lines.iter().enumerate() {
1137 if i < last {
1138 lines.push(format!("{col},"));
1139 } else {
1140 lines.push(col.clone());
1141 }
1142 }
1143
1144 lines.push(");".to_string());
1145
1146 let vector_fields: Vec<_> = type_def
1150 .fields
1151 .iter()
1152 .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
1153 .collect();
1154 if !vector_fields.is_empty() {
1155 lines.push(String::new());
1156 lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
1157 for (field, config) in vector_fields {
1158 let col_name = to_snake_case(field.name.as_str());
1159 let index_ddl = config
1160 .index_type
1161 .index_sql(
1162 &format!("tb_{table_name}"),
1163 &col_name,
1164 &field.field_type,
1165 config.distance_metric,
1166 )
1167 .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
1168 if let Some(index_ddl) = index_ddl {
1169 lines.push(format!("{index_ddl};"));
1170 }
1171 }
1172 }
1173
1174 lines.push(String::new());
1175 Ok(lines.join("\n"))
1176}
1177
1178pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
1180 match ft {
1181 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
1182 FieldType::Int => "INTEGER".to_string(),
1183 FieldType::Float => "DOUBLE PRECISION".to_string(),
1184 FieldType::Boolean => "BOOLEAN".to_string(),
1185 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
1186 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
1187 FieldType::Date => "DATE".to_string(),
1188 FieldType::Time => "TIME".to_string(),
1189 FieldType::Json | FieldType::List { item: _, .. } | FieldType::Object(_) => {
1190 "JSONB".to_string()
1191 },
1192 FieldType::Decimal => "NUMERIC".to_string(),
1193 FieldType::Vector => "VECTOR".to_string(),
1194 FieldType::BitVector => "VARBIT".to_string(),
1199 FieldType::HalfVector => "HALFVEC".to_string(),
1203 FieldType::SparseVector => "SPARSEVEC".to_string(),
1204 FieldType::Enum(name) => name.clone(),
1206 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
1207 _ => "TEXT".to_string(),
1209 }
1210}
1211
1212pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1215
1216pub(crate) fn wide_cascade_mutations(
1222 schema: &CompiledSchema,
1223 threshold: usize,
1224) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1225 schema
1226 .mutations
1227 .iter()
1228 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1229 .collect()
1230}
1231
1232fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1245 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1246 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1247
1248 let mut targets: Vec<&str> = mutation
1250 .invalidates_views
1251 .iter()
1252 .chain(mutation.invalidates_fact_tables.iter())
1253 .map(String::as_str)
1254 .collect();
1255 targets.sort_unstable();
1256 targets.dedup();
1257
1258 let alter_stmts: Vec<String> = targets
1261 .iter()
1262 .map(|&name| {
1263 let table = name
1264 .strip_prefix("tv_")
1265 .or_else(|| name.strip_prefix("v_"))
1266 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1267 format!("ALTER TABLE {table} SET (fillfactor = 75);")
1268 })
1269 .collect();
1270
1271 warn!(
1272 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1273 Under high write load, HOT-update page slots on these tables may be \
1274 exhausted, forcing full-page writes and reducing mutation throughput. \
1275 Set fillfactor=70-80 on the backing tables: {} \
1276 Monitor HOT efficiency: SELECT relname, \
1277 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1278 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1279 mutation.name,
1280 total,
1281 targets.join(", "),
1282 alter_stmts.join(" "),
1283 );
1284 }
1285}
1286
1287pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1300 if schema.naming_convention != NamingConvention::Preserve {
1301 return Vec::new();
1302 }
1303 let mut out = Vec::new();
1304 for mutation in &schema.mutations {
1305 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1308 || matches!(mutation.operation, MutationOperation::Update { .. });
1309 if !single_jsonb {
1310 continue;
1311 }
1312 let input_type_name = match mutation.arguments.as_slice() {
1319 [arg] if arg.name == "input" => match &arg.arg_type {
1320 FieldType::Input(name) => name.as_str(),
1321 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1322 _ => continue,
1323 },
1324 _ => continue,
1325 };
1326 let Some(input_type) = schema.find_input_type(input_type_name) else {
1327 continue;
1328 };
1329 let camel_fields: Vec<String> = input_type
1332 .fields
1333 .iter()
1334 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1335 .map(|f| f.name.clone())
1336 .collect();
1337 if !camel_fields.is_empty() {
1338 out.push((mutation.name.clone(), camel_fields));
1339 }
1340 }
1341 out
1342}
1343
1344fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1348 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1349 warn!(
1350 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1351 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1352 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1353 recasing), so a SQL function reading payload->>'snake_field' will receive these \
1354 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1355 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1356 fields.join(", "),
1357 );
1358 }
1359}
1360
1361pub(crate) async fn build_postgres_introspector(
1368 db_url: &str,
1369 tls: &PostgresTlsConfig,
1370) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1371 let pool = crate::connection::postgres_pool(db_url, "database validation", tls).await?;
1372 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1373}
1374
1375const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1377 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1378];
1379
1380pub(crate) fn apply_database_report(
1392 schema: &mut fraiseql_core::schema::CompiledSchema,
1393 report: &crate::schema::database_validator::DatabaseValidationReport,
1394) {
1395 for query in &mut schema.queries {
1396 if let Some(cols) = report.native_columns.get(&query.name) {
1397 query.native_columns = cols.clone();
1398 }
1399 if let Some(order) = report.pagination_orders.get(&query.name) {
1400 query.pagination_order = Some(order.clone());
1401 }
1402 }
1403}
1404
1405pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1417 for query in &mut schema.queries {
1418 if query.sql_source.is_none()
1420 || query.jsonb_column.is_empty()
1421 || !query.sql_source_kind.is_relation()
1422 {
1423 continue;
1424 }
1425 for arg in &query.arguments {
1426 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1427 continue;
1428 }
1429 if query.native_columns.contains_key(&arg.name) {
1430 continue; }
1432 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1433 query.native_columns.insert(
1435 arg.name.clone(),
1436 fraiseql_core::schema::NativeColumn::nullable("uuid"),
1437 );
1438 }
1439 }
1440 }
1441}