1use std::{fs, path::Path, process::Command};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{
9 CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType, InputStyle, MutationOperation,
10 NamingConvention, canonicalize_json,
11};
12use tracing::{info, warn};
13
14use crate::{
15 config::TomlProjectConfig,
16 schema::{
17 ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer,
18 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}
45
46impl<'a> CompileOptions<'a> {
47 #[must_use]
49 pub fn new(input: &'a str) -> Self {
50 Self {
51 input,
52 ..Default::default()
53 }
54 }
55
56 #[must_use]
58 pub fn with_types(mut self, types: &'a str) -> Self {
59 self.types = Some(types);
60 self
61 }
62
63 #[must_use]
65 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
66 self.schema_dir = Some(schema_dir);
67 self
68 }
69
70 #[must_use]
72 pub fn with_database(mut self, database: &'a str) -> Self {
73 self.database = Some(database);
74 self
75 }
76}
77
78#[allow(clippy::cognitive_complexity)] fn load_intermediate_schema(
87 toml_path: &str,
88 type_files: &[String],
89 query_files: &[String],
90 mutation_files: &[String],
91 schema_dir: Option<&str>,
92 types_path: Option<&str>,
93) -> Result<IntermediateSchema> {
94 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
95 info!("Mode: Explicit file lists");
96 return crate::schema::SchemaMerger::merge_explicit_files(
97 toml_path,
98 type_files,
99 query_files,
100 mutation_files,
101 )
102 .context("Failed to load explicit schema files");
103 }
104 if let Some(dir) = schema_dir {
105 info!("Mode: Auto-discovery from directory: {}", dir);
106 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
107 .context("Failed to load schema from directory");
108 }
109 if let Some(types) = types_path {
110 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
111 return crate::schema::SchemaMerger::merge_files(types, toml_path)
112 .context("Failed to merge types.json with TOML");
113 }
114 info!("Mode: TOML-based (checking for domain discovery...)");
115 if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
116 return Ok(schema);
117 }
118 info!("No domains configured, checking for TOML includes...");
119 if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
120 return Ok(schema);
121 }
122 info!("No includes configured, using TOML-only definitions");
123 crate::schema::SchemaMerger::merge_toml_only(toml_path)
124 .context("Failed to load schema from TOML")
125}
126
127#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
142 opts: CompileOptions<'_>,
143) -> Result<(CompiledSchema, OptimizationReport)> {
144 info!("Compiling schema: {}", opts.input);
145
146 let input_path = Path::new(opts.input);
148 if !input_path.exists() {
149 anyhow::bail!("Input file not found: {}", opts.input);
150 }
151
152 let is_toml = input_path
154 .extension()
155 .and_then(|ext| ext.to_str())
156 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
157 let mut intermediate: IntermediateSchema = if is_toml {
158 info!("Using TOML-based workflow");
159 load_intermediate_schema(
160 opts.input,
161 &opts.type_files,
162 &opts.query_files,
163 &opts.mutation_files,
164 opts.schema_dir,
165 opts.types,
166 )?
167 } else {
168 info!("Using legacy JSON workflow");
170 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
171
172 info!("Parsing intermediate schema...");
177 let raw: serde_json::Value =
178 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?;
179 let input_has_federation = ["federation", "federation_config"].iter().any(|key| {
180 raw.get(*key)
181 .and_then(serde_json::Value::as_object)
182 .is_some_and(|o| !o.is_empty())
183 });
184 let intermediate: IntermediateSchema =
185 serde_json::from_value(raw).context("Failed to parse schema.json")?;
186 if input_has_federation && intermediate.federation_config.is_none() {
187 anyhow::bail!(
188 "schema.json carries a `federation` block that did not bind into the compiled \
189 schema — refusing to compile a silently non-federated subgraph. This is a \
190 compiler bug; please report it."
191 );
192 }
193 intermediate
194 };
195
196 let mut auto_error_union = false;
202 let mut naming_acronyms: Vec<String> = Vec::new();
204 let mut operation_cost_weights: std::collections::HashMap<String, usize> =
206 std::collections::HashMap::new();
207 let mut naming_convention = NamingConvention::CamelCase;
213 if !is_toml && Path::new("fraiseql.toml").exists() {
214 info!("Loading security configuration from fraiseql.toml...");
215 match TomlProjectConfig::from_file("fraiseql.toml") {
216 Ok(config) => {
217 info!("Validating security configuration...");
218 config.validate()?;
219
220 auto_error_union = config.fraiseql.mutations.auto_error_union;
221 naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
222 operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
223 naming_convention = config.fraiseql.naming.convention;
224
225 info!("Applying security configuration to schema...");
226 let mut security_json = config.fraiseql.security.to_json();
228
229 if !matches!(
231 config.fraiseql.tenancy.mode,
232 crate::config::security::TenancyModeConfig::None
233 ) {
234 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
235 }
236
237 intermediate.security = Some(security_json);
238
239 info!("Security configuration applied successfully");
240 },
241 Err(e) => {
242 anyhow::bail!(
243 "Failed to parse fraiseql.toml: {e}\n\
244 Fix the configuration file or remove it to use defaults."
245 );
246 },
247 }
248 } else {
249 info!("No fraiseql.toml found, using default security configuration");
250 }
251
252 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
256
257 if !is_toml {
263 intermediate.naming_convention = naming_convention;
264 }
265
266 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
269 let tenancy = sec.get("tenancy")?;
270 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
271 if mode == "row" {
272 Some(
273 tenancy
274 .get("tenantClaim")
275 .and_then(|c| c.as_str())
276 .unwrap_or("tenant_id")
277 .to_string(),
278 )
279 } else {
280 None
281 }
282 });
283 if let Some(tenant_claim) = &tenancy_row_claim {
284 info!("Validating @tenant_id annotations for row-isolation tenancy...");
285 crate::schema::converter::tenancy::validate_tenant_annotations(
286 &mut intermediate,
287 tenant_claim,
288 )
289 .context("@tenant_id validation failed")?;
290 }
291
292 info!("Validating schema structure...");
294 let validation_report =
295 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
296
297 if !validation_report.is_valid() {
298 validation_report.print();
299 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
300 }
301
302 if validation_report.warning_count() > 0 {
304 validation_report.print();
305 }
306
307 info!("Converting to compiled format...");
309 let mut schema =
310 SchemaConverter::convert_with_options(intermediate, &ConvertOptions { auto_error_union })
311 .context("Failed to convert schema to compiled format")?;
312
313 schema.naming_acronyms = naming_acronyms;
316
317 if !operation_cost_weights.is_empty() {
321 schema.operation_cost_weights = operation_cost_weights;
322 }
323
324 info!("Analyzing schema for optimization opportunities...");
326 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
327
328 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
330
331 infer_native_columns_from_arg_types(&mut schema);
334
335 if let Some(db_url) = opts.database {
337 info!("Validating indexed columns against database...");
338 validate_indexed_columns(&schema, db_url).await?;
339
340 info!("Validating native columns for direct query arguments...");
341 let pg_introspector = build_postgres_introspector(db_url)
342 .context("Failed to connect for native column validation")?;
343 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
344 for w in &db_report.warnings {
345 warn!("{w}");
346 }
347 for query in &mut schema.queries {
349 if let Some(cols) = db_report.native_columns.get(&query.name) {
350 query.native_columns = cols.clone();
351 }
352 }
353
354 if db_url.starts_with("postgres") {
359 info!("Validating mutation contract against the database...");
360 let catalog = PgCatalog::connect(db_url)
361 .context("Failed to connect for mutation-contract validation")?;
362 let contract = validate_mutation_contract(&schema, &catalog).await?;
363 for m in &contract.mutations {
364 for v in &m.violations {
365 let kind = match v.severity() {
366 Severity::Error => "contract error",
367 Severity::Warn => "contract warning",
368 };
369 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
370 }
371 }
372 }
373 } else {
374 for query in &schema.queries {
377 if query.sql_source.is_none() {
378 continue;
379 }
380 let unresolved: Vec<_> = query
381 .arguments
382 .iter()
383 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
384 .filter(|a| !query.native_columns.contains_key(&a.name))
385 .collect();
386 if !unresolved.is_empty() {
387 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
388 warn!(
389 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
390 columns — no --database URL provided. These filters will use JSONB \
391 extraction. Provide --database or annotate with native_columns.",
392 query.name,
393 names,
394 query.sql_source.as_deref().unwrap_or("?"),
395 );
396 }
397 }
398 }
399
400 check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
402
403 warn_wide_cascade_mutations(&schema);
405
406 warn_jsonb_preserve_mismatch(&schema);
409
410 Ok((schema, report))
411}
412
413#[allow(clippy::too_many_arguments)]
442#[doc(hidden)] pub async fn run(
446 input: &str,
447 types: Option<&str>,
448 schema_dir: Option<&str>,
449 type_files: Vec<String>,
450 query_files: Vec<String>,
451 mutation_files: Vec<String>,
452 output: &str,
453 check: bool,
454 database: Option<&str>,
455 emit_ddl: Option<&str>,
456 check_migrations: bool,
457 skip_hash: bool,
458) -> Result<()> {
459 if !check && Path::new(output) == Path::new(input) {
464 anyhow::bail!(
465 "Refusing to write compiled output over the input file '{input}'. \
466 Use a distinct --output path (e.g. schema.compiled.json)."
467 );
468 }
469
470 let opts = CompileOptions {
471 input,
472 types,
473 schema_dir,
474 type_files,
475 query_files,
476 mutation_files,
477 database,
478 skip_hash,
479 };
480 let (schema, optimization_report) = compile_to_schema(opts).await?;
481
482 if check {
484 println!("✓ Schema is valid");
485 println!(" Types: {}", schema.types.len());
486 println!(" Queries: {}", schema.queries.len());
487 println!(" Mutations: {}", schema.mutations.len());
488 optimization_report.print();
489 return Ok(());
490 }
491
492 info!("Writing compiled schema to: {output}");
494 let output_json = if skip_hash {
495 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?
496 } else {
497 use sha2::{Digest, Sha256};
498
499 let body =
500 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
501 let value: serde_json::Value = serde_json::from_str(&body)?;
502 let canonical = serde_json::to_string_pretty(&canonicalize_json(&value))?;
504 let hash = Sha256::digest(canonical.as_bytes());
505 let hash_hex = hex::encode(&hash[..16]);
506
507 let obj = value.as_object().context("schema must serialise as JSON object")?;
508
509 let mut new_obj = serde_json::Map::new();
511 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
512 for (k, v) in obj {
513 new_obj.insert(k.clone(), v.clone());
514 }
515 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
516 };
517
518 fs::write(output, output_json).context("Failed to write compiled schema")?;
519
520 println!("✓ Schema compiled successfully");
522 println!(" Input: {input}");
523 println!(" Output: {output}");
524 println!(" Types: {}", schema.types.len());
525 println!(" Queries: {}", schema.queries.len());
526 println!(" Mutations: {}", schema.mutations.len());
527 optimization_report.print();
528
529 if let Some(ddl_dir) = emit_ddl {
531 emit_ddl_to_dir(&schema, ddl_dir)?;
532 }
533
534 if check_migrations {
536 run_check_migrations(&schema)?;
537 }
538
539 Ok(())
540}
541
542pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
556 fs::create_dir_all(output_dir)
557 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
558
559 let mut count = 0;
560 for type_def in &schema.types {
561 let table_name = to_snake_case(type_def.name.as_str());
562 let ddl = build_create_table_ddl(&table_name, type_def);
563
564 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
565 fs::write(&file_path, ddl)
566 .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
567 count += 1;
568 }
569
570 println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
571 Ok(())
572}
573
574fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
584 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
585 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
586
587 emit_ddl_to_dir(schema, tmp_path)?;
588
589 info!("Running confiture migrate validate for drift detection...");
590
591 let status = Command::new("confiture").args(["migrate", "validate"]).status();
592
593 match status {
594 Err(_) => {
595 eprintln!(
597 "WARN: confiture is not installed; skipping migration drift check.\n\
598 Install it with: cargo install confiture"
599 );
600 Ok(())
601 },
602 Ok(s) if s.success() => {
603 println!("✓ No migration drift detected.");
604 Ok(())
605 },
606 Ok(_) => {
607 eprintln!(
608 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
609 );
610 anyhow::bail!(
611 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
612 )
613 },
614 }
615}
616
617pub(crate) fn to_snake_case(name: &str) -> String {
619 let mut result = String::with_capacity(name.len() + 4);
620 for (i, ch) in name.chars().enumerate() {
621 if ch.is_uppercase() && i > 0 {
622 result.push('_');
623 }
624 result.extend(ch.to_lowercase());
625 }
626 result
627}
628
629fn build_create_table_ddl(
631 table_name: &str,
632 type_def: &fraiseql_core::schema::TypeDefinition,
633) -> String {
634 let mut lines: Vec<String> = Vec::new();
635 lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
636 lines.push(format!("-- Type: {}", type_def.name));
637 if let Some(desc) = &type_def.description {
638 lines.push(format!("-- {desc}"));
639 }
640 lines.push(String::new());
641 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
642
643 let col_lines: Vec<String> = type_def
644 .fields
645 .iter()
646 .map(|field| {
647 let col_name = to_snake_case(field.name.as_str());
648 let pg_type = field_type_to_pg(&field.field_type);
649 let nullable = if field.nullable { "" } else { " NOT NULL" };
650 format!(" {col_name} {pg_type}{nullable}")
651 })
652 .collect();
653
654 let last = col_lines.len().saturating_sub(1);
655 for (i, col) in col_lines.iter().enumerate() {
656 if i < last {
657 lines.push(format!("{col},"));
658 } else {
659 lines.push(col.clone());
660 }
661 }
662
663 lines.push(");".to_string());
664 lines.push(String::new());
665 lines.join("\n")
666}
667
668pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
670 match ft {
671 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
672 FieldType::Int => "INTEGER".to_string(),
673 FieldType::Float => "DOUBLE PRECISION".to_string(),
674 FieldType::Boolean => "BOOLEAN".to_string(),
675 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
676 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
677 FieldType::Date => "DATE".to_string(),
678 FieldType::Time => "TIME".to_string(),
679 FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
680 FieldType::Decimal => "NUMERIC".to_string(),
681 FieldType::Vector => "VECTOR".to_string(),
682 FieldType::Enum(name) => name.clone(),
684 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
685 _ => "TEXT".to_string(),
687 }
688}
689
690fn check_sqlite_compatibility_warnings(
696 schema: &CompiledSchema,
697 input_path: &str,
698 is_toml: bool,
699 database_url: Option<&str>,
700) {
701 let target_is_sqlite = database_url
702 .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
703 || is_toml && detect_sqlite_target_in_toml(input_path);
704
705 if !target_is_sqlite {
706 return;
707 }
708
709 let unsupported_mutation_count = schema
712 .mutations
713 .iter()
714 .filter(|m| {
715 matches!(
716 m.operation,
717 fraiseql_core::schema::MutationOperation::Update { .. }
718 | fraiseql_core::schema::MutationOperation::Custom
719 )
720 })
721 .count();
722 let relay_count = schema.queries.iter().filter(|q| q.relay).count();
723 let subscription_count = schema.subscriptions.len();
724
725 if unsupported_mutation_count > 0 {
726 warn!(
727 "Schema contains {} Update or custom mutation(s) but target database is SQLite. \
728 SQLite supports only direct-SQL Insert/Delete mutations. \
729 See: https://fraiseql.dev/docs/database-compatibility",
730 unsupported_mutation_count,
731 );
732 }
733 if relay_count > 0 {
734 warn!(
735 "Schema contains {} relay query/queries but target database is SQLite. \
736 Relay (keyset pagination) is not supported on SQLite. \
737 See: https://fraiseql.dev/docs/database-compatibility",
738 relay_count,
739 );
740 }
741 if subscription_count > 0 {
742 warn!(
743 "Schema contains {} subscription(s) but target database is SQLite. \
744 Subscriptions are not supported on SQLite. \
745 See: https://fraiseql.dev/docs/database-compatibility",
746 subscription_count,
747 );
748 }
749}
750
751fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
756 let Ok(content) = fs::read_to_string(toml_path) else {
757 return false;
758 };
759 let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
760 return false;
761 };
762 toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
763}
764
765pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
768
769pub(crate) fn wide_cascade_mutations(
775 schema: &CompiledSchema,
776 threshold: usize,
777) -> Vec<&fraiseql_core::schema::MutationDefinition> {
778 schema
779 .mutations
780 .iter()
781 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
782 .collect()
783}
784
785fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
798 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
799 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
800
801 let mut targets: Vec<&str> = mutation
803 .invalidates_views
804 .iter()
805 .chain(mutation.invalidates_fact_tables.iter())
806 .map(String::as_str)
807 .collect();
808 targets.sort_unstable();
809 targets.dedup();
810
811 let alter_stmts: Vec<String> = targets
814 .iter()
815 .map(|&name| {
816 let table = name
817 .strip_prefix("tv_")
818 .or_else(|| name.strip_prefix("v_"))
819 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
820 format!("ALTER TABLE {table} SET (fillfactor = 75);")
821 })
822 .collect();
823
824 warn!(
825 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
826 Under high write load, HOT-update page slots on these tables may be \
827 exhausted, forcing full-page writes and reducing mutation throughput. \
828 Set fillfactor=70-80 on the backing tables: {} \
829 Monitor HOT efficiency: SELECT relname, \
830 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
831 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
832 mutation.name,
833 total,
834 targets.join(", "),
835 alter_stmts.join(" "),
836 );
837 }
838}
839
840pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
853 if schema.naming_convention != NamingConvention::Preserve {
854 return Vec::new();
855 }
856 let mut out = Vec::new();
857 for mutation in &schema.mutations {
858 let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
861 || matches!(mutation.operation, MutationOperation::Update { .. });
862 if !single_jsonb {
863 continue;
864 }
865 let input_type_name = match mutation.arguments.as_slice() {
872 [arg] if arg.name == "input" => match &arg.arg_type {
873 FieldType::Input(name) => name.as_str(),
874 FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
875 _ => continue,
876 },
877 _ => continue,
878 };
879 let Some(input_type) = schema.find_input_type(input_type_name) else {
880 continue;
881 };
882 let camel_fields: Vec<String> = input_type
885 .fields
886 .iter()
887 .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
888 .map(|f| f.name.clone())
889 .collect();
890 if !camel_fields.is_empty() {
891 out.push((mutation.name.clone(), camel_fields));
892 }
893 }
894 out
895}
896
897fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
901 for (mutation, fields) in jsonb_preserve_mismatches(schema) {
902 warn!(
903 "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
904 uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
905 [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
906 recasing), so a SQL function reading payload->>'snake_field' will receive these \
907 camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
908 new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
909 fields.join(", "),
910 );
911 }
912}
913
914fn build_postgres_introspector(
922 db_url: &str,
923) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
924 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
925 use tokio_postgres::NoTls;
926
927 let mut cfg = Config::new();
928 cfg.url = Some(db_url.to_string());
929 cfg.manager = Some(ManagerConfig {
930 recycling_method: RecyclingMethod::Fast,
931 });
932 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
933
934 let pool = cfg
935 .create_pool(Some(Runtime::Tokio1), NoTls)
936 .context("Failed to create connection pool for database validation")?;
937
938 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
939}
940
941async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
956 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
957 use fraiseql_core::db::postgres::PostgresIntrospector;
958 use tokio_postgres::NoTls;
959
960 let mut cfg = Config::new();
962 cfg.url = Some(db_url.to_string());
963 cfg.manager = Some(ManagerConfig {
964 recycling_method: RecyclingMethod::Fast,
965 });
966 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
967
968 let pool = cfg
969 .create_pool(Some(Runtime::Tokio1), NoTls)
970 .context("Failed to create connection pool for indexed column validation")?;
971
972 let introspector = PostgresIntrospector::new(pool);
973
974 let mut total_indexed = 0;
975 let mut total_views = 0;
976
977 for query in &schema.queries {
979 if let Some(view_name) = &query.sql_source {
980 total_views += 1;
981
982 match introspector.get_indexed_nested_columns(view_name).await {
984 Ok(indexed_cols) => {
985 if !indexed_cols.is_empty() {
986 info!(
987 "View '{}': found {} indexed column(s): {:?}",
988 view_name,
989 indexed_cols.len(),
990 indexed_cols
991 );
992 total_indexed += indexed_cols.len();
993 }
994 },
995 Err(e) => {
996 warn!(
997 "Could not introspect view '{}': {}. Skipping indexed column check.",
998 view_name, e
999 );
1000 },
1001 }
1002 }
1003 }
1004
1005 println!("✓ Indexed column validation complete");
1006 println!(" Views checked: {total_views}");
1007 println!(" Indexed columns found: {total_indexed}");
1008
1009 Ok(())
1010}
1011
1012const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1014 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1015];
1016
1017pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1029 for query in &mut schema.queries {
1030 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
1031 continue;
1032 }
1033 for arg in &query.arguments {
1034 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1035 continue;
1036 }
1037 if query.native_columns.contains_key(&arg.name) {
1038 continue; }
1040 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1041 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
1042 }
1043 }
1044 }
1045}