1use std::{fs, path::Path, process::Command};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{
9 CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType, NamingConvention, canonicalize_json,
10};
11use tracing::{info, warn};
12
13use crate::{
14 config::TomlProjectConfig,
15 schema::{
16 ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer,
17 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}
44
45impl<'a> CompileOptions<'a> {
46 #[must_use]
48 pub fn new(input: &'a str) -> Self {
49 Self {
50 input,
51 ..Default::default()
52 }
53 }
54
55 #[must_use]
57 pub fn with_types(mut self, types: &'a str) -> Self {
58 self.types = Some(types);
59 self
60 }
61
62 #[must_use]
64 pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
65 self.schema_dir = Some(schema_dir);
66 self
67 }
68
69 #[must_use]
71 pub fn with_database(mut self, database: &'a str) -> Self {
72 self.database = Some(database);
73 self
74 }
75}
76
77#[allow(clippy::cognitive_complexity)] fn load_intermediate_schema(
86 toml_path: &str,
87 type_files: &[String],
88 query_files: &[String],
89 mutation_files: &[String],
90 schema_dir: Option<&str>,
91 types_path: Option<&str>,
92) -> Result<IntermediateSchema> {
93 if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
94 info!("Mode: Explicit file lists");
95 return crate::schema::SchemaMerger::merge_explicit_files(
96 toml_path,
97 type_files,
98 query_files,
99 mutation_files,
100 )
101 .context("Failed to load explicit schema files");
102 }
103 if let Some(dir) = schema_dir {
104 info!("Mode: Auto-discovery from directory: {}", dir);
105 return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
106 .context("Failed to load schema from directory");
107 }
108 if let Some(types) = types_path {
109 info!("Mode: Language + TOML (types.json + fraiseql.toml)");
110 return crate::schema::SchemaMerger::merge_files(types, toml_path)
111 .context("Failed to merge types.json with TOML");
112 }
113 info!("Mode: TOML-based (checking for domain discovery...)");
114 if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
115 return Ok(schema);
116 }
117 info!("No domains configured, checking for TOML includes...");
118 if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
119 return Ok(schema);
120 }
121 info!("No includes configured, using TOML-only definitions");
122 crate::schema::SchemaMerger::merge_toml_only(toml_path)
123 .context("Failed to load schema from TOML")
124}
125
126#[allow(clippy::cognitive_complexity)] pub async fn compile_to_schema(
141 opts: CompileOptions<'_>,
142) -> Result<(CompiledSchema, OptimizationReport)> {
143 info!("Compiling schema: {}", opts.input);
144
145 let input_path = Path::new(opts.input);
147 if !input_path.exists() {
148 anyhow::bail!("Input file not found: {}", opts.input);
149 }
150
151 let is_toml = input_path
153 .extension()
154 .and_then(|ext| ext.to_str())
155 .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
156 let mut intermediate: IntermediateSchema = if is_toml {
157 info!("Using TOML-based workflow");
158 load_intermediate_schema(
159 opts.input,
160 &opts.type_files,
161 &opts.query_files,
162 &opts.mutation_files,
163 opts.schema_dir,
164 opts.types,
165 )?
166 } else {
167 info!("Using legacy JSON workflow");
169 let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
170
171 info!("Parsing intermediate schema...");
173 serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
174 };
175
176 let mut auto_error_union = false;
182 let mut naming_acronyms: Vec<String> = Vec::new();
184 let mut operation_cost_weights: std::collections::HashMap<String, usize> =
186 std::collections::HashMap::new();
187 let mut naming_convention = NamingConvention::CamelCase;
193 if !is_toml && Path::new("fraiseql.toml").exists() {
194 info!("Loading security configuration from fraiseql.toml...");
195 match TomlProjectConfig::from_file("fraiseql.toml") {
196 Ok(config) => {
197 info!("Validating security configuration...");
198 config.validate()?;
199
200 auto_error_union = config.fraiseql.mutations.auto_error_union;
201 naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
202 operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
203 naming_convention = config.fraiseql.naming.convention;
204
205 info!("Applying security configuration to schema...");
206 let mut security_json = config.fraiseql.security.to_json();
208
209 if !matches!(
211 config.fraiseql.tenancy.mode,
212 crate::config::security::TenancyModeConfig::None
213 ) {
214 security_json["tenancy"] = config.fraiseql.tenancy.to_json();
215 }
216
217 intermediate.security = Some(security_json);
218
219 info!("Security configuration applied successfully");
220 },
221 Err(e) => {
222 anyhow::bail!(
223 "Failed to parse fraiseql.toml: {e}\n\
224 Fix the configuration file or remove it to use defaults."
225 );
226 },
227 }
228 } else {
229 info!("No fraiseql.toml found, using default security configuration");
230 }
231
232 fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
236
237 if !is_toml {
243 intermediate.naming_convention = naming_convention;
244 }
245
246 let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
249 let tenancy = sec.get("tenancy")?;
250 let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
251 if mode == "row" {
252 Some(
253 tenancy
254 .get("tenantClaim")
255 .and_then(|c| c.as_str())
256 .unwrap_or("tenant_id")
257 .to_string(),
258 )
259 } else {
260 None
261 }
262 });
263 if let Some(tenant_claim) = &tenancy_row_claim {
264 info!("Validating @tenant_id annotations for row-isolation tenancy...");
265 crate::schema::converter::tenancy::validate_tenant_annotations(
266 &mut intermediate,
267 tenant_claim,
268 )
269 .context("@tenant_id validation failed")?;
270 }
271
272 info!("Validating schema structure...");
274 let validation_report =
275 SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
276
277 if !validation_report.is_valid() {
278 validation_report.print();
279 anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
280 }
281
282 if validation_report.warning_count() > 0 {
284 validation_report.print();
285 }
286
287 info!("Converting to compiled format...");
289 let mut schema =
290 SchemaConverter::convert_with_options(intermediate, &ConvertOptions { auto_error_union })
291 .context("Failed to convert schema to compiled format")?;
292
293 schema.naming_acronyms = naming_acronyms;
296
297 if !operation_cost_weights.is_empty() {
301 schema.operation_cost_weights = operation_cost_weights;
302 }
303
304 info!("Analyzing schema for optimization opportunities...");
306 let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
307
308 schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
310
311 infer_native_columns_from_arg_types(&mut schema);
314
315 if let Some(db_url) = opts.database {
317 info!("Validating indexed columns against database...");
318 validate_indexed_columns(&schema, db_url).await?;
319
320 info!("Validating native columns for direct query arguments...");
321 let pg_introspector = build_postgres_introspector(db_url)
322 .context("Failed to connect for native column validation")?;
323 let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
324 for w in &db_report.warnings {
325 warn!("{w}");
326 }
327 for query in &mut schema.queries {
329 if let Some(cols) = db_report.native_columns.get(&query.name) {
330 query.native_columns = cols.clone();
331 }
332 }
333
334 if db_url.starts_with("postgres") {
339 info!("Validating mutation contract against the database...");
340 let catalog = PgCatalog::connect(db_url)
341 .context("Failed to connect for mutation-contract validation")?;
342 let contract = validate_mutation_contract(&schema, &catalog).await?;
343 for m in &contract.mutations {
344 for v in &m.violations {
345 let kind = match v.severity() {
346 Severity::Error => "contract error",
347 Severity::Warn => "contract warning",
348 };
349 warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
350 }
351 }
352 }
353 } else {
354 for query in &schema.queries {
357 if query.sql_source.is_none() {
358 continue;
359 }
360 let unresolved: Vec<_> = query
361 .arguments
362 .iter()
363 .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
364 .filter(|a| !query.native_columns.contains_key(&a.name))
365 .collect();
366 if !unresolved.is_empty() {
367 let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
368 warn!(
369 "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
370 columns — no --database URL provided. These filters will use JSONB \
371 extraction. Provide --database or annotate with native_columns.",
372 query.name,
373 names,
374 query.sql_source.as_deref().unwrap_or("?"),
375 );
376 }
377 }
378 }
379
380 check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
382
383 warn_wide_cascade_mutations(&schema);
385
386 Ok((schema, report))
387}
388
389#[allow(clippy::too_many_arguments)]
418#[doc(hidden)] pub async fn run(
422 input: &str,
423 types: Option<&str>,
424 schema_dir: Option<&str>,
425 type_files: Vec<String>,
426 query_files: Vec<String>,
427 mutation_files: Vec<String>,
428 output: &str,
429 check: bool,
430 database: Option<&str>,
431 emit_ddl: Option<&str>,
432 check_migrations: bool,
433 skip_hash: bool,
434) -> Result<()> {
435 if !check && Path::new(output) == Path::new(input) {
440 anyhow::bail!(
441 "Refusing to write compiled output over the input file '{input}'. \
442 Use a distinct --output path (e.g. schema.compiled.json)."
443 );
444 }
445
446 let opts = CompileOptions {
447 input,
448 types,
449 schema_dir,
450 type_files,
451 query_files,
452 mutation_files,
453 database,
454 skip_hash,
455 };
456 let (schema, optimization_report) = compile_to_schema(opts).await?;
457
458 if check {
460 println!("✓ Schema is valid");
461 println!(" Types: {}", schema.types.len());
462 println!(" Queries: {}", schema.queries.len());
463 println!(" Mutations: {}", schema.mutations.len());
464 optimization_report.print();
465 return Ok(());
466 }
467
468 info!("Writing compiled schema to: {output}");
470 let output_json = if skip_hash {
471 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?
472 } else {
473 use sha2::{Digest, Sha256};
474
475 let body =
476 serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
477 let value: serde_json::Value = serde_json::from_str(&body)?;
478 let canonical = serde_json::to_string_pretty(&canonicalize_json(&value))?;
480 let hash = Sha256::digest(canonical.as_bytes());
481 let hash_hex = hex::encode(&hash[..16]);
482
483 let obj = value.as_object().context("schema must serialise as JSON object")?;
484
485 let mut new_obj = serde_json::Map::new();
487 new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
488 for (k, v) in obj {
489 new_obj.insert(k.clone(), v.clone());
490 }
491 serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
492 };
493
494 fs::write(output, output_json).context("Failed to write compiled schema")?;
495
496 println!("✓ Schema compiled successfully");
498 println!(" Input: {input}");
499 println!(" Output: {output}");
500 println!(" Types: {}", schema.types.len());
501 println!(" Queries: {}", schema.queries.len());
502 println!(" Mutations: {}", schema.mutations.len());
503 optimization_report.print();
504
505 if let Some(ddl_dir) = emit_ddl {
507 emit_ddl_to_dir(&schema, ddl_dir)?;
508 }
509
510 if check_migrations {
512 run_check_migrations(&schema)?;
513 }
514
515 Ok(())
516}
517
518pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
532 fs::create_dir_all(output_dir)
533 .context(format!("Failed to create DDL output directory: {output_dir}"))?;
534
535 let mut count = 0;
536 for type_def in &schema.types {
537 let table_name = to_snake_case(type_def.name.as_str());
538 let ddl = build_create_table_ddl(&table_name, type_def);
539
540 let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
541 fs::write(&file_path, ddl)
542 .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
543 count += 1;
544 }
545
546 println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
547 Ok(())
548}
549
550fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
560 let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
561 let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
562
563 emit_ddl_to_dir(schema, tmp_path)?;
564
565 info!("Running confiture migrate validate for drift detection...");
566
567 let status = Command::new("confiture").args(["migrate", "validate"]).status();
568
569 match status {
570 Err(_) => {
571 eprintln!(
573 "WARN: confiture is not installed; skipping migration drift check.\n\
574 Install it with: cargo install confiture"
575 );
576 Ok(())
577 },
578 Ok(s) if s.success() => {
579 println!("✓ No migration drift detected.");
580 Ok(())
581 },
582 Ok(_) => {
583 eprintln!(
584 "WARN: compiled schema diverges from database — run fraiseql migrate generate"
585 );
586 anyhow::bail!(
587 "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
588 )
589 },
590 }
591}
592
593pub(crate) fn to_snake_case(name: &str) -> String {
595 let mut result = String::with_capacity(name.len() + 4);
596 for (i, ch) in name.chars().enumerate() {
597 if ch.is_uppercase() && i > 0 {
598 result.push('_');
599 }
600 result.extend(ch.to_lowercase());
601 }
602 result
603}
604
605fn build_create_table_ddl(
607 table_name: &str,
608 type_def: &fraiseql_core::schema::TypeDefinition,
609) -> String {
610 let mut lines: Vec<String> = Vec::new();
611 lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
612 lines.push(format!("-- Type: {}", type_def.name));
613 if let Some(desc) = &type_def.description {
614 lines.push(format!("-- {desc}"));
615 }
616 lines.push(String::new());
617 lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
618
619 let col_lines: Vec<String> = type_def
620 .fields
621 .iter()
622 .map(|field| {
623 let col_name = to_snake_case(field.name.as_str());
624 let pg_type = field_type_to_pg(&field.field_type);
625 let nullable = if field.nullable { "" } else { " NOT NULL" };
626 format!(" {col_name} {pg_type}{nullable}")
627 })
628 .collect();
629
630 let last = col_lines.len().saturating_sub(1);
631 for (i, col) in col_lines.iter().enumerate() {
632 if i < last {
633 lines.push(format!("{col},"));
634 } else {
635 lines.push(col.clone());
636 }
637 }
638
639 lines.push(");".to_string());
640 lines.push(String::new());
641 lines.join("\n")
642}
643
644pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
646 match ft {
647 FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
648 FieldType::Int => "INTEGER".to_string(),
649 FieldType::Float => "DOUBLE PRECISION".to_string(),
650 FieldType::Boolean => "BOOLEAN".to_string(),
651 FieldType::Id | FieldType::Uuid => "UUID".to_string(),
652 FieldType::DateTime => "TIMESTAMPTZ".to_string(),
653 FieldType::Date => "DATE".to_string(),
654 FieldType::Time => "TIME".to_string(),
655 FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
656 FieldType::Decimal => "NUMERIC".to_string(),
657 FieldType::Vector => "VECTOR".to_string(),
658 FieldType::Enum(name) => name.clone(),
660 FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
661 _ => "TEXT".to_string(),
663 }
664}
665
666fn check_sqlite_compatibility_warnings(
672 schema: &CompiledSchema,
673 input_path: &str,
674 is_toml: bool,
675 database_url: Option<&str>,
676) {
677 let target_is_sqlite = database_url
678 .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
679 || is_toml && detect_sqlite_target_in_toml(input_path);
680
681 if !target_is_sqlite {
682 return;
683 }
684
685 let unsupported_mutation_count = schema
688 .mutations
689 .iter()
690 .filter(|m| {
691 matches!(
692 m.operation,
693 fraiseql_core::schema::MutationOperation::Update { .. }
694 | fraiseql_core::schema::MutationOperation::Custom
695 )
696 })
697 .count();
698 let relay_count = schema.queries.iter().filter(|q| q.relay).count();
699 let subscription_count = schema.subscriptions.len();
700
701 if unsupported_mutation_count > 0 {
702 warn!(
703 "Schema contains {} Update or custom mutation(s) but target database is SQLite. \
704 SQLite supports only direct-SQL Insert/Delete mutations. \
705 See: https://fraiseql.dev/docs/database-compatibility",
706 unsupported_mutation_count,
707 );
708 }
709 if relay_count > 0 {
710 warn!(
711 "Schema contains {} relay query/queries but target database is SQLite. \
712 Relay (keyset pagination) is not supported on SQLite. \
713 See: https://fraiseql.dev/docs/database-compatibility",
714 relay_count,
715 );
716 }
717 if subscription_count > 0 {
718 warn!(
719 "Schema contains {} subscription(s) but target database is SQLite. \
720 Subscriptions are not supported on SQLite. \
721 See: https://fraiseql.dev/docs/database-compatibility",
722 subscription_count,
723 );
724 }
725}
726
727fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
732 let Ok(content) = fs::read_to_string(toml_path) else {
733 return false;
734 };
735 let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
736 return false;
737 };
738 toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
739}
740
741pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
744
745pub(crate) fn wide_cascade_mutations(
751 schema: &CompiledSchema,
752 threshold: usize,
753) -> Vec<&fraiseql_core::schema::MutationDefinition> {
754 schema
755 .mutations
756 .iter()
757 .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
758 .collect()
759}
760
761fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
774 for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
775 let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
776
777 let mut targets: Vec<&str> = mutation
779 .invalidates_views
780 .iter()
781 .chain(mutation.invalidates_fact_tables.iter())
782 .map(String::as_str)
783 .collect();
784 targets.sort_unstable();
785 targets.dedup();
786
787 let alter_stmts: Vec<String> = targets
790 .iter()
791 .map(|&name| {
792 let table = name
793 .strip_prefix("tv_")
794 .or_else(|| name.strip_prefix("v_"))
795 .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
796 format!("ALTER TABLE {table} SET (fillfactor = 75);")
797 })
798 .collect();
799
800 warn!(
801 "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
802 Under high write load, HOT-update page slots on these tables may be \
803 exhausted, forcing full-page writes and reducing mutation throughput. \
804 Set fillfactor=70-80 on the backing tables: {} \
805 Monitor HOT efficiency: SELECT relname, \
806 n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
807 FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
808 mutation.name,
809 total,
810 targets.join(", "),
811 alter_stmts.join(" "),
812 );
813 }
814}
815
816fn build_postgres_introspector(
824 db_url: &str,
825) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
826 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
827 use tokio_postgres::NoTls;
828
829 let mut cfg = Config::new();
830 cfg.url = Some(db_url.to_string());
831 cfg.manager = Some(ManagerConfig {
832 recycling_method: RecyclingMethod::Fast,
833 });
834 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
835
836 let pool = cfg
837 .create_pool(Some(Runtime::Tokio1), NoTls)
838 .context("Failed to create connection pool for database validation")?;
839
840 Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
841}
842
843async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
858 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
859 use fraiseql_core::db::postgres::PostgresIntrospector;
860 use tokio_postgres::NoTls;
861
862 let mut cfg = Config::new();
864 cfg.url = Some(db_url.to_string());
865 cfg.manager = Some(ManagerConfig {
866 recycling_method: RecyclingMethod::Fast,
867 });
868 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
869
870 let pool = cfg
871 .create_pool(Some(Runtime::Tokio1), NoTls)
872 .context("Failed to create connection pool for indexed column validation")?;
873
874 let introspector = PostgresIntrospector::new(pool);
875
876 let mut total_indexed = 0;
877 let mut total_views = 0;
878
879 for query in &schema.queries {
881 if let Some(view_name) = &query.sql_source {
882 total_views += 1;
883
884 match introspector.get_indexed_nested_columns(view_name).await {
886 Ok(indexed_cols) => {
887 if !indexed_cols.is_empty() {
888 info!(
889 "View '{}': found {} indexed column(s): {:?}",
890 view_name,
891 indexed_cols.len(),
892 indexed_cols
893 );
894 total_indexed += indexed_cols.len();
895 }
896 },
897 Err(e) => {
898 warn!(
899 "Could not introspect view '{}': {}. Skipping indexed column check.",
900 view_name, e
901 );
902 },
903 }
904 }
905 }
906
907 println!("✓ Indexed column validation complete");
908 println!(" Views checked: {total_views}");
909 println!(" Indexed columns found: {total_indexed}");
910
911 Ok(())
912}
913
914const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
916 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
917];
918
919pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
931 for query in &mut schema.queries {
932 if query.sql_source.is_none() || query.jsonb_column.is_empty() {
933 continue;
934 }
935 for arg in &query.arguments {
936 if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
937 continue;
938 }
939 if query.native_columns.contains_key(&arg.name) {
940 continue; }
942 if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
943 query.native_columns.insert(arg.name.clone(), "uuid".to_string());
944 }
945 }
946 }
947}