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 if !matches!(
298 config.fraiseql.tenancy.mode,
299 crate::config::security::TenancyModeConfig::None
300 ) {
301 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
302 }
303
304 intermediate.security = Some(security_json);
305
306 if config.fraiseql.session_variables
310 != fraiseql_core::schema::SessionVariablesConfig::default()
311 {
312 intermediate.session_variables = Some(config.fraiseql.session_variables);
313 }
314
315 intermediate.inject_defaults =
318 crate::config::inject_defaults::InjectDefaultsToml::reconcile(
319 config.inject_defaults.as_ref(),
320 intermediate.inject_defaults.take(),
321 )?;
322
323 info!("Security configuration applied successfully");
324 },
325 Err(e) => {
326 anyhow::bail!(
327 "Failed to parse {}: {e}\n\
328 Fix the configuration file or remove it to use defaults.",
329 config_path.display()
330 );
331 },
332 }
333 }
334
335 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
339
340 if !is_toml {
346 intermediate.naming_convention = naming_convention;
347 }
348
349 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
352 let tenancy = sec.get("tenancy")?;
353 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
354 if mode == "row" {
355 Some(
360 tenancy
361 .get("tenant_claim")
362 .and_then(|c| c.as_str())
363 .unwrap_or("tenant_id")
364 .to_string(),
365 )
366 } else {
367 None
368 }
369 });
370 if let Some(tenant_claim) = &tenancy_row_claim {
371 info!("Validating @tenant_id annotations for row-isolation tenancy...");
372 crate::schema::converter::tenancy::validate_tenant_annotations(
373 &mut intermediate,
374 tenant_claim,
375 )
376 .context("@tenant_id validation failed")?;
377 }
378
379 info!("Validating schema structure...");
381 let validation_report =
382 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
383
384 if !validation_report.is_valid() {
385 validation_report.print();
386 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
387 }
388
389 if validation_report.warning_count() > 0 {
391 validation_report.print();
392 }
393
394 let mutation_defaults =
402 intermediate.inject_defaults.clone().unwrap_or_default().for_mutations();
403 let default_args: std::collections::HashMap<String, Vec<String>> = intermediate
404 .mutations
405 .iter()
406 .filter_map(|m| {
407 let mut inject = m.inject.clone();
408 crate::schema::intermediate::IntermediateInjectDefaults::apply_to(
409 &mutation_defaults,
410 &mut inject,
411 &m.exclude_inject_defaults,
412 &m.name,
413 )
414 .ok()
415 .filter(|added| !added.is_empty())
416 .map(|added| (m.name.clone(), added))
417 })
418 .collect();
419
420 info!("Converting to compiled format...");
421 let CompiledArtifact {
422 mut schema,
423 functions,
424 } = SchemaConverter::convert_artifact(intermediate, &ConvertOptions { auto_error_union })
425 .context("Failed to convert schema to compiled format")?;
426
427 schema.naming_acronyms = naming_acronyms;
430
431 if !operation_cost_weights.is_empty() {
435 schema.operation_cost_weights = operation_cost_weights;
436 }
437
438 info!("Analyzing schema for optimization opportunities...");
440 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
441
442 infer_native_columns_from_arg_types(&mut schema);
445
446 if let Some(db_url) = opts.database {
448 info!("Validating native columns for direct query arguments...");
449 let pool = crate::connection::postgres_pool(db_url, "database validation", &database_tls)
451 .await
452 .context("Failed to connect for database validation")?;
453 let pg_introspector = fraiseql_core::db::postgres::PostgresIntrospector::new(pool.clone());
454 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
455
456 let mut drift_errors: Vec<String> = Vec::new();
459 for w in &db_report.warnings {
460 match w.severity() {
461 Severity::Error => {
462 warn!("{w} [drift error]");
463 drift_errors.push(w.to_string());
464 },
465 Severity::Warn => warn!("{w}"),
466 }
467 }
468 apply_database_report(&mut schema, &db_report);
469
470 info!("Validating mutation contract against the database...");
475 let catalog = PgCatalog::from_pool(pool);
476 let contract = validate_mutation_contract(&schema, &catalog).await?;
477 let mut caused_by_default: std::collections::BTreeMap<String, Vec<String>> =
478 std::collections::BTreeMap::new();
479 for m in &contract.mutations {
480 let added = default_args.get(&m.mutation).map_or(&[][..], Vec::as_slice);
481 for v in &m.violations {
482 let kind = match v.severity() {
483 Severity::Error => "contract error",
484 Severity::Warn => "contract warning",
485 };
486 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
487 if v.severity() != Severity::Error {
488 continue;
489 }
490 if let Some(key) = default_caused(v, added) {
491 caused_by_default.entry(key).or_default().push(m.mutation.clone());
492 } else {
493 drift_errors.push(format!(
494 "mutation `{}` (sql_source: {}): {v}",
495 m.mutation, m.sql_source
496 ));
497 }
498 }
499 }
500 for (key, mutations) in caused_by_default {
501 drift_errors.push(default_collision(&key, &mutations));
502 }
503
504 if !drift_errors.is_empty() {
509 if opts.allow_drift {
510 warn!(
511 "{} schema↔database drift error(s) suppressed by --allow-drift",
512 drift_errors.len()
513 );
514 } else {
515 let list =
516 drift_errors.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n");
517 anyhow::bail!(
518 "schema↔database drift: {} error(s):\n{list}\n\
519 Fix the declarations or the database objects, or pass --allow-drift \
520 to compile anyway (advisory mode).",
521 drift_errors.len()
522 );
523 }
524 }
525 } else {
526 for query in &schema.queries {
529 if query.sql_source.is_none() {
530 continue;
531 }
532 let unresolved: Vec<_> = query
533 .arguments
534 .iter()
535 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
536 .filter(|a| !query.native_columns.contains_key(&a.name))
537 .collect();
538 if !unresolved.is_empty() {
539 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
540 warn!(
541 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
542 columns — no --database URL provided. These filters will use JSONB \
543 extraction. Provide --database or annotate with native_columns.",
544 query.name,
545 names,
546 query.sql_source.as_deref().unwrap_or("?"),
547 );
548 }
549
550 if query.pagination_order == Some(fraiseql_core::schema::PaginationOrder::JsonIdentity)
555 {
556 warn!(
557 "query `{}`: offset pages will be ordered by `data->>'id'` — no --database \
558 URL provided, so a cheaper unique column on `{}` could not be found. \
559 Provide --database to use `pk_{}` or a native `id` column instead, or \
560 declare pagination_order.",
561 query.name,
562 query.sql_source.as_deref().unwrap_or("?"),
563 fraiseql_core::utils::to_snake_case(&query.return_type),
564 );
565 }
566 }
567 }
568
569 warn_wide_cascade_mutations(&schema);
571
572 warn_jsonb_preserve_mismatch(&schema);
575
576 refuse_unattributable_mutations(&schema)?;
578
579 refuse_unenforced_authz_declarations(&schema)?;
581
582 refuse_what_a_server_would_not_load(&schema)?;
584
585 Ok((CompiledArtifact { schema, functions }, report))
586}
587
588pub(crate) fn default_caused(
608 violation: &crate::schema::mutation_contract::ContractViolation,
609 added: &[String],
610) -> Option<String> {
611 use crate::schema::mutation_contract::ContractViolation;
612 match violation {
613 ContractViolation::ArityMismatch { expected, found }
614 if !added.is_empty() && found.contains(&expected.saturating_sub(added.len())) =>
615 {
616 Some(added.join(", "))
617 },
618 ContractViolation::InjectNameMismatch { expected, .. } if added.contains(expected) => {
619 Some(expected.clone())
620 },
621 _ => None,
622 }
623}
624
625pub(crate) fn default_collision(key: &str, mutations: &[String]) -> String {
627 const SHOWN: usize = 5;
628 let shown = mutations.iter().take(SHOWN).map(|m| format!("`{m}`")).collect::<Vec<_>>();
629 let more = mutations.len().saturating_sub(SHOWN);
630 let list = if more == 0 {
631 shown.join(", ")
632 } else {
633 format!("{}, and {more} more", shown.join(", "))
634 };
635 format!(
636 "[inject_defaults] adds `{key}` to {} mutation(s) whose functions do not take it: \
637 {list}. Exclude it on those mutations (`exclude_inject_defaults = [\"{key}\"]`), or \
638 move it from the base [inject_defaults] table to [inject_defaults.queries].",
639 mutations.len()
640 )
641}
642
643fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
644 let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
645 let mut value: serde_json::Value = serde_json::from_str(&body)?;
646 let hash = content_hash_of(&value);
647 value
648 .as_object_mut()
649 .context("schema must serialise as JSON object")?
650 .insert("_content_hash".to_string(), serde_json::Value::String(hash));
651 CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
652 anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
653 })?;
654 Ok(())
655}
656
657fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
675 let Some(security) = schema.security.as_ref() else {
676 return Ok(());
677 };
678
679 let mut declared: Vec<String> = Vec::new();
680 if !security.rules.is_empty() {
681 declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
682 }
683 if !security.field_auth.is_empty() {
684 declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
685 }
686 if let Some(policy) = security.default_policy.as_deref() {
687 declared.push(format!("`security.default_policy` (\"{policy}\")"));
688 }
689 if declared.is_empty() {
690 return Ok(());
691 }
692
693 anyhow::bail!(
694 "authorization declaration(s) with no enforcer: {}.\n\n\
695 FraiseQL enforces field-level authorization through `security.role_definitions` \
696 (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
697 rule written here is not applied to any request. Remove them, or express the same \
698 intent with role definitions and `requires_scope`.\n\n\
699 (A general authorization-rule engine is tracked separately; until it exists, a \
700 declaration that silently enforces nothing is refused rather than accepted.)",
701 declared.join(", ")
702 )
703}
704
705fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
729 if !fraiseql_core::cache::declares_cacheable_views(schema) {
730 return Ok(());
731 }
732 let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
733 if unattributable.is_empty() {
734 return Ok(());
735 }
736
737 let cacheable: Vec<&str> = schema
738 .queries
739 .iter()
740 .filter(|q| q.cache_ttl_seconds.is_some())
741 .filter_map(|q| q.sql_source.as_deref())
742 .collect();
743 let list = unattributable.iter().map(|m| format!(" - {m}")).collect::<Vec<_>>().join("\n");
744 anyhow::bail!(
745 "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
746 This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
747 view invalidates nothing when it succeeds — permanently, for any view annotated \
748 `cache_ttl_seconds = 0`.\n\n\
749 Fix by declaring what each mutation writes:\n\
750 \x20 invalidates_views = [\"v_price\"]\n\n\
751 A mutation whose return type is backed by a view, or whose payload wraps an entity \
752 that is, already resolves and needs no annotation.",
753 cacheable.len(),
754 cacheable.join(", "),
755 )
756}
757
758#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
788#[doc(hidden)] pub async fn run(
792 input: &str,
793 types: Option<&str>,
794 schema_dir: Option<&str>,
795 type_files: Vec<String>,
796 query_files: Vec<String>,
797 mutation_files: Vec<String>,
798 output: &str,
799 check: bool,
800 database: Option<&str>,
801 emit_ddl: Option<&str>,
802 check_migrations: bool,
803 skip_hash: bool,
804 allow_drift: bool,
805 config: Option<&str>,
806) -> Result<()> {
807 if !check && Path::new(output) == Path::new(input) {
812 anyhow::bail!(
813 "Refusing to write compiled output over the input file '{input}'. \
814 Use a distinct --output path (e.g. schema.compiled.json)."
815 );
816 }
817
818 let config = ConfigSource::resolve(Path::new(input), config.map(Path::new))?;
821 println!("Config: {config}");
822
823 let opts = CompileOptions {
824 input,
825 types,
826 schema_dir,
827 type_files,
828 query_files,
829 mutation_files,
830 database,
831 skip_hash,
832 allow_drift,
833 config: Some(config),
834 };
835 let (artifact, optimization_report) = compile_to_schema(opts).await?;
836 let schema = &artifact.schema;
837
838 if check {
840 println!("✓ Schema is valid");
841 println!(" Types: {}", schema.types.len());
842 println!(" Queries: {}", schema.queries.len());
843 println!(" Mutations: {}", schema.mutations.len());
844 optimization_report.print();
845 return Ok(());
846 }
847
848 info!("Writing compiled schema to: {output}");
850 let value = artifact.to_json_value()?;
853
854 let output_json = if skip_hash {
855 serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
856 } else {
857 let hash_hex = content_hash_of(&value);
860
861 let obj = value.as_object().context("schema must serialise as JSON object")?;
862
863 let mut new_obj = serde_json::Map::new();
865 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
866 for (k, v) in obj {
867 new_obj.insert(k.clone(), v.clone());
868 }
869 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
870 };
871
872 fs::write(output, output_json).context("Failed to write compiled schema")?;
873
874 println!("✓ Schema compiled successfully");
876 println!(" Input: {input}");
877 println!(" Output: {output}");
878 println!(" Types: {}", schema.types.len());
879 println!(" Queries: {}", schema.queries.len());
880 println!(" Mutations: {}", schema.mutations.len());
881 optimization_report.print();
882
883 if let Some(ddl_dir) = emit_ddl {
885 emit_ddl_to_dir(schema, ddl_dir)?;
886 }
887
888 if check_migrations {
890 run_check_migrations(schema)?;
891 }
892
893 Ok(())
894}
895
896pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
910 fs::create_dir_all(output_dir)
911 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
912
913 let mut count = 0;
914 for type_def in &schema.types {
915 let table_name = to_snake_case(type_def.name.as_str());
916 let ddl = build_create_table_ddl(&table_name, type_def)?;
917
918 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
919 fs::write(&file_path, ddl)
920 .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
921 count += 1;
922 }
923
924 println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
925 Ok(())
926}
927
928fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
938 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
939 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
940
941 emit_ddl_to_dir(schema, tmp_path)?;
942
943 info!("Running confiture migrate validate for drift detection...");
944
945 let status = Command::new("confiture").args(["migrate", "validate"]).status();
946
947 match status {
948 Err(_) => {
949 eprintln!(
951 "WARN: confiture is not installed; skipping migration drift check.\n\
952 Install it with: cargo install confiture"
953 );
954 Ok(())
955 },
956 Ok(s) if s.success() => {
957 println!("✓ No migration drift detected.");
958 Ok(())
959 },
960 Ok(_) => {
961 eprintln!(
962 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
963 );
964 anyhow::bail!(
965 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
966 )
967 },
968 }
969}
970
971pub(crate) fn to_snake_case(name: &str) -> String {
979 fraiseql_core::utils::to_snake_case(name)
980}
981
982fn build_create_table_ddl(
989 table_name: &str,
990 type_def: &fraiseql_core::schema::TypeDefinition,
991) -> Result<String> {
992 let mut lines: Vec<String> = Vec::new();
993 lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
994 lines.push(format!("-- Type: {}", type_def.name));
995 if let Some(desc) = &type_def.description {
996 lines.push(format!("-- {desc}"));
997 }
998 lines.push(String::new());
999 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
1000
1001 let col_lines: Vec<String> = type_def
1002 .fields
1003 .iter()
1004 .map(|field| {
1005 let col_name = to_snake_case(field.name.as_str());
1006 let pg_type =
1011 if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
1012 field.field_type.to_sql_type(field.vector_config.as_ref())
1013 } else {
1014 field_type_to_pg(&field.field_type)
1015 };
1016 let nullable = if field.nullable { "" } else { " NOT NULL" };
1017 format!(" {col_name} {pg_type}{nullable}")
1018 })
1019 .collect();
1020
1021 let last = col_lines.len().saturating_sub(1);
1022 for (i, col) in col_lines.iter().enumerate() {
1023 if i < last {
1024 lines.push(format!("{col},"));
1025 } else {
1026 lines.push(col.clone());
1027 }
1028 }
1029
1030 lines.push(");".to_string());
1031
1032 let vector_fields: Vec<_> = type_def
1036 .fields
1037 .iter()
1038 .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
1039 .collect();
1040 if !vector_fields.is_empty() {
1041 lines.push(String::new());
1042 lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
1043 for (field, config) in vector_fields {
1044 let col_name = to_snake_case(field.name.as_str());
1045 let index_ddl = config
1046 .index_type
1047 .index_sql(
1048 &format!("tb_{table_name}"),
1049 &col_name,
1050 &field.field_type,
1051 config.distance_metric,
1052 )
1053 .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
1054 if let Some(index_ddl) = index_ddl {
1055 lines.push(format!("{index_ddl};"));
1056 }
1057 }
1058 }
1059
1060 lines.push(String::new());
1061 Ok(lines.join("\n"))
1062}
1063
1064pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
1066 match ft {
1067 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
1068 FieldType::Int => "INTEGER".to_string(),
1069 FieldType::Float => "DOUBLE PRECISION".to_string(),
1070 FieldType::Boolean => "BOOLEAN".to_string(),
1071 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
1072 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
1073 FieldType::Date => "DATE".to_string(),
1074 FieldType::Time => "TIME".to_string(),
1075 FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
1076 FieldType::Decimal => "NUMERIC".to_string(),
1077 FieldType::Vector => "VECTOR".to_string(),
1078 FieldType::BitVector => "VARBIT".to_string(),
1083 FieldType::HalfVector => "HALFVEC".to_string(),
1087 FieldType::SparseVector => "SPARSEVEC".to_string(),
1088 FieldType::Enum(name) => name.clone(),
1090 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
1091 _ => "TEXT".to_string(),
1093 }
1094}
1095
1096pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1099
1100pub(crate) fn wide_cascade_mutations(
1106 schema: &CompiledSchema,
1107 threshold: usize,
1108) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1109 schema
1110 .mutations
1111 .iter()
1112 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1113 .collect()
1114}
1115
1116fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1129 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1130 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1131
1132 let mut targets: Vec<&str> = mutation
1134 .invalidates_views
1135 .iter()
1136 .chain(mutation.invalidates_fact_tables.iter())
1137 .map(String::as_str)
1138 .collect();
1139 targets.sort_unstable();
1140 targets.dedup();
1141
1142 let alter_stmts: Vec<String> = targets
1145 .iter()
1146 .map(|&name| {
1147 let table = name
1148 .strip_prefix("tv_")
1149 .or_else(|| name.strip_prefix("v_"))
1150 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1151 format!("ALTER TABLE {table} SET (fillfactor = 75);")
1152 })
1153 .collect();
1154
1155 warn!(
1156 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1157 Under high write load, HOT-update page slots on these tables may be \
1158 exhausted, forcing full-page writes and reducing mutation throughput. \
1159 Set fillfactor=70-80 on the backing tables: {} \
1160 Monitor HOT efficiency: SELECT relname, \
1161 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1162 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1163 mutation.name,
1164 total,
1165 targets.join(", "),
1166 alter_stmts.join(" "),
1167 );
1168 }
1169}
1170
1171pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1184 if schema.naming_convention != NamingConvention::Preserve {
1185 return Vec::new();
1186 }
1187 let mut out = Vec::new();
1188 for mutation in &schema.mutations {
1189 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1192 || matches!(mutation.operation, MutationOperation::Update { .. });
1193 if !single_jsonb {
1194 continue;
1195 }
1196 let input_type_name = match mutation.arguments.as_slice() {
1203 [arg] if arg.name == "input" => match &arg.arg_type {
1204 FieldType::Input(name) => name.as_str(),
1205 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1206 _ => continue,
1207 },
1208 _ => continue,
1209 };
1210 let Some(input_type) = schema.find_input_type(input_type_name) else {
1211 continue;
1212 };
1213 let camel_fields: Vec<String> = input_type
1216 .fields
1217 .iter()
1218 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1219 .map(|f| f.name.clone())
1220 .collect();
1221 if !camel_fields.is_empty() {
1222 out.push((mutation.name.clone(), camel_fields));
1223 }
1224 }
1225 out
1226}
1227
1228fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1232 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1233 warn!(
1234 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1235 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1236 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1237 recasing), so a SQL function reading payload->>'snake_field' will receive these \
1238 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1239 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1240 fields.join(", "),
1241 );
1242 }
1243}
1244
1245pub(crate) async fn build_postgres_introspector(
1252 db_url: &str,
1253 tls: &PostgresTlsConfig,
1254) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1255 let pool = crate::connection::postgres_pool(db_url, "database validation", tls).await?;
1256 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1257}
1258
1259const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1261 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1262];
1263
1264pub(crate) fn apply_database_report(
1276 schema: &mut fraiseql_core::schema::CompiledSchema,
1277 report: &crate::schema::database_validator::DatabaseValidationReport,
1278) {
1279 for query in &mut schema.queries {
1280 if let Some(cols) = report.native_columns.get(&query.name) {
1281 query.native_columns = cols.clone();
1282 }
1283 if let Some(order) = report.pagination_orders.get(&query.name) {
1284 query.pagination_order = Some(order.clone());
1285 }
1286 }
1287}
1288
1289pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1301 for query in &mut schema.queries {
1302 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
1303 continue;
1304 }
1305 for arg in &query.arguments {
1306 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1307 continue;
1308 }
1309 if query.native_columns.contains_key(&arg.name) {
1310 continue; }
1312 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1313 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
1314 }
1315 }
1316 }
1317}