1use std::{fs, path::Path};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType};
9use tracing::{info, warn};
10
11use crate::{
12 config::TomlProjectConfig,
13 schema::{
14 IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer, SchemaValidator,
15 database_validator::validate_schema_against_database,
16 },
17};
18
19#[derive(Debug, Default)]
21pub struct CompileOptions<'a> {
22 pub input: &'a str,
24 pub types: Option<&'a str>,
26 pub schema_dir: Option<&'a str>,
28 pub type_files: Vec<String>,
30 pub query_files: Vec<String>,
32 pub mutation_files: Vec<String>,
34 pub database: Option<&'a str>,
36}
37
38impl<'a> CompileOptions<'a> {
39 #[must_use]
41 pub fn new(input: &'a str) -> Self {
42 Self {
43 input,
44 ..Default::default()
45 }
46 }
47
48 #[must_use]
50 pub fn with_types(mut self, types: &'a str) -> Self {
51 self.types = Some(types);
52 self
53 }
54
55 #[must_use]
57 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
58 self.schema_dir = Some(schema_dir);
59 self
60 }
61
62 #[must_use]
64 pub fn with_database(mut self, database: &'a str) -> Self {
65 self.database = Some(database);
66 self
67 }
68}
69
70#[allow(clippy::cognitive_complexity)] fn load_intermediate_schema(
79 toml_path: &str,
80 type_files: &[String],
81 query_files: &[String],
82 mutation_files: &[String],
83 schema_dir: Option<&str>,
84 types_path: Option<&str>,
85) -> Result<IntermediateSchema> {
86 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
87 info!("Mode: Explicit file lists");
88 return crate::schema::SchemaMerger::merge_explicit_files(
89 toml_path,
90 type_files,
91 query_files,
92 mutation_files,
93 )
94 .context("Failed to load explicit schema files");
95 }
96 if let Some(dir) = schema_dir {
97 info!("Mode: Auto-discovery from directory: {}", dir);
98 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
99 .context("Failed to load schema from directory");
100 }
101 if let Some(types) = types_path {
102 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
103 return crate::schema::SchemaMerger::merge_files(types, toml_path)
104 .context("Failed to merge types.json with TOML");
105 }
106 info!("Mode: TOML-based (checking for domain discovery...)");
107 if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
108 return Ok(schema);
109 }
110 info!("No domains configured, checking for TOML includes...");
111 if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
112 return Ok(schema);
113 }
114 info!("No includes configured, using TOML-only definitions");
115 crate::schema::SchemaMerger::merge_toml_only(toml_path)
116 .context("Failed to load schema from TOML")
117}
118
119#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
134 opts: CompileOptions<'_>,
135) -> Result<(CompiledSchema, OptimizationReport)> {
136 info!("Compiling schema: {}", opts.input);
137
138 let input_path = Path::new(opts.input);
140 if !input_path.exists() {
141 anyhow::bail!("Input file not found: {}", opts.input);
142 }
143
144 let is_toml = input_path
146 .extension()
147 .and_then(|ext| ext.to_str())
148 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
149 let mut intermediate: IntermediateSchema = if is_toml {
150 info!("Using TOML-based workflow");
151 load_intermediate_schema(
152 opts.input,
153 &opts.type_files,
154 &opts.query_files,
155 &opts.mutation_files,
156 opts.schema_dir,
157 opts.types,
158 )?
159 } else {
160 info!("Using legacy JSON workflow");
162 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
163
164 info!("Parsing intermediate schema...");
166 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
167 };
168
169 if !is_toml && Path::new("fraiseql.toml").exists() {
174 info!("Loading security configuration from fraiseql.toml...");
175 match TomlProjectConfig::from_file("fraiseql.toml") {
176 Ok(config) => {
177 info!("Validating security configuration...");
178 config.validate()?;
179
180 info!("Applying security configuration to schema...");
181 let security_json = config.fraiseql.security.to_json();
183 intermediate.security = Some(security_json);
184
185 info!("Security configuration applied successfully");
186 },
187 Err(e) => {
188 anyhow::bail!(
189 "Failed to parse fraiseql.toml: {e}\n\
190 Fix the configuration file or remove it to use defaults."
191 );
192 },
193 }
194 } else {
195 info!("No fraiseql.toml found, using default security configuration");
196 }
197
198 info!("Validating schema structure...");
200 let validation_report =
201 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
202
203 if !validation_report.is_valid() {
204 validation_report.print();
205 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
206 }
207
208 if validation_report.warning_count() > 0 {
210 validation_report.print();
211 }
212
213 info!("Converting to compiled format...");
215 let mut schema = SchemaConverter::convert(intermediate)
216 .context("Failed to convert schema to compiled format")?;
217
218 info!("Analyzing schema for optimization opportunities...");
220 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
221
222 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
224
225 infer_native_columns_from_arg_types(&mut schema);
228
229 if let Some(db_url) = opts.database {
231 info!("Validating indexed columns against database...");
232 validate_indexed_columns(&schema, db_url).await?;
233
234 info!("Validating native columns for direct query arguments...");
235 let pg_introspector = build_postgres_introspector(db_url)
236 .context("Failed to connect for native column validation")?;
237 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
238 for w in &db_report.warnings {
239 warn!("{w}");
240 }
241 for query in &mut schema.queries {
243 if let Some(cols) = db_report.native_columns.get(&query.name) {
244 query.native_columns = cols.clone();
245 }
246 }
247 } else {
248 for query in &schema.queries {
251 if query.sql_source.is_none() {
252 continue;
253 }
254 let unresolved: Vec<_> = query
255 .arguments
256 .iter()
257 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
258 .filter(|a| !query.native_columns.contains_key(&a.name))
259 .collect();
260 if !unresolved.is_empty() {
261 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
262 warn!(
263 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
264 columns — no --database URL provided. These filters will use JSONB \
265 extraction. Provide --database or annotate with native_columns.",
266 query.name,
267 names,
268 query.sql_source.as_deref().unwrap_or("?"),
269 );
270 }
271 }
272 }
273
274 check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
276
277 warn_wide_cascade_mutations(&schema);
279
280 Ok((schema, report))
281}
282
283#[allow(clippy::too_many_arguments)] pub async fn run(
315 input: &str,
316 types: Option<&str>,
317 schema_dir: Option<&str>,
318 type_files: Vec<String>,
319 query_files: Vec<String>,
320 mutation_files: Vec<String>,
321 output: &str,
322 check: bool,
323 database: Option<&str>,
324) -> Result<()> {
325 let opts = CompileOptions {
326 input,
327 types,
328 schema_dir,
329 type_files,
330 query_files,
331 mutation_files,
332 database,
333 };
334 let (schema, optimization_report) = compile_to_schema(opts).await?;
335
336 if check {
338 println!("✓ Schema is valid");
339 println!(" Types: {}", schema.types.len());
340 println!(" Queries: {}", schema.queries.len());
341 println!(" Mutations: {}", schema.mutations.len());
342 optimization_report.print();
343 return Ok(());
344 }
345
346 info!("Writing compiled schema to: {output}");
348 let output_json =
349 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
350 fs::write(output, output_json).context("Failed to write compiled schema")?;
351
352 println!("✓ Schema compiled successfully");
354 println!(" Input: {input}");
355 println!(" Output: {output}");
356 println!(" Types: {}", schema.types.len());
357 println!(" Queries: {}", schema.queries.len());
358 println!(" Mutations: {}", schema.mutations.len());
359 optimization_report.print();
360
361 Ok(())
362}
363
364fn check_sqlite_compatibility_warnings(
369 schema: &CompiledSchema,
370 input_path: &str,
371 is_toml: bool,
372 database_url: Option<&str>,
373) {
374 let target_is_sqlite = database_url
375 .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
376 || is_toml && detect_sqlite_target_in_toml(input_path);
377
378 if !target_is_sqlite {
379 return;
380 }
381
382 let mutation_count = schema.mutations.len();
383 let relay_count = schema.queries.iter().filter(|q| q.relay).count();
384 let subscription_count = schema.subscriptions.len();
385
386 if mutation_count > 0 {
387 warn!(
388 "Schema contains {} mutation(s) but target database is SQLite. \
389 Mutations are not supported on SQLite. \
390 See: https://fraiseql.dev/docs/database-compatibility",
391 mutation_count,
392 );
393 }
394 if relay_count > 0 {
395 warn!(
396 "Schema contains {} relay query/queries but target database is SQLite. \
397 Relay (keyset pagination) is not supported on SQLite. \
398 See: https://fraiseql.dev/docs/database-compatibility",
399 relay_count,
400 );
401 }
402 if subscription_count > 0 {
403 warn!(
404 "Schema contains {} subscription(s) but target database is SQLite. \
405 Subscriptions are not supported on SQLite. \
406 See: https://fraiseql.dev/docs/database-compatibility",
407 subscription_count,
408 );
409 }
410}
411
412fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
417 let Ok(content) = fs::read_to_string(toml_path) else {
418 return false;
419 };
420 let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
421 return false;
422 };
423 toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
424}
425
426const WIDE_FANOUT_THRESHOLD: usize = 3;
429
430fn wide_cascade_mutations(
436 schema: &CompiledSchema,
437 threshold: usize,
438) -> Vec<&fraiseql_core::schema::MutationDefinition> {
439 schema
440 .mutations
441 .iter()
442 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
443 .collect()
444}
445
446fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
459 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
460 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
461
462 let mut targets: Vec<&str> = mutation
464 .invalidates_views
465 .iter()
466 .chain(mutation.invalidates_fact_tables.iter())
467 .map(String::as_str)
468 .collect();
469 targets.sort_unstable();
470 targets.dedup();
471
472 let alter_stmts: Vec<String> = targets
475 .iter()
476 .map(|&name| {
477 let table = name
478 .strip_prefix("tv_")
479 .or_else(|| name.strip_prefix("v_"))
480 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
481 format!("ALTER TABLE {table} SET (fillfactor = 75);")
482 })
483 .collect();
484
485 warn!(
486 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
487 Under high write load, HOT-update page slots on these tables may be \
488 exhausted, forcing full-page writes and reducing mutation throughput. \
489 Set fillfactor=70-80 on the backing tables: {} \
490 Monitor HOT efficiency: SELECT relname, \
491 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
492 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
493 mutation.name,
494 total,
495 targets.join(", "),
496 alter_stmts.join(" "),
497 );
498 }
499}
500
501fn build_postgres_introspector(
509 db_url: &str,
510) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
511 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
512 use tokio_postgres::NoTls;
513
514 let mut cfg = Config::new();
515 cfg.url = Some(db_url.to_string());
516 cfg.manager = Some(ManagerConfig {
517 recycling_method: RecyclingMethod::Fast,
518 });
519 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
520
521 let pool = cfg
522 .create_pool(Some(Runtime::Tokio1), NoTls)
523 .context("Failed to create connection pool for database validation")?;
524
525 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
526}
527
528async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
543 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
544 use fraiseql_core::db::postgres::PostgresIntrospector;
545 use tokio_postgres::NoTls;
546
547 let mut cfg = Config::new();
549 cfg.url = Some(db_url.to_string());
550 cfg.manager = Some(ManagerConfig {
551 recycling_method: RecyclingMethod::Fast,
552 });
553 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
554
555 let pool = cfg
556 .create_pool(Some(Runtime::Tokio1), NoTls)
557 .context("Failed to create connection pool for indexed column validation")?;
558
559 let introspector = PostgresIntrospector::new(pool);
560
561 let mut total_indexed = 0;
562 let mut total_views = 0;
563
564 for query in &schema.queries {
566 if let Some(view_name) = &query.sql_source {
567 total_views += 1;
568
569 match introspector.get_indexed_nested_columns(view_name).await {
571 Ok(indexed_cols) => {
572 if !indexed_cols.is_empty() {
573 info!(
574 "View '{}': found {} indexed column(s): {:?}",
575 view_name,
576 indexed_cols.len(),
577 indexed_cols
578 );
579 total_indexed += indexed_cols.len();
580 }
581 },
582 Err(e) => {
583 warn!(
584 "Could not introspect view '{}': {}. Skipping indexed column check.",
585 view_name, e
586 );
587 },
588 }
589 }
590 }
591
592 println!("✓ Indexed column validation complete");
593 println!(" Views checked: {total_views}");
594 println!(" Indexed columns found: {total_indexed}");
595
596 Ok(())
597}
598
599const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
601 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
602];
603
604fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
616 for query in &mut schema.queries {
617 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
618 continue;
619 }
620 for arg in &query.arguments {
621 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
622 continue;
623 }
624 if query.native_columns.contains_key(&arg.name) {
625 continue; }
627 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
628 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
629 }
630 }
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use std::collections::HashMap;
637
638 use fraiseql_core::{
639 schema::{
640 ArgumentDefinition, AutoParams, CompiledSchema, CursorType, FieldDefinition,
641 FieldDenyPolicy, FieldType, MutationDefinition, QueryDefinition, TypeDefinition,
642 },
643 validation::CustomTypeRegistry,
644 };
645 use indexmap::IndexMap;
646
647 use super::{
648 WIDE_FANOUT_THRESHOLD, infer_native_columns_from_arg_types, wide_cascade_mutations,
649 };
650
651 fn mutation_with_fanout(
652 name: &str,
653 views: &[&str],
654 fact_tables: &[&str],
655 ) -> MutationDefinition {
656 let mut m = MutationDefinition::new(name, "SomeResult");
657 m.invalidates_views = views.iter().map(|s| (*s).to_string()).collect();
658 m.invalidates_fact_tables = fact_tables.iter().map(|s| (*s).to_string()).collect();
659 m
660 }
661
662 #[test]
663 fn test_wide_cascade_below_threshold_not_flagged() {
664 let schema = CompiledSchema {
665 mutations: vec![mutation_with_fanout("update", &["tv_user", "tv_post"], &[])],
666 ..Default::default()
667 };
668 assert!(
669 wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD).is_empty(),
670 "2 targets is below threshold of 3"
671 );
672 }
673
674 #[test]
675 fn test_wide_cascade_at_threshold_flagged() {
676 let schema = CompiledSchema {
677 mutations: vec![mutation_with_fanout(
678 "updateUserWithPosts",
679 &["tv_user", "tv_post", "tv_comment"],
680 &[],
681 )],
682 ..Default::default()
683 };
684 let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
685 assert_eq!(flagged.len(), 1);
686 assert_eq!(flagged[0].name, "updateUserWithPosts");
687 }
688
689 #[test]
690 fn test_wide_cascade_views_plus_fact_tables_counted_together() {
691 let schema = CompiledSchema {
692 mutations: vec![mutation_with_fanout(
693 "createOrder",
694 &["tv_order", "tv_order_item"],
695 &["tf_sales"],
696 )],
697 ..Default::default()
698 };
699 let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
700 assert_eq!(flagged.len(), 1, "2 views + 1 fact table = 3 total, meets threshold");
701 }
702
703 #[test]
704 fn test_wide_cascade_only_wide_mutations_flagged() {
705 let schema = CompiledSchema {
706 mutations: vec![
707 mutation_with_fanout("narrow", &["tv_user"], &[]),
708 mutation_with_fanout("wide", &["tv_user", "tv_post", "tv_comment"], &[]),
709 ],
710 ..Default::default()
711 };
712 let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
713 assert_eq!(flagged.len(), 1);
714 assert_eq!(flagged[0].name, "wide");
715 }
716
717 #[test]
718 fn test_wide_cascade_no_mutations_no_warnings() {
719 let schema = CompiledSchema::default();
720 assert!(wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD).is_empty());
721 }
722
723 #[test]
724 fn test_validate_schema_success() {
725 let schema = CompiledSchema {
726 types: vec![TypeDefinition {
727 name: "User".into(),
728 fields: vec![
729 FieldDefinition {
730 name: "id".into(),
731 field_type: FieldType::Int,
732 nullable: false,
733 default_value: None,
734 description: None,
735 vector_config: None,
736 alias: None,
737 deprecation: None,
738 requires_scope: None,
739 on_deny: FieldDenyPolicy::default(),
740 encryption: None,
741 },
742 FieldDefinition {
743 name: "name".into(),
744 field_type: FieldType::String,
745 nullable: false,
746 default_value: None,
747 description: None,
748 vector_config: None,
749 alias: None,
750 deprecation: None,
751 requires_scope: None,
752 on_deny: FieldDenyPolicy::default(),
753 encryption: None,
754 },
755 ],
756 description: Some("User type".to_string()),
757 sql_source: String::new().into(),
758 jsonb_column: String::new(),
759 sql_projection_hint: None,
760 implements: vec![],
761 requires_role: None,
762 is_error: false,
763 relay: false,
764 relationships: Vec::new(),
765 }],
766 queries: vec![QueryDefinition {
767 name: "users".to_string(),
768 return_type: "User".to_string(),
769 returns_list: true,
770 nullable: false,
771 arguments: vec![],
772 sql_source: Some("v_user".to_string()),
773 description: Some("Get users".to_string()),
774 auto_params: AutoParams::default(),
775 deprecation: None,
776 jsonb_column: "data".to_string(),
777 relay: false,
778 relay_cursor_column: None,
779 relay_cursor_type: CursorType::default(),
780 inject_params: IndexMap::default(),
781 cache_ttl_seconds: None,
782 additional_views: vec![],
783 requires_role: None,
784 rest_path: None,
785 rest_method: None,
786 native_columns: HashMap::new(),
787 }],
788 enums: vec![],
789 input_types: vec![],
790 interfaces: vec![],
791 unions: vec![],
792 mutations: vec![],
793 subscriptions: vec![],
794 directives: vec![],
795 observers: Vec::new(),
796 fact_tables: HashMap::default(),
797 federation: None,
798 security: None,
799 observers_config: None,
800 subscriptions_config: None,
801 validation_config: None,
802 debug_config: None,
803 mcp_config: None,
804 schema_sdl: None,
805 schema_format_version: None,
809 custom_scalars: CustomTypeRegistry::default(),
810 ..Default::default()
811 };
812
813 assert_eq!(schema.types.len(), 1);
816 assert_eq!(schema.queries.len(), 1);
817 }
818
819 #[test]
820 fn test_validate_schema_unknown_type() {
821 let schema = CompiledSchema {
822 types: vec![],
823 enums: vec![],
824 input_types: vec![],
825 interfaces: vec![],
826 unions: vec![],
827 queries: vec![QueryDefinition {
828 name: "users".to_string(),
829 return_type: "UnknownType".to_string(),
830 returns_list: true,
831 nullable: false,
832 arguments: vec![],
833 sql_source: Some("v_user".to_string()),
834 description: Some("Get users".to_string()),
835 auto_params: AutoParams::default(),
836 deprecation: None,
837 jsonb_column: "data".to_string(),
838 relay: false,
839 relay_cursor_column: None,
840 relay_cursor_type: CursorType::default(),
841 inject_params: IndexMap::default(),
842 cache_ttl_seconds: None,
843 additional_views: vec![],
844 requires_role: None,
845 rest_path: None,
846 rest_method: None,
847 native_columns: HashMap::new(),
848 }],
849 mutations: vec![],
850 subscriptions: vec![],
851 directives: vec![],
852 observers: Vec::new(),
853 fact_tables: HashMap::default(),
854 federation: None,
855 security: None,
856 observers_config: None,
857 subscriptions_config: None,
858 validation_config: None,
859 debug_config: None,
860 mcp_config: None,
861 schema_sdl: None,
862 schema_format_version: None,
866 custom_scalars: CustomTypeRegistry::default(),
867 ..Default::default()
868 };
869
870 assert_eq!(schema.types.len(), 0);
873 assert_eq!(schema.queries[0].return_type, "UnknownType");
874 }
875
876 fn make_query(
877 name: &str,
878 sql_source: Option<&str>,
879 jsonb_column: &str,
880 args: Vec<(&str, FieldType)>,
881 native_columns: std::collections::HashMap<String, String>,
882 ) -> QueryDefinition {
883 QueryDefinition {
884 name: name.to_string(),
885 return_type: "T".to_string(),
886 returns_list: false,
887 nullable: true,
888 arguments: args.into_iter().map(|(n, t)| ArgumentDefinition::new(n, t)).collect(),
889 sql_source: sql_source.map(str::to_string),
890 jsonb_column: jsonb_column.to_string(),
891 native_columns,
892 auto_params: AutoParams::default(),
893 ..Default::default()
894 }
895 }
896
897 #[test]
898 fn test_infer_id_arg_becomes_uuid_native_column() {
899 let mut schema = CompiledSchema {
900 queries: vec![make_query(
901 "user",
902 Some("tv_user"),
903 "data",
904 vec![("id", FieldType::Id)],
905 std::collections::HashMap::new(),
906 )],
907 ..Default::default()
908 };
909 infer_native_columns_from_arg_types(&mut schema);
910 assert_eq!(
911 schema.queries[0].native_columns.get("id").map(String::as_str),
912 Some("uuid"),
913 "ID-typed arg should be inferred as uuid native column"
914 );
915 }
916
917 #[test]
918 fn test_infer_uuid_arg_becomes_uuid_native_column() {
919 let mut schema = CompiledSchema {
920 queries: vec![make_query(
921 "user",
922 Some("tv_user"),
923 "data",
924 vec![("userId", FieldType::Uuid)],
925 std::collections::HashMap::new(),
926 )],
927 ..Default::default()
928 };
929 infer_native_columns_from_arg_types(&mut schema);
930 assert_eq!(
931 schema.queries[0].native_columns.get("userId").map(String::as_str),
932 Some("uuid")
933 );
934 }
935
936 #[test]
937 fn test_infer_does_not_override_explicit_declaration() {
938 let mut explicit = std::collections::HashMap::new();
939 explicit.insert("id".to_string(), "text".to_string()); let mut schema = CompiledSchema {
941 queries: vec![make_query(
942 "user",
943 Some("tv_user"),
944 "data",
945 vec![("id", FieldType::Id)],
946 explicit,
947 )],
948 ..Default::default()
949 };
950 infer_native_columns_from_arg_types(&mut schema);
951 assert_eq!(
953 schema.queries[0].native_columns.get("id").map(String::as_str),
954 Some("text"),
955 "explicit native_columns declaration must win over inference"
956 );
957 }
958
959 #[test]
960 fn test_infer_skips_queries_without_sql_source() {
961 let mut schema = CompiledSchema {
962 queries: vec![make_query(
963 "user",
964 None,
965 "data",
966 vec![("id", FieldType::Id)],
967 std::collections::HashMap::new(),
968 )],
969 ..Default::default()
970 };
971 infer_native_columns_from_arg_types(&mut schema);
972 assert!(
973 schema.queries[0].native_columns.is_empty(),
974 "queries without sql_source must not get inferred native_columns"
975 );
976 }
977
978 #[test]
979 fn test_infer_skips_queries_without_jsonb_column() {
980 let mut schema = CompiledSchema {
981 queries: vec![make_query(
982 "user",
983 Some("v_user"),
984 "", vec![("id", FieldType::Id)],
986 std::collections::HashMap::new(),
987 )],
988 ..Default::default()
989 };
990 infer_native_columns_from_arg_types(&mut schema);
991 assert!(
992 schema.queries[0].native_columns.is_empty(),
993 "queries without jsonb_column must not get inferred native_columns"
994 );
995 }
996
997 #[test]
998 fn test_infer_skips_non_id_types() {
999 let mut schema = CompiledSchema {
1000 queries: vec![make_query(
1001 "user",
1002 Some("tv_user"),
1003 "data",
1004 vec![("username", FieldType::String), ("age", FieldType::Int)],
1005 std::collections::HashMap::new(),
1006 )],
1007 ..Default::default()
1008 };
1009 infer_native_columns_from_arg_types(&mut schema);
1010 assert!(
1011 schema.queries[0].native_columns.is_empty(),
1012 "String/Int args must not be inferred as native columns"
1013 );
1014 }
1015
1016 #[test]
1017 fn test_infer_skips_auto_param_names() {
1018 let mut schema = CompiledSchema {
1019 queries: vec![make_query(
1020 "users",
1021 Some("tv_user"),
1022 "data",
1023 vec![
1024 ("where", FieldType::Id),
1025 ("limit", FieldType::Id),
1026 ("orderBy", FieldType::Id),
1027 ],
1028 std::collections::HashMap::new(),
1029 )],
1030 ..Default::default()
1031 };
1032 infer_native_columns_from_arg_types(&mut schema);
1033 assert!(
1034 schema.queries[0].native_columns.is_empty(),
1035 "auto-param names must never be inferred as native columns even if typed ID"
1036 );
1037 }
1038}