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,
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 sql_source: String,
89 json_column: String,
91 field_name: String,
93 json_key: String,
95 },
96 NativeColumnFallback {
102 query_name: String,
104 sql_source: String,
106 arg_name: String,
108 },
109}
110
111impl fmt::Display for DatabaseWarning {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 Self::MissingRelation {
115 query_name,
116 sql_source,
117 } => {
118 write!(
119 f,
120 "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
121 )
122 },
123 Self::MissingAdditionalView {
124 query_name,
125 view_name,
126 } => {
127 write!(
128 f,
129 "query `{query_name}`: additional_view `{view_name}` does not exist in database"
130 )
131 },
132 Self::MissingJsonColumn {
133 query_name,
134 sql_source,
135 column_name,
136 } => {
137 write!(
138 f,
139 "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
140 )
141 },
142 Self::WrongJsonColumnType {
143 query_name,
144 sql_source,
145 column_name,
146 actual_type,
147 } => {
148 write!(
149 f,
150 "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
151 )
152 },
153 Self::MissingCursorColumn {
154 query_name,
155 sql_source,
156 column_name,
157 } => {
158 write!(
159 f,
160 "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
161 )
162 },
163 Self::MissingJsonKey {
164 query_name,
165 sql_source,
166 json_column,
167 field_name,
168 json_key,
169 } => {
170 write!(
171 f,
172 "query `{query_name}`: field `{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
173 )
174 },
175 Self::NativeColumnFallback {
176 query_name,
177 sql_source,
178 arg_name,
179 } => {
180 write!(
181 f,
182 "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
183 (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
184 `{sql_source}`. Add a native column with an index for O(log n) lookup."
185 )
186 },
187 }
188 }
189}
190
191pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
193 let lower = data_type.to_lowercase();
194 match db_type {
195 DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
196 DatabaseType::MySQL => lower == "json",
197 DatabaseType::SQLite => lower.contains("json"),
198 DatabaseType::SQLServer => true,
201 }
202}
203
204fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
206 match sql_source.split_once('.') {
207 Some((schema, table)) => (Some(schema), table),
208 None => (None, sql_source),
209 }
210}
211
212fn relation_exists(
214 schema_qualified: &HashMap<(String, String), RelationInfo>,
215 unqualified: &HashMap<String, Vec<String>>,
216 sql_source: &str,
217) -> bool {
218 let (schema, name) = split_schema_qualified(sql_source);
219 if let Some(s) = schema {
220 schema_qualified.contains_key(&(s.to_string(), name.to_string()))
221 } else {
222 unqualified.contains_key(name)
223 }
224}
225
226pub(crate) fn to_snake_case(name: &str) -> String {
230 let mut result = String::with_capacity(name.len() + 4);
231 for (i, ch) in name.chars().enumerate() {
232 if ch.is_uppercase() {
233 if i > 0 {
234 result.push('_');
235 }
236 result.push(ch.to_lowercase().next().unwrap_or(ch));
237 } else {
238 result.push(ch);
239 }
240 }
241 result
242}
243
244pub async fn validate_schema_against_database(
257 schema: &CompiledSchema,
258 introspector: &impl DatabaseIntrospector,
259) -> fraiseql_core::Result<DatabaseValidationReport> {
260 const AUTO_PARAM_NAMES: &[&str] = &[
263 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
264 ];
265
266 let mut warnings = Vec::new();
267 let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
268 let db_type = introspector.database_type();
269
270 let relations = introspector.list_relations().await?;
272 let (schema_qualified, unqualified) = build_relation_maps(&relations);
273
274 for query in &schema.queries {
276 if let Some(ref source) = query.sql_source {
277 if !relation_exists(&schema_qualified, &unqualified, source) {
279 warnings.push(DatabaseWarning::MissingRelation {
280 query_name: query.name.clone(),
281 sql_source: source.clone(),
282 });
283 continue; }
285
286 let columns = introspector.get_columns(source).await?;
290 let column_map: HashMap<String, String> =
291 columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
292
293 let jsonb_col = &query.jsonb_column;
295 if !jsonb_col.is_empty() {
296 if let Some(actual_type) = column_map.get(jsonb_col) {
297 if !is_json_type(actual_type, db_type) {
298 warnings.push(DatabaseWarning::WrongJsonColumnType {
299 query_name: query.name.clone(),
300 sql_source: source.clone(),
301 column_name: jsonb_col.clone(),
302 actual_type: actual_type.clone(),
303 });
304 }
305 } else {
306 warnings.push(DatabaseWarning::MissingJsonColumn {
307 query_name: query.name.clone(),
308 sql_source: source.clone(),
309 column_name: jsonb_col.clone(),
310 });
311 }
312 }
313
314 if query.relay {
316 if let Some(ref cursor_col) = query.relay_cursor_column {
317 if !column_map.contains_key(cursor_col) {
318 warnings.push(DatabaseWarning::MissingCursorColumn {
319 query_name: query.name.clone(),
320 sql_source: source.clone(),
321 column_name: cursor_col.clone(),
322 });
323 }
324 }
325 }
326
327 if !jsonb_col.is_empty() {
329 let json_type_ok =
330 column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
331
332 if json_type_ok {
333 validate_json_keys(
334 schema,
335 query,
336 source,
337 jsonb_col,
338 introspector,
339 source, &mut warnings,
341 )
342 .await?;
343 }
344 }
345
346 let direct_args: Vec<&str> = query
351 .arguments
352 .iter()
353 .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
354 .map(|a| a.name.as_str())
355 .collect();
356
357 let (query_native, arg_fallbacks) = detect_query_native_columns(
358 &direct_args,
359 query.inject_params.keys().map(String::as_str),
360 &column_map,
361 );
362 for arg_name in arg_fallbacks {
363 warnings.push(DatabaseWarning::NativeColumnFallback {
364 query_name: query.name.clone(),
365 sql_source: source.clone(),
366 arg_name,
367 });
368 }
369 if !query_native.is_empty() {
370 native_columns.insert(query.name.clone(), query_native);
371 }
372
373 for view in &query.additional_views {
375 if !relation_exists(&schema_qualified, &unqualified, view) {
376 warnings.push(DatabaseWarning::MissingAdditionalView {
377 query_name: query.name.clone(),
378 view_name: view.clone(),
379 });
380 }
381 }
382 }
383 }
384
385 for mutation in &schema.mutations {
387 if let Some(ref source) = mutation.sql_source {
388 if !relation_exists(&schema_qualified, &unqualified, source) {
389 warnings.push(DatabaseWarning::MissingRelation {
390 query_name: mutation.name.clone(),
391 sql_source: source.clone(),
392 });
393 }
394 }
395 }
396
397 Ok(DatabaseValidationReport {
398 warnings,
399 native_columns,
400 })
401}
402
403fn build_relation_maps(
405 relations: &[RelationInfo],
406) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
407 let mut schema_qualified = HashMap::new();
408 let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
409
410 for rel in relations {
411 schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
412 unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
413 }
414
415 (schema_qualified, unqualified)
416}
417
418async fn validate_json_keys(
420 schema: &CompiledSchema,
421 query: &fraiseql_core::schema::QueryDefinition,
422 source: &str,
423 jsonb_col: &str,
424 introspector: &impl DatabaseIntrospector,
425 table_name: &str,
426 warnings: &mut Vec<DatabaseWarning>,
427) -> fraiseql_core::Result<()> {
428 let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
429
430 if samples.is_empty() {
431 return Ok(());
432 }
433
434 let mut all_keys = HashSet::new();
436 for sample in &samples {
437 if let serde_json::Value::Object(map) = sample {
438 for key in map.keys() {
439 all_keys.insert(key.clone());
440 }
441 }
442 }
443
444 if all_keys.is_empty() {
445 return Ok(());
446 }
447
448 let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
450
451 if let Some(type_def) = type_def {
452 for field in &type_def.fields {
453 let field_str = field.name.as_str();
454 let json_key = to_snake_case(field_str);
455 if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
458 continue;
459 }
460 if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
461 warnings.push(DatabaseWarning::MissingJsonKey {
462 query_name: query.name.clone(),
463 sql_source: source.to_string(),
464 json_column: jsonb_col.to_string(),
465 field_name: field_str.to_string(),
466 json_key,
467 });
468 }
469 }
470 }
471
472 Ok(())
473}
474
475pub enum AnyIntrospector {
480 Postgres(fraiseql_core::db::PostgresIntrospector),
482 #[cfg(feature = "mysql")]
483 MySql(fraiseql_core::db::MySqlIntrospector),
485 #[cfg(feature = "sqlite")]
486 Sqlite(fraiseql_core::db::SqliteIntrospector),
488 #[cfg(feature = "sqlserver")]
489 SqlServer(fraiseql_core::db::SqlServerIntrospector),
491}
492
493impl DatabaseIntrospector for AnyIntrospector {
494 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
495 match self {
496 Self::Postgres(i) => i.list_fact_tables().await,
497 #[cfg(feature = "mysql")]
498 Self::MySql(i) => i.list_fact_tables().await,
499 #[cfg(feature = "sqlite")]
500 Self::Sqlite(i) => i.list_fact_tables().await,
501 #[cfg(feature = "sqlserver")]
502 Self::SqlServer(i) => i.list_fact_tables().await,
503 }
504 }
505
506 async fn get_columns(
507 &self,
508 table_name: &str,
509 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
510 match self {
511 Self::Postgres(i) => i.get_columns(table_name).await,
512 #[cfg(feature = "mysql")]
513 Self::MySql(i) => i.get_columns(table_name).await,
514 #[cfg(feature = "sqlite")]
515 Self::Sqlite(i) => i.get_columns(table_name).await,
516 #[cfg(feature = "sqlserver")]
517 Self::SqlServer(i) => i.get_columns(table_name).await,
518 }
519 }
520
521 async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
522 match self {
523 Self::Postgres(i) => i.get_indexed_columns(table_name).await,
524 #[cfg(feature = "mysql")]
525 Self::MySql(i) => i.get_indexed_columns(table_name).await,
526 #[cfg(feature = "sqlite")]
527 Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
528 #[cfg(feature = "sqlserver")]
529 Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
530 }
531 }
532
533 fn database_type(&self) -> DatabaseType {
534 match self {
535 Self::Postgres(i) => i.database_type(),
536 #[cfg(feature = "mysql")]
537 Self::MySql(i) => i.database_type(),
538 #[cfg(feature = "sqlite")]
539 Self::Sqlite(i) => i.database_type(),
540 #[cfg(feature = "sqlserver")]
541 Self::SqlServer(i) => i.database_type(),
542 }
543 }
544
545 async fn get_sample_jsonb(
546 &self,
547 table_name: &str,
548 column_name: &str,
549 ) -> fraiseql_core::Result<Option<serde_json::Value>> {
550 match self {
551 Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
552 #[cfg(feature = "mysql")]
553 Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
554 #[cfg(feature = "sqlite")]
555 Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
556 #[cfg(feature = "sqlserver")]
557 Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
558 }
559 }
560
561 async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
562 match self {
563 Self::Postgres(i) => i.list_relations().await,
564 #[cfg(feature = "mysql")]
565 Self::MySql(i) => i.list_relations().await,
566 #[cfg(feature = "sqlite")]
567 Self::Sqlite(i) => i.list_relations().await,
568 #[cfg(feature = "sqlserver")]
569 Self::SqlServer(i) => i.list_relations().await,
570 }
571 }
572
573 async fn get_sample_json_rows(
574 &self,
575 table_name: &str,
576 column_name: &str,
577 limit: usize,
578 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
579 match self {
580 Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
581 #[cfg(feature = "mysql")]
582 Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
583 #[cfg(feature = "sqlite")]
584 Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
585 #[cfg(feature = "sqlserver")]
586 Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
587 }
588 }
589}
590
591#[allow(clippy::unused_async)] pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
602 if db_url.starts_with("postgres") {
603 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
604 use tokio_postgres::NoTls;
605
606 let mut cfg = Config::new();
607 cfg.url = Some(db_url.to_string());
608 cfg.manager = Some(ManagerConfig {
609 recycling_method: RecyclingMethod::Fast,
610 });
611 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
612
613 let pool = cfg
614 .create_pool(Some(Runtime::Tokio1), NoTls)
615 .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
616
617 Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
618 } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
619 #[cfg(feature = "mysql")]
620 {
621 use sqlx::mysql::MySqlPool;
622
623 let pool = MySqlPool::connect(db_url)
624 .await
625 .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
626
627 Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
628 }
629 #[cfg(not(feature = "mysql"))]
630 {
631 anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
632 }
633 } else if db_url.starts_with("sqlite")
634 || std::path::Path::new(db_url)
635 .extension()
636 .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
637 {
638 #[cfg(feature = "sqlite")]
639 {
640 use sqlx::sqlite::SqlitePool;
641
642 let pool = SqlitePool::connect(db_url)
643 .await
644 .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
645
646 Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
647 }
648 #[cfg(not(feature = "sqlite"))]
649 {
650 anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
651 }
652 } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
653 #[cfg(feature = "sqlserver")]
654 {
655 use bb8::Pool;
656 use bb8_tiberius::ConnectionManager;
657 use tiberius::Config;
658
659 let config = Config::from_ado_string(db_url).map_err(|e| {
660 anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
661 })?;
662 let mgr = ConnectionManager::build(config).map_err(|e| {
663 anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
664 })?;
665 let pool = Pool::builder()
666 .max_size(2)
667 .build(mgr)
668 .await
669 .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
670
671 Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
672 }
673 #[cfg(not(feature = "sqlserver"))]
674 {
675 anyhow::bail!(
676 "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
677 )
678 }
679 } else {
680 anyhow::bail!("Unrecognized database URL scheme: {db_url}")
681 }
682}
683
684fn detect_query_native_columns<'a>(
697 direct_arg_names: &[&str],
698 inject_param_names: impl Iterator<Item = &'a str>,
699 column_map: &HashMap<String, String>,
700) -> (HashMap<String, String>, Vec<String>) {
701 let mut native: HashMap<String, String> = HashMap::new();
702 let mut arg_fallbacks: Vec<String> = Vec::new();
703
704 for arg in direct_arg_names {
705 if let Some(col_type) = column_map.get(*arg) {
706 native.insert((*arg).to_string(), col_type.clone());
707 } else {
708 arg_fallbacks.push((*arg).to_string());
709 }
710 }
711
712 for name in inject_param_names {
713 if let Some(col_type) = column_map.get(name) {
714 native.insert(name.to_string(), col_type.clone());
715 }
716 }
717
718 (native, arg_fallbacks)
719}
720
721#[cfg(test)]
722#[path = "database_validator_tests.rs"]
723mod database_validator_tests;