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 tracing::{info, warn};
12
13use crate::{
14 config::TomlProjectConfig,
15 schema::{
16 CompiledArtifact, ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter,
17 SchemaOptimizer, SchemaValidator,
18 database_validator::validate_schema_against_database,
19 mutation_contract::{Severity, validate_mutation_contract},
20 pg_catalog::PgCatalog,
21 },
22};
23
24#[derive(Debug, Default)]
26pub struct CompileOptions<'a> {
27 pub input: &'a str,
29 pub types: Option<&'a str>,
31 pub schema_dir: Option<&'a str>,
33 pub type_files: Vec<String>,
35 pub query_files: Vec<String>,
37 pub mutation_files: Vec<String>,
39 pub database: Option<&'a str>,
41 pub skip_hash: bool,
43 pub allow_drift: bool,
46}
47
48impl<'a> CompileOptions<'a> {
49 #[must_use]
51 pub fn new(input: &'a str) -> Self {
52 Self {
53 input,
54 ..Default::default()
55 }
56 }
57
58 #[must_use]
60 pub fn with_types(mut self, types: &'a str) -> Self {
61 self.types = Some(types);
62 self
63 }
64
65 #[must_use]
67 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
68 self.schema_dir = Some(schema_dir);
69 self
70 }
71
72 #[must_use]
74 pub fn with_database(mut self, database: &'a str) -> Self {
75 self.database = Some(database);
76 self
77 }
78}
79
80#[allow(clippy::cognitive_complexity)] pub fn load_intermediate_schema(
95 toml_path: &str,
96 type_files: &[String],
97 query_files: &[String],
98 mutation_files: &[String],
99 schema_dir: Option<&str>,
100 types_path: Option<&str>,
101) -> Result<IntermediateSchema> {
102 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
103 info!("Mode: Explicit file lists");
104 return crate::schema::SchemaMerger::merge_explicit_files(
105 toml_path,
106 type_files,
107 query_files,
108 mutation_files,
109 )
110 .context("Failed to load explicit schema files");
111 }
112 if let Some(dir) = schema_dir {
113 info!("Mode: Auto-discovery from directory: {}", dir);
114 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
115 .context("Failed to load schema from directory");
116 }
117 if let Some(types) = types_path {
118 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
119 return crate::schema::SchemaMerger::merge_files(types, toml_path)
120 .context("Failed to merge types.json with TOML");
121 }
122 let toml_schema = crate::config::TomlSchema::from_file(toml_path)
134 .context(format!("Failed to load TOML from {toml_path}"))?;
135
136 if toml_schema.domain_discovery.enabled {
137 info!("Mode: TOML-based with domain discovery");
138 return crate::schema::SchemaMerger::merge_from_domains(toml_path).context(
139 "Failed to load schema from the configured [domain_discovery] section. Fix the \
140 error above, or remove the section to compile from TOML definitions only.",
141 );
142 }
143
144 if !toml_schema.includes.is_empty() {
145 info!("Mode: TOML-based with schema includes");
146 return crate::schema::SchemaMerger::merge_with_includes(toml_path).context(
147 "Failed to load schema from the configured [includes] section. Fix the error \
148 above, or remove the section to compile from TOML definitions only.",
149 );
150 }
151
152 info!("No domains or includes configured, using TOML-only definitions");
153 crate::schema::SchemaMerger::merge_toml_only(toml_path)
154 .context("Failed to load schema from TOML")
155}
156
157#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
172 opts: CompileOptions<'_>,
173) -> Result<(CompiledArtifact, OptimizationReport)> {
174 info!("Compiling schema: {}", opts.input);
175
176 let input_path = Path::new(opts.input);
178 if !input_path.exists() {
179 anyhow::bail!("Input file not found: {}", opts.input);
180 }
181
182 let is_toml = input_path
184 .extension()
185 .and_then(|ext| ext.to_str())
186 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
187 let mut intermediate: IntermediateSchema = if is_toml {
188 info!("Using TOML-based workflow");
189 load_intermediate_schema(
190 opts.input,
191 &opts.type_files,
192 &opts.query_files,
193 &opts.mutation_files,
194 opts.schema_dir,
195 opts.types,
196 )?
197 } else {
198 info!("Using legacy JSON workflow");
200 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
201
202 info!("Parsing intermediate schema...");
207 let raw: serde_json::Value =
208 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?;
209
210 crate::schema::intermediate::reject_drifted_keys(&raw)
215 .map_err(|e| anyhow::anyhow!("{e}"))?;
216
217 let input_has_federation = ["federation", "federation_config"].iter().any(|key| {
218 raw.get(*key)
219 .and_then(serde_json::Value::as_object)
220 .is_some_and(|o| !o.is_empty())
221 });
222 let intermediate: IntermediateSchema =
223 serde_json::from_value(raw).context("Failed to parse schema.json")?;
224 if input_has_federation && intermediate.federation_config.is_none() {
225 anyhow::bail!(
226 "schema.json carries a `federation` block that did not bind into the compiled \
227 schema — refusing to compile a silently non-federated subgraph. This is a \
228 compiler bug; please report it."
229 );
230 }
231 intermediate
232 };
233
234 let mut auto_error_union = false;
240 let mut naming_acronyms: Vec<String> = Vec::new();
242 let mut operation_cost_weights: std::collections::HashMap<String, usize> =
244 std::collections::HashMap::new();
245 let mut naming_convention = NamingConvention::CamelCase;
251 if !is_toml && Path::new("fraiseql.toml").exists() {
252 info!("Loading security configuration from fraiseql.toml...");
253 match TomlProjectConfig::from_file("fraiseql.toml") {
254 Ok(config) => {
255 info!("Validating security configuration...");
256 config.validate()?;
257
258 auto_error_union = config.fraiseql.mutations.auto_error_union;
259 naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
260 operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
261 naming_convention = config.fraiseql.naming.convention;
262
263 info!("Applying security configuration to schema...");
264 let mut security_json = config.fraiseql.security.to_json();
266
267 if !matches!(
269 config.fraiseql.tenancy.mode,
270 crate::config::security::TenancyModeConfig::None
271 ) {
272 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
273 }
274
275 intermediate.security = Some(security_json);
276
277 if config.fraiseql.session_variables
281 != fraiseql_core::schema::SessionVariablesConfig::default()
282 {
283 intermediate.session_variables = Some(config.fraiseql.session_variables);
284 }
285
286 info!("Security configuration applied successfully");
287 },
288 Err(e) => {
289 anyhow::bail!(
290 "Failed to parse fraiseql.toml: {e}\n\
291 Fix the configuration file or remove it to use defaults."
292 );
293 },
294 }
295 } else {
296 info!("No fraiseql.toml found, using default security configuration");
297 }
298
299 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
303
304 if !is_toml {
310 intermediate.naming_convention = naming_convention;
311 }
312
313 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
316 let tenancy = sec.get("tenancy")?;
317 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
318 if mode == "row" {
319 Some(
324 tenancy
325 .get("tenant_claim")
326 .and_then(|c| c.as_str())
327 .unwrap_or("tenant_id")
328 .to_string(),
329 )
330 } else {
331 None
332 }
333 });
334 if let Some(tenant_claim) = &tenancy_row_claim {
335 info!("Validating @tenant_id annotations for row-isolation tenancy...");
336 crate::schema::converter::tenancy::validate_tenant_annotations(
337 &mut intermediate,
338 tenant_claim,
339 )
340 .context("@tenant_id validation failed")?;
341 }
342
343 info!("Validating schema structure...");
345 let validation_report =
346 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
347
348 if !validation_report.is_valid() {
349 validation_report.print();
350 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
351 }
352
353 if validation_report.warning_count() > 0 {
355 validation_report.print();
356 }
357
358 info!("Converting to compiled format...");
362 let CompiledArtifact {
363 mut schema,
364 functions,
365 } = SchemaConverter::convert_artifact(intermediate, &ConvertOptions { auto_error_union })
366 .context("Failed to convert schema to compiled format")?;
367
368 schema.naming_acronyms = naming_acronyms;
371
372 if !operation_cost_weights.is_empty() {
376 schema.operation_cost_weights = operation_cost_weights;
377 }
378
379 info!("Analyzing schema for optimization opportunities...");
381 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
382
383 infer_native_columns_from_arg_types(&mut schema);
386
387 if let Some(db_url) = opts.database {
389 info!("Validating indexed columns against database...");
390 validate_indexed_columns(&schema, db_url).await?;
391
392 info!("Validating native columns for direct query arguments...");
393 let pg_introspector = build_postgres_introspector(db_url)
394 .context("Failed to connect for native column validation")?;
395 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
396
397 let mut drift_errors: Vec<String> = Vec::new();
400 for w in &db_report.warnings {
401 match w.severity() {
402 Severity::Error => {
403 warn!("{w} [drift error]");
404 drift_errors.push(w.to_string());
405 },
406 Severity::Warn => warn!("{w}"),
407 }
408 }
409 apply_database_report(&mut schema, &db_report);
410
411 if db_url.starts_with("postgres") {
414 info!("Validating mutation contract against the database...");
415 let catalog = PgCatalog::connect(db_url)
416 .context("Failed to connect for mutation-contract validation")?;
417 let contract = validate_mutation_contract(&schema, &catalog).await?;
418 for m in &contract.mutations {
419 for v in &m.violations {
420 let kind = match v.severity() {
421 Severity::Error => "contract error",
422 Severity::Warn => "contract warning",
423 };
424 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
425 if v.severity() == Severity::Error {
426 drift_errors.push(format!(
427 "mutation `{}` (sql_source: {}): {v}",
428 m.mutation, m.sql_source
429 ));
430 }
431 }
432 }
433 }
434
435 if !drift_errors.is_empty() {
440 if opts.allow_drift {
441 warn!(
442 "{} schema↔database drift error(s) suppressed by --allow-drift",
443 drift_errors.len()
444 );
445 } else {
446 let list =
447 drift_errors.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n");
448 anyhow::bail!(
449 "schema↔database drift: {} error(s):\n{list}\n\
450 Fix the declarations or the database objects, or pass --allow-drift \
451 to compile anyway (advisory mode).",
452 drift_errors.len()
453 );
454 }
455 }
456 } else {
457 for query in &schema.queries {
460 if query.sql_source.is_none() {
461 continue;
462 }
463 let unresolved: Vec<_> = query
464 .arguments
465 .iter()
466 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
467 .filter(|a| !query.native_columns.contains_key(&a.name))
468 .collect();
469 if !unresolved.is_empty() {
470 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
471 warn!(
472 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
473 columns — no --database URL provided. These filters will use JSONB \
474 extraction. Provide --database or annotate with native_columns.",
475 query.name,
476 names,
477 query.sql_source.as_deref().unwrap_or("?"),
478 );
479 }
480
481 if query.pagination_order == Some(fraiseql_core::schema::PaginationOrder::JsonIdentity)
486 {
487 warn!(
488 "query `{}`: offset pages will be ordered by `data->>'id'` — no --database \
489 URL provided, so a cheaper unique column on `{}` could not be found. \
490 Provide --database to use `pk_{}` or a native `id` column instead, or \
491 declare pagination_order.",
492 query.name,
493 query.sql_source.as_deref().unwrap_or("?"),
494 fraiseql_core::utils::to_snake_case(&query.return_type),
495 );
496 }
497 }
498 }
499
500 check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
502
503 warn_wide_cascade_mutations(&schema);
505
506 warn_jsonb_preserve_mismatch(&schema);
509
510 refuse_unattributable_mutations(&schema)?;
512
513 refuse_unenforced_authz_declarations(&schema)?;
515
516 refuse_what_a_server_would_not_load(&schema)?;
518
519 Ok((CompiledArtifact { schema, functions }, report))
520}
521
522fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
536 let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
537 let mut value: serde_json::Value = serde_json::from_str(&body)?;
538 let hash = content_hash_of(&value);
539 value
540 .as_object_mut()
541 .context("schema must serialise as JSON object")?
542 .insert("_content_hash".to_string(), serde_json::Value::String(hash));
543 CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
544 anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
545 })?;
546 Ok(())
547}
548
549fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
567 let Some(security) = schema.security.as_ref() else {
568 return Ok(());
569 };
570
571 let mut declared: Vec<String> = Vec::new();
572 if !security.rules.is_empty() {
573 declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
574 }
575 if !security.field_auth.is_empty() {
576 declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
577 }
578 if let Some(policy) = security.default_policy.as_deref() {
579 declared.push(format!("`security.default_policy` (\"{policy}\")"));
580 }
581 if declared.is_empty() {
582 return Ok(());
583 }
584
585 anyhow::bail!(
586 "authorization declaration(s) with no enforcer: {}.\n\n\
587 FraiseQL enforces field-level authorization through `security.role_definitions` \
588 (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
589 rule written here is not applied to any request. Remove them, or express the same \
590 intent with role definitions and `requires_scope`.\n\n\
591 (A general authorization-rule engine is tracked separately; until it exists, a \
592 declaration that silently enforces nothing is refused rather than accepted.)",
593 declared.join(", ")
594 )
595}
596
597fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
621 if !fraiseql_core::cache::declares_cacheable_views(schema) {
622 return Ok(());
623 }
624 let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
625 if unattributable.is_empty() {
626 return Ok(());
627 }
628
629 let cacheable: Vec<&str> = schema
630 .queries
631 .iter()
632 .filter(|q| q.cache_ttl_seconds.is_some())
633 .filter_map(|q| q.sql_source.as_deref())
634 .collect();
635 let list = unattributable.iter().map(|m| format!(" - {m}")).collect::<Vec<_>>().join("\n");
636 anyhow::bail!(
637 "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
638 This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
639 view invalidates nothing when it succeeds — permanently, for any view annotated \
640 `cache_ttl_seconds = 0`.\n\n\
641 Fix by declaring what each mutation writes:\n\
642 \x20 invalidates_views = [\"v_price\"]\n\n\
643 A mutation whose return type is backed by a view, or whose payload wraps an entity \
644 that is, already resolves and needs no annotation.",
645 cacheable.len(),
646 cacheable.join(", "),
647 )
648}
649
650#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
680#[doc(hidden)] pub async fn run(
684 input: &str,
685 types: Option<&str>,
686 schema_dir: Option<&str>,
687 type_files: Vec<String>,
688 query_files: Vec<String>,
689 mutation_files: Vec<String>,
690 output: &str,
691 check: bool,
692 database: Option<&str>,
693 emit_ddl: Option<&str>,
694 check_migrations: bool,
695 skip_hash: bool,
696 allow_drift: bool,
697) -> Result<()> {
698 if !check && Path::new(output) == Path::new(input) {
703 anyhow::bail!(
704 "Refusing to write compiled output over the input file '{input}'. \
705 Use a distinct --output path (e.g. schema.compiled.json)."
706 );
707 }
708
709 let opts = CompileOptions {
710 input,
711 types,
712 schema_dir,
713 type_files,
714 query_files,
715 mutation_files,
716 database,
717 skip_hash,
718 allow_drift,
719 };
720 let (artifact, optimization_report) = compile_to_schema(opts).await?;
721 let schema = &artifact.schema;
722
723 if check {
725 println!("✓ Schema is valid");
726 println!(" Types: {}", schema.types.len());
727 println!(" Queries: {}", schema.queries.len());
728 println!(" Mutations: {}", schema.mutations.len());
729 optimization_report.print();
730 return Ok(());
731 }
732
733 info!("Writing compiled schema to: {output}");
735 let value = artifact.to_json_value()?;
738
739 let output_json = if skip_hash {
740 serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
741 } else {
742 let hash_hex = content_hash_of(&value);
745
746 let obj = value.as_object().context("schema must serialise as JSON object")?;
747
748 let mut new_obj = serde_json::Map::new();
750 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
751 for (k, v) in obj {
752 new_obj.insert(k.clone(), v.clone());
753 }
754 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
755 };
756
757 fs::write(output, output_json).context("Failed to write compiled schema")?;
758
759 println!("✓ Schema compiled successfully");
761 println!(" Input: {input}");
762 println!(" Output: {output}");
763 println!(" Types: {}", schema.types.len());
764 println!(" Queries: {}", schema.queries.len());
765 println!(" Mutations: {}", schema.mutations.len());
766 optimization_report.print();
767
768 if let Some(ddl_dir) = emit_ddl {
770 emit_ddl_to_dir(schema, ddl_dir)?;
771 }
772
773 if check_migrations {
775 run_check_migrations(schema)?;
776 }
777
778 Ok(())
779}
780
781pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
795 fs::create_dir_all(output_dir)
796 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
797
798 let mut count = 0;
799 for type_def in &schema.types {
800 let table_name = to_snake_case(type_def.name.as_str());
801 let ddl = build_create_table_ddl(&table_name, type_def)?;
802
803 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
804 fs::write(&file_path, ddl)
805 .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
806 count += 1;
807 }
808
809 println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
810 Ok(())
811}
812
813fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
823 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
824 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
825
826 emit_ddl_to_dir(schema, tmp_path)?;
827
828 info!("Running confiture migrate validate for drift detection...");
829
830 let status = Command::new("confiture").args(["migrate", "validate"]).status();
831
832 match status {
833 Err(_) => {
834 eprintln!(
836 "WARN: confiture is not installed; skipping migration drift check.\n\
837 Install it with: cargo install confiture"
838 );
839 Ok(())
840 },
841 Ok(s) if s.success() => {
842 println!("✓ No migration drift detected.");
843 Ok(())
844 },
845 Ok(_) => {
846 eprintln!(
847 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
848 );
849 anyhow::bail!(
850 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
851 )
852 },
853 }
854}
855
856pub(crate) fn to_snake_case(name: &str) -> String {
864 fraiseql_core::utils::to_snake_case(name)
865}
866
867fn build_create_table_ddl(
874 table_name: &str,
875 type_def: &fraiseql_core::schema::TypeDefinition,
876) -> Result<String> {
877 let mut lines: Vec<String> = Vec::new();
878 lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
879 lines.push(format!("-- Type: {}", type_def.name));
880 if let Some(desc) = &type_def.description {
881 lines.push(format!("-- {desc}"));
882 }
883 lines.push(String::new());
884 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
885
886 let col_lines: Vec<String> = type_def
887 .fields
888 .iter()
889 .map(|field| {
890 let col_name = to_snake_case(field.name.as_str());
891 let pg_type =
896 if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
897 field.field_type.to_sql_type(field.vector_config.as_ref())
898 } else {
899 field_type_to_pg(&field.field_type)
900 };
901 let nullable = if field.nullable { "" } else { " NOT NULL" };
902 format!(" {col_name} {pg_type}{nullable}")
903 })
904 .collect();
905
906 let last = col_lines.len().saturating_sub(1);
907 for (i, col) in col_lines.iter().enumerate() {
908 if i < last {
909 lines.push(format!("{col},"));
910 } else {
911 lines.push(col.clone());
912 }
913 }
914
915 lines.push(");".to_string());
916
917 let vector_fields: Vec<_> = type_def
921 .fields
922 .iter()
923 .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
924 .collect();
925 if !vector_fields.is_empty() {
926 lines.push(String::new());
927 lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
928 for (field, config) in vector_fields {
929 let col_name = to_snake_case(field.name.as_str());
930 let index_ddl = config
931 .index_type
932 .index_sql(
933 &format!("tb_{table_name}"),
934 &col_name,
935 &field.field_type,
936 config.distance_metric,
937 )
938 .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
939 if let Some(index_ddl) = index_ddl {
940 lines.push(format!("{index_ddl};"));
941 }
942 }
943 }
944
945 lines.push(String::new());
946 Ok(lines.join("\n"))
947}
948
949pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
951 match ft {
952 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
953 FieldType::Int => "INTEGER".to_string(),
954 FieldType::Float => "DOUBLE PRECISION".to_string(),
955 FieldType::Boolean => "BOOLEAN".to_string(),
956 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
957 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
958 FieldType::Date => "DATE".to_string(),
959 FieldType::Time => "TIME".to_string(),
960 FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
961 FieldType::Decimal => "NUMERIC".to_string(),
962 FieldType::Vector => "VECTOR".to_string(),
963 FieldType::BitVector => "VARBIT".to_string(),
968 FieldType::HalfVector => "HALFVEC".to_string(),
972 FieldType::SparseVector => "SPARSEVEC".to_string(),
973 FieldType::Enum(name) => name.clone(),
975 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
976 _ => "TEXT".to_string(),
978 }
979}
980
981fn check_sqlite_compatibility_warnings(
987 schema: &CompiledSchema,
988 input_path: &str,
989 is_toml: bool,
990 database_url: Option<&str>,
991) {
992 let target_is_sqlite = database_url
993 .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
994 || is_toml && detect_sqlite_target_in_toml(input_path);
995
996 if !target_is_sqlite {
997 return;
998 }
999
1000 let unsupported_mutation_count = schema
1008 .mutations
1009 .iter()
1010 .filter(|m| {
1011 matches!(
1012 m.operation,
1013 fraiseql_core::schema::MutationOperation::Update { .. }
1014 | fraiseql_core::schema::MutationOperation::Custom
1015 )
1016 })
1017 .count();
1018 let relay_count = schema.queries.iter().filter(|q| q.relay).count();
1019 let subscription_count = schema.subscriptions.len();
1020
1021 if unsupported_mutation_count > 0 {
1022 warn!(
1023 "Schema contains {} Update or custom mutation(s) but target database is SQLite. \
1024 SQLite supports only direct-SQL Insert/Delete mutations. \
1025 See: https://fraiseql.dev/docs/database-compatibility",
1026 unsupported_mutation_count,
1027 );
1028 }
1029 if relay_count > 0 {
1030 warn!(
1031 "Schema contains {} relay query/queries but target database is SQLite. \
1032 Relay (keyset pagination) is not supported on SQLite. \
1033 See: https://fraiseql.dev/docs/database-compatibility",
1034 relay_count,
1035 );
1036 }
1037 if subscription_count > 0 {
1038 warn!(
1039 "Schema contains {} subscription(s) but target database is SQLite. \
1040 Subscriptions are not supported on SQLite. \
1041 See: https://fraiseql.dev/docs/database-compatibility",
1042 subscription_count,
1043 );
1044 }
1045}
1046
1047fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
1052 let Ok(content) = fs::read_to_string(toml_path) else {
1053 return false;
1054 };
1055 let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
1056 return false;
1057 };
1058 toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
1059}
1060
1061pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1064
1065pub(crate) fn wide_cascade_mutations(
1071 schema: &CompiledSchema,
1072 threshold: usize,
1073) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1074 schema
1075 .mutations
1076 .iter()
1077 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1078 .collect()
1079}
1080
1081fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1094 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1095 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1096
1097 let mut targets: Vec<&str> = mutation
1099 .invalidates_views
1100 .iter()
1101 .chain(mutation.invalidates_fact_tables.iter())
1102 .map(String::as_str)
1103 .collect();
1104 targets.sort_unstable();
1105 targets.dedup();
1106
1107 let alter_stmts: Vec<String> = targets
1110 .iter()
1111 .map(|&name| {
1112 let table = name
1113 .strip_prefix("tv_")
1114 .or_else(|| name.strip_prefix("v_"))
1115 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1116 format!("ALTER TABLE {table} SET (fillfactor = 75);")
1117 })
1118 .collect();
1119
1120 warn!(
1121 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1122 Under high write load, HOT-update page slots on these tables may be \
1123 exhausted, forcing full-page writes and reducing mutation throughput. \
1124 Set fillfactor=70-80 on the backing tables: {} \
1125 Monitor HOT efficiency: SELECT relname, \
1126 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1127 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1128 mutation.name,
1129 total,
1130 targets.join(", "),
1131 alter_stmts.join(" "),
1132 );
1133 }
1134}
1135
1136pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1149 if schema.naming_convention != NamingConvention::Preserve {
1150 return Vec::new();
1151 }
1152 let mut out = Vec::new();
1153 for mutation in &schema.mutations {
1154 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1157 || matches!(mutation.operation, MutationOperation::Update { .. });
1158 if !single_jsonb {
1159 continue;
1160 }
1161 let input_type_name = match mutation.arguments.as_slice() {
1168 [arg] if arg.name == "input" => match &arg.arg_type {
1169 FieldType::Input(name) => name.as_str(),
1170 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1171 _ => continue,
1172 },
1173 _ => continue,
1174 };
1175 let Some(input_type) = schema.find_input_type(input_type_name) else {
1176 continue;
1177 };
1178 let camel_fields: Vec<String> = input_type
1181 .fields
1182 .iter()
1183 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1184 .map(|f| f.name.clone())
1185 .collect();
1186 if !camel_fields.is_empty() {
1187 out.push((mutation.name.clone(), camel_fields));
1188 }
1189 }
1190 out
1191}
1192
1193fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1197 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1198 warn!(
1199 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1200 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1201 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1202 recasing), so a SQL function reading payload->>'snake_field' will receive these \
1203 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1204 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1205 fields.join(", "),
1206 );
1207 }
1208}
1209
1210pub(crate) fn build_postgres_introspector(
1218 db_url: &str,
1219) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1220 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
1221 use tokio_postgres::NoTls;
1222
1223 let mut cfg = Config::new();
1224 cfg.url = Some(db_url.to_string());
1225 cfg.manager = Some(ManagerConfig {
1226 recycling_method: RecyclingMethod::Fast,
1227 });
1228 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
1229
1230 let pool = cfg
1231 .create_pool(Some(Runtime::Tokio1), NoTls)
1232 .context("Failed to create connection pool for database validation")?;
1233
1234 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1235}
1236
1237async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
1252 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
1253 use fraiseql_core::db::postgres::PostgresIntrospector;
1254 use tokio_postgres::NoTls;
1255
1256 let mut cfg = Config::new();
1258 cfg.url = Some(db_url.to_string());
1259 cfg.manager = Some(ManagerConfig {
1260 recycling_method: RecyclingMethod::Fast,
1261 });
1262 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
1263
1264 let pool = cfg
1265 .create_pool(Some(Runtime::Tokio1), NoTls)
1266 .context("Failed to create connection pool for indexed column validation")?;
1267
1268 let introspector = PostgresIntrospector::new(pool);
1269
1270 let mut total_indexed = 0;
1271 let mut total_views = 0;
1272
1273 for query in &schema.queries {
1275 if let Some(view_name) = &query.sql_source {
1276 total_views += 1;
1277
1278 match introspector.get_indexed_nested_columns(view_name).await {
1280 Ok(indexed_cols) => {
1281 if !indexed_cols.is_empty() {
1282 info!(
1283 "View '{}': found {} indexed column(s): {:?}",
1284 view_name,
1285 indexed_cols.len(),
1286 indexed_cols
1287 );
1288 total_indexed += indexed_cols.len();
1289 }
1290 },
1291 Err(e) => {
1292 warn!(
1293 "Could not introspect view '{}': {}. Skipping indexed column check.",
1294 view_name, e
1295 );
1296 },
1297 }
1298 }
1299 }
1300
1301 println!("✓ Indexed column validation complete");
1302 println!(" Views checked: {total_views}");
1303 println!(" Indexed columns found: {total_indexed}");
1304
1305 Ok(())
1306}
1307
1308const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1310 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1311];
1312
1313pub(crate) fn apply_database_report(
1325 schema: &mut fraiseql_core::schema::CompiledSchema,
1326 report: &crate::schema::database_validator::DatabaseValidationReport,
1327) {
1328 for query in &mut schema.queries {
1329 if let Some(cols) = report.native_columns.get(&query.name) {
1330 query.native_columns = cols.clone();
1331 }
1332 if let Some(order) = report.pagination_orders.get(&query.name) {
1333 query.pagination_order = Some(order.clone());
1334 }
1335 }
1336}
1337
1338pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1350 for query in &mut schema.queries {
1351 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
1352 continue;
1353 }
1354 for arg in &query.arguments {
1355 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1356 continue;
1357 }
1358 if query.native_columns.contains_key(&arg.name) {
1359 continue; }
1361 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1362 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
1363 }
1364 }
1365 }
1366}