1use std::{
11 collections::{HashMap, HashSet},
12 fmt,
13};
14
15use fraiseql_core::{
16 db::{
17 DatabaseType,
18 introspector::{DatabaseIntrospector, RelationInfo},
19 },
20 schema::{CompiledSchema, FieldType},
21};
22
23pub struct DatabaseValidationReport {
25 pub warnings: Vec<DatabaseWarning>,
27 pub native_columns: HashMap<String, HashMap<String, String>>,
35}
36
37#[derive(Debug)]
39pub enum DatabaseWarning {
40 MissingRelation {
42 query_name: String,
44 sql_source: String,
46 },
47 MissingAdditionalView {
49 query_name: String,
51 view_name: String,
53 },
54 MissingJsonColumn {
56 query_name: String,
58 sql_source: String,
60 column_name: String,
62 },
63 WrongJsonColumnType {
65 query_name: String,
67 sql_source: String,
69 column_name: String,
71 actual_type: String,
73 },
74 MissingCursorColumn {
76 query_name: String,
78 sql_source: String,
80 column_name: String,
82 },
83 MissingJsonKey {
85 query_name: String,
87 type_name: String,
89 sql_source: String,
91 json_column: String,
93 field_name: String,
95 json_key: String,
97 },
98 NativeColumnFallback {
104 query_name: String,
106 sql_source: String,
108 arg_name: String,
110 },
111 TypeConvertibility {
116 query_name: String,
118 sql_source: String,
120 arg_name: String,
122 graphql_type: String,
124 column_type: String,
126 },
127}
128
129impl fmt::Display for DatabaseWarning {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 match self {
132 Self::MissingRelation {
133 query_name,
134 sql_source,
135 } => {
136 write!(
137 f,
138 "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
139 )
140 },
141 Self::MissingAdditionalView {
142 query_name,
143 view_name,
144 } => {
145 write!(
146 f,
147 "query `{query_name}`: additional_view `{view_name}` does not exist in database"
148 )
149 },
150 Self::MissingJsonColumn {
151 query_name,
152 sql_source,
153 column_name,
154 } => {
155 write!(
156 f,
157 "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
158 )
159 },
160 Self::WrongJsonColumnType {
161 query_name,
162 sql_source,
163 column_name,
164 actual_type,
165 } => {
166 write!(
167 f,
168 "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
169 )
170 },
171 Self::MissingCursorColumn {
172 query_name,
173 sql_source,
174 column_name,
175 } => {
176 write!(
177 f,
178 "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
179 )
180 },
181 Self::MissingJsonKey {
182 query_name,
183 type_name,
184 sql_source,
185 json_column,
186 field_name,
187 json_key,
188 } => {
189 write!(
190 f,
191 "query `{query_name}`: field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
192 )
193 },
194 Self::NativeColumnFallback {
195 query_name,
196 sql_source,
197 arg_name,
198 } => {
199 write!(
200 f,
201 "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
202 (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
203 `{sql_source}`. Add a native column with an index for O(log n) lookup."
204 )
205 },
206 Self::TypeConvertibility {
207 query_name,
208 sql_source,
209 arg_name,
210 graphql_type,
211 column_type,
212 } => {
213 write!(
214 f,
215 "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
216 `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
217 Align the argument type with the column, or store the field in `data` for JSONB extraction."
218 )
219 },
220 }
221 }
222}
223
224pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
226 let lower = data_type.to_lowercase();
227 match db_type {
228 DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
229 DatabaseType::MySQL => lower == "json",
230 DatabaseType::SQLite => lower.contains("json"),
231 DatabaseType::SQLServer => true,
234 }
235}
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239enum SqlFamily {
240 Numeric,
242 Boolean,
244 Uuid,
246 Text,
248 Temporal,
250 Json,
252 Other,
254}
255
256fn sql_type_family(sql_type: &str) -> SqlFamily {
261 let lower = sql_type.trim().to_lowercase();
262 let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
263 match base {
264 "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
265 | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
266 | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
267 "boolean" | "bool" | "bit" => SqlFamily::Boolean,
268 "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
269 "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
270 | "character" | "clob" | "string" => SqlFamily::Text,
271 "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
272 | "interval" | "smalldatetime" => SqlFamily::Temporal,
273 "json" | "jsonb" => SqlFamily::Json,
274 _ => SqlFamily::Other,
275 }
276}
277
278fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
287 let base = match field_type {
289 FieldType::List(inner) => inner.as_ref(),
290 other => other,
291 };
292 let family = sql_type_family(sql_type);
293 if family == SqlFamily::Other {
295 return true;
296 }
297 match base {
298 FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
299 FieldType::Boolean => family == SqlFamily::Boolean,
300 FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
301 _ => true,
302 }
303}
304
305fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
307 match sql_source.split_once('.') {
308 Some((schema, table)) => (Some(schema), table),
309 None => (None, sql_source),
310 }
311}
312
313fn relation_exists(
315 schema_qualified: &HashMap<(String, String), RelationInfo>,
316 unqualified: &HashMap<String, Vec<String>>,
317 sql_source: &str,
318) -> bool {
319 let (schema, name) = split_schema_qualified(sql_source);
320 if let Some(s) = schema {
321 schema_qualified.contains_key(&(s.to_string(), name.to_string()))
322 } else {
323 unqualified.contains_key(name)
324 }
325}
326
327pub(crate) fn to_snake_case(name: &str) -> String {
331 let mut result = String::with_capacity(name.len() + 4);
332 for (i, ch) in name.chars().enumerate() {
333 if ch.is_uppercase() {
334 if i > 0 {
335 result.push('_');
336 }
337 result.push(ch.to_lowercase().next().unwrap_or(ch));
338 } else {
339 result.push(ch);
340 }
341 }
342 result
343}
344
345pub async fn validate_schema_against_database(
358 schema: &CompiledSchema,
359 introspector: &impl DatabaseIntrospector,
360) -> fraiseql_core::Result<DatabaseValidationReport> {
361 const AUTO_PARAM_NAMES: &[&str] = &[
364 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
365 ];
366
367 let mut warnings = Vec::new();
368 let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
369 let db_type = introspector.database_type();
370
371 let relations = introspector.list_relations().await?;
373 let (schema_qualified, unqualified) = build_relation_maps(&relations);
374
375 for query in &schema.queries {
377 if let Some(ref source) = query.sql_source {
378 if !relation_exists(&schema_qualified, &unqualified, source) {
380 warnings.push(DatabaseWarning::MissingRelation {
381 query_name: query.name.clone(),
382 sql_source: source.clone(),
383 });
384 continue; }
386
387 let columns = introspector.get_columns(source).await?;
391 let column_map: HashMap<String, String> =
392 columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
393
394 let jsonb_col = &query.jsonb_column;
396 if !jsonb_col.is_empty() {
397 if let Some(actual_type) = column_map.get(jsonb_col) {
398 if !is_json_type(actual_type, db_type) {
399 warnings.push(DatabaseWarning::WrongJsonColumnType {
400 query_name: query.name.clone(),
401 sql_source: source.clone(),
402 column_name: jsonb_col.clone(),
403 actual_type: actual_type.clone(),
404 });
405 }
406 } else {
407 warnings.push(DatabaseWarning::MissingJsonColumn {
408 query_name: query.name.clone(),
409 sql_source: source.clone(),
410 column_name: jsonb_col.clone(),
411 });
412 }
413 }
414
415 if query.relay {
417 if let Some(ref cursor_col) = query.relay_cursor_column {
418 if !column_map.contains_key(cursor_col) {
419 warnings.push(DatabaseWarning::MissingCursorColumn {
420 query_name: query.name.clone(),
421 sql_source: source.clone(),
422 column_name: cursor_col.clone(),
423 });
424 }
425 }
426 }
427
428 if !jsonb_col.is_empty() {
430 let json_type_ok =
431 column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
432
433 if json_type_ok {
434 validate_json_keys(
435 schema,
436 query,
437 source,
438 jsonb_col,
439 introspector,
440 source, &mut warnings,
442 )
443 .await?;
444 }
445 }
446
447 let direct_args: Vec<&str> = query
452 .arguments
453 .iter()
454 .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
455 .map(|a| a.name.as_str())
456 .collect();
457
458 let (query_native, arg_fallbacks) = detect_query_native_columns(
459 &direct_args,
460 query.inject_params.keys().map(String::as_str),
461 &column_map,
462 );
463 for arg_name in arg_fallbacks {
464 warnings.push(DatabaseWarning::NativeColumnFallback {
465 query_name: query.name.clone(),
466 sql_source: source.clone(),
467 arg_name,
468 });
469 }
470
471 for arg in &query.arguments {
475 if let Some(col_type) = query_native.get(&arg.name) {
476 if !arg_type_convertible(&arg.arg_type, col_type) {
477 warnings.push(DatabaseWarning::TypeConvertibility {
478 query_name: query.name.clone(),
479 sql_source: source.clone(),
480 arg_name: arg.name.clone(),
481 graphql_type: arg.arg_type.to_graphql_string(),
482 column_type: col_type.clone(),
483 });
484 }
485 }
486 }
487
488 if !query_native.is_empty() {
489 native_columns.insert(query.name.clone(), query_native);
490 }
491
492 for view in &query.additional_views {
494 if !relation_exists(&schema_qualified, &unqualified, view) {
495 warnings.push(DatabaseWarning::MissingAdditionalView {
496 query_name: query.name.clone(),
497 view_name: view.clone(),
498 });
499 }
500 }
501 }
502 }
503
504 for mutation in &schema.mutations {
506 if let Some(ref source) = mutation.sql_source {
507 if !relation_exists(&schema_qualified, &unqualified, source) {
508 warnings.push(DatabaseWarning::MissingRelation {
509 query_name: mutation.name.clone(),
510 sql_source: source.clone(),
511 });
512 }
513 }
514 }
515
516 Ok(DatabaseValidationReport {
517 warnings,
518 native_columns,
519 })
520}
521
522fn build_relation_maps(
524 relations: &[RelationInfo],
525) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
526 let mut schema_qualified = HashMap::new();
527 let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
528
529 for rel in relations {
530 schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
531 unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
532 }
533
534 (schema_qualified, unqualified)
535}
536
537async fn validate_json_keys(
539 schema: &CompiledSchema,
540 query: &fraiseql_core::schema::QueryDefinition,
541 source: &str,
542 jsonb_col: &str,
543 introspector: &impl DatabaseIntrospector,
544 table_name: &str,
545 warnings: &mut Vec<DatabaseWarning>,
546) -> fraiseql_core::Result<()> {
547 let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
548
549 if samples.is_empty() {
550 return Ok(());
551 }
552
553 let mut all_keys = HashSet::new();
555 for sample in &samples {
556 if let serde_json::Value::Object(map) = sample {
557 for key in map.keys() {
558 all_keys.insert(key.clone());
559 }
560 }
561 }
562
563 if all_keys.is_empty() {
564 return Ok(());
565 }
566
567 let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
569
570 if let Some(type_def) = type_def {
571 for field in &type_def.fields {
572 let field_str = field.name.as_str();
573 let json_key = to_snake_case(field_str);
574 if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
577 continue;
578 }
579 if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
580 warnings.push(DatabaseWarning::MissingJsonKey {
581 query_name: query.name.clone(),
582 type_name: type_def.name.as_str().to_string(),
583 sql_source: source.to_string(),
584 json_column: jsonb_col.to_string(),
585 field_name: field_str.to_string(),
586 json_key,
587 });
588 }
589 }
590 }
591
592 Ok(())
593}
594
595pub enum AnyIntrospector {
600 Postgres(fraiseql_core::db::PostgresIntrospector),
602 #[cfg(feature = "mysql")]
603 MySql(fraiseql_core::db::MySqlIntrospector),
605 #[cfg(feature = "sqlite")]
606 Sqlite(fraiseql_core::db::SqliteIntrospector),
608 #[cfg(feature = "sqlserver")]
609 SqlServer(fraiseql_core::db::SqlServerIntrospector),
611}
612
613impl DatabaseIntrospector for AnyIntrospector {
614 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
615 match self {
616 Self::Postgres(i) => i.list_fact_tables().await,
617 #[cfg(feature = "mysql")]
618 Self::MySql(i) => i.list_fact_tables().await,
619 #[cfg(feature = "sqlite")]
620 Self::Sqlite(i) => i.list_fact_tables().await,
621 #[cfg(feature = "sqlserver")]
622 Self::SqlServer(i) => i.list_fact_tables().await,
623 }
624 }
625
626 async fn get_columns(
627 &self,
628 table_name: &str,
629 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
630 match self {
631 Self::Postgres(i) => i.get_columns(table_name).await,
632 #[cfg(feature = "mysql")]
633 Self::MySql(i) => i.get_columns(table_name).await,
634 #[cfg(feature = "sqlite")]
635 Self::Sqlite(i) => i.get_columns(table_name).await,
636 #[cfg(feature = "sqlserver")]
637 Self::SqlServer(i) => i.get_columns(table_name).await,
638 }
639 }
640
641 async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
642 match self {
643 Self::Postgres(i) => i.get_indexed_columns(table_name).await,
644 #[cfg(feature = "mysql")]
645 Self::MySql(i) => i.get_indexed_columns(table_name).await,
646 #[cfg(feature = "sqlite")]
647 Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
648 #[cfg(feature = "sqlserver")]
649 Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
650 }
651 }
652
653 fn database_type(&self) -> DatabaseType {
654 match self {
655 Self::Postgres(i) => i.database_type(),
656 #[cfg(feature = "mysql")]
657 Self::MySql(i) => i.database_type(),
658 #[cfg(feature = "sqlite")]
659 Self::Sqlite(i) => i.database_type(),
660 #[cfg(feature = "sqlserver")]
661 Self::SqlServer(i) => i.database_type(),
662 }
663 }
664
665 async fn get_sample_jsonb(
666 &self,
667 table_name: &str,
668 column_name: &str,
669 ) -> fraiseql_core::Result<Option<serde_json::Value>> {
670 match self {
671 Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
672 #[cfg(feature = "mysql")]
673 Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
674 #[cfg(feature = "sqlite")]
675 Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
676 #[cfg(feature = "sqlserver")]
677 Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
678 }
679 }
680
681 async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
682 match self {
683 Self::Postgres(i) => i.list_relations().await,
684 #[cfg(feature = "mysql")]
685 Self::MySql(i) => i.list_relations().await,
686 #[cfg(feature = "sqlite")]
687 Self::Sqlite(i) => i.list_relations().await,
688 #[cfg(feature = "sqlserver")]
689 Self::SqlServer(i) => i.list_relations().await,
690 }
691 }
692
693 async fn get_sample_json_rows(
694 &self,
695 table_name: &str,
696 column_name: &str,
697 limit: usize,
698 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
699 match self {
700 Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
701 #[cfg(feature = "mysql")]
702 Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
703 #[cfg(feature = "sqlite")]
704 Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
705 #[cfg(feature = "sqlserver")]
706 Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
707 }
708 }
709}
710
711#[allow(clippy::unused_async)] pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
722 if db_url.starts_with("postgres") {
723 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
724 use tokio_postgres::NoTls;
725
726 let mut cfg = Config::new();
727 cfg.url = Some(db_url.to_string());
728 cfg.manager = Some(ManagerConfig {
729 recycling_method: RecyclingMethod::Fast,
730 });
731 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
732
733 let pool = cfg
734 .create_pool(Some(Runtime::Tokio1), NoTls)
735 .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
736
737 Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
738 } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
739 #[cfg(feature = "mysql")]
740 {
741 use sqlx::mysql::MySqlPool;
742
743 let pool = MySqlPool::connect(db_url)
744 .await
745 .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
746
747 Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
748 }
749 #[cfg(not(feature = "mysql"))]
750 {
751 anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
752 }
753 } else if db_url.starts_with("sqlite")
754 || std::path::Path::new(db_url)
755 .extension()
756 .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
757 {
758 #[cfg(feature = "sqlite")]
759 {
760 use sqlx::sqlite::SqlitePool;
761
762 let pool = SqlitePool::connect(db_url)
763 .await
764 .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
765
766 Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
767 }
768 #[cfg(not(feature = "sqlite"))]
769 {
770 anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
771 }
772 } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
773 #[cfg(feature = "sqlserver")]
774 {
775 use bb8::Pool;
776 use bb8_tiberius::ConnectionManager;
777 use tiberius::Config;
778
779 let config = Config::from_ado_string(db_url).map_err(|e| {
780 anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
781 })?;
782 let mgr = ConnectionManager::build(config).map_err(|e| {
783 anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
784 })?;
785 let pool = Pool::builder()
786 .max_size(2)
787 .build(mgr)
788 .await
789 .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
790
791 Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
792 }
793 #[cfg(not(feature = "sqlserver"))]
794 {
795 anyhow::bail!(
796 "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
797 )
798 }
799 } else {
800 anyhow::bail!("Unrecognized database URL scheme: {db_url}")
801 }
802}
803
804fn detect_query_native_columns<'a>(
817 direct_arg_names: &[&str],
818 inject_param_names: impl Iterator<Item = &'a str>,
819 column_map: &HashMap<String, String>,
820) -> (HashMap<String, String>, Vec<String>) {
821 let mut native: HashMap<String, String> = HashMap::new();
822 let mut arg_fallbacks: Vec<String> = Vec::new();
823
824 for arg in direct_arg_names {
825 if let Some(col_type) = column_map.get(*arg) {
826 native.insert((*arg).to_string(), col_type.clone());
827 } else {
828 arg_fallbacks.push((*arg).to_string());
829 }
830 }
831
832 for name in inject_param_names {
833 if let Some(col_type) = column_map.get(name) {
834 native.insert(name.to_string(), col_type.clone());
835 }
836 }
837
838 (native, arg_fallbacks)
839}
840
841#[cfg(test)]
842#[path = "database_validator_tests.rs"]
843mod database_validator_tests;