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
191fn 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
226fn 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
348 .arguments
349 .iter()
350 .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
351 .map(|a| a.name.as_str())
352 .collect();
353
354 if !direct_args.is_empty() {
355 let mut query_native: HashMap<String, String> = HashMap::new();
356 for arg_name in &direct_args {
357 if let Some(col_type) = column_map.get(*arg_name) {
358 query_native.insert((*arg_name).to_string(), col_type.clone());
359 } else {
360 warnings.push(DatabaseWarning::NativeColumnFallback {
361 query_name: query.name.clone(),
362 sql_source: source.clone(),
363 arg_name: (*arg_name).to_string(),
364 });
365 }
366 }
367 if !query_native.is_empty() {
368 native_columns.insert(query.name.clone(), query_native);
369 }
370 }
371
372 for view in &query.additional_views {
374 if !relation_exists(&schema_qualified, &unqualified, view) {
375 warnings.push(DatabaseWarning::MissingAdditionalView {
376 query_name: query.name.clone(),
377 view_name: view.clone(),
378 });
379 }
380 }
381 }
382 }
383
384 for mutation in &schema.mutations {
386 if let Some(ref source) = mutation.sql_source {
387 if !relation_exists(&schema_qualified, &unqualified, source) {
388 warnings.push(DatabaseWarning::MissingRelation {
389 query_name: mutation.name.clone(),
390 sql_source: source.clone(),
391 });
392 }
393 }
394 }
395
396 Ok(DatabaseValidationReport {
397 warnings,
398 native_columns,
399 })
400}
401
402fn build_relation_maps(
404 relations: &[RelationInfo],
405) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
406 let mut schema_qualified = HashMap::new();
407 let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
408
409 for rel in relations {
410 schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
411 unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
412 }
413
414 (schema_qualified, unqualified)
415}
416
417async fn validate_json_keys(
419 schema: &CompiledSchema,
420 query: &fraiseql_core::schema::QueryDefinition,
421 source: &str,
422 jsonb_col: &str,
423 introspector: &impl DatabaseIntrospector,
424 table_name: &str,
425 warnings: &mut Vec<DatabaseWarning>,
426) -> fraiseql_core::Result<()> {
427 let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
428
429 if samples.is_empty() {
430 return Ok(());
431 }
432
433 let mut all_keys = HashSet::new();
435 for sample in &samples {
436 if let serde_json::Value::Object(map) = sample {
437 for key in map.keys() {
438 all_keys.insert(key.clone());
439 }
440 }
441 }
442
443 if all_keys.is_empty() {
444 return Ok(());
445 }
446
447 let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
449
450 if let Some(type_def) = type_def {
451 for field in &type_def.fields {
452 let field_str = field.name.as_str();
453 let json_key = to_snake_case(field_str);
454 if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
457 continue;
458 }
459 if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
460 warnings.push(DatabaseWarning::MissingJsonKey {
461 query_name: query.name.clone(),
462 sql_source: source.to_string(),
463 json_column: jsonb_col.to_string(),
464 field_name: field_str.to_string(),
465 json_key,
466 });
467 }
468 }
469 }
470
471 Ok(())
472}
473
474pub enum AnyIntrospector {
479 Postgres(fraiseql_core::db::PostgresIntrospector),
481 #[cfg(feature = "mysql")]
482 MySql(fraiseql_core::db::MySqlIntrospector),
484 #[cfg(feature = "sqlite")]
485 Sqlite(fraiseql_core::db::SqliteIntrospector),
487 #[cfg(feature = "sqlserver")]
488 SqlServer(fraiseql_core::db::SqlServerIntrospector),
490}
491
492impl DatabaseIntrospector for AnyIntrospector {
493 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
494 match self {
495 Self::Postgres(i) => i.list_fact_tables().await,
496 #[cfg(feature = "mysql")]
497 Self::MySql(i) => i.list_fact_tables().await,
498 #[cfg(feature = "sqlite")]
499 Self::Sqlite(i) => i.list_fact_tables().await,
500 #[cfg(feature = "sqlserver")]
501 Self::SqlServer(i) => i.list_fact_tables().await,
502 }
503 }
504
505 async fn get_columns(
506 &self,
507 table_name: &str,
508 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
509 match self {
510 Self::Postgres(i) => i.get_columns(table_name).await,
511 #[cfg(feature = "mysql")]
512 Self::MySql(i) => i.get_columns(table_name).await,
513 #[cfg(feature = "sqlite")]
514 Self::Sqlite(i) => i.get_columns(table_name).await,
515 #[cfg(feature = "sqlserver")]
516 Self::SqlServer(i) => i.get_columns(table_name).await,
517 }
518 }
519
520 async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
521 match self {
522 Self::Postgres(i) => i.get_indexed_columns(table_name).await,
523 #[cfg(feature = "mysql")]
524 Self::MySql(i) => i.get_indexed_columns(table_name).await,
525 #[cfg(feature = "sqlite")]
526 Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
527 #[cfg(feature = "sqlserver")]
528 Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
529 }
530 }
531
532 fn database_type(&self) -> DatabaseType {
533 match self {
534 Self::Postgres(i) => i.database_type(),
535 #[cfg(feature = "mysql")]
536 Self::MySql(i) => i.database_type(),
537 #[cfg(feature = "sqlite")]
538 Self::Sqlite(i) => i.database_type(),
539 #[cfg(feature = "sqlserver")]
540 Self::SqlServer(i) => i.database_type(),
541 }
542 }
543
544 async fn get_sample_jsonb(
545 &self,
546 table_name: &str,
547 column_name: &str,
548 ) -> fraiseql_core::Result<Option<serde_json::Value>> {
549 match self {
550 Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
551 #[cfg(feature = "mysql")]
552 Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
553 #[cfg(feature = "sqlite")]
554 Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
555 #[cfg(feature = "sqlserver")]
556 Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
557 }
558 }
559
560 async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
561 match self {
562 Self::Postgres(i) => i.list_relations().await,
563 #[cfg(feature = "mysql")]
564 Self::MySql(i) => i.list_relations().await,
565 #[cfg(feature = "sqlite")]
566 Self::Sqlite(i) => i.list_relations().await,
567 #[cfg(feature = "sqlserver")]
568 Self::SqlServer(i) => i.list_relations().await,
569 }
570 }
571
572 async fn get_sample_json_rows(
573 &self,
574 table_name: &str,
575 column_name: &str,
576 limit: usize,
577 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
578 match self {
579 Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
580 #[cfg(feature = "mysql")]
581 Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
582 #[cfg(feature = "sqlite")]
583 Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
584 #[cfg(feature = "sqlserver")]
585 Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
586 }
587 }
588}
589
590#[allow(clippy::unused_async)] pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
601 if db_url.starts_with("postgres") {
602 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
603 use tokio_postgres::NoTls;
604
605 let mut cfg = Config::new();
606 cfg.url = Some(db_url.to_string());
607 cfg.manager = Some(ManagerConfig {
608 recycling_method: RecyclingMethod::Fast,
609 });
610 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
611
612 let pool = cfg
613 .create_pool(Some(Runtime::Tokio1), NoTls)
614 .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
615
616 Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
617 } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
618 #[cfg(feature = "mysql")]
619 {
620 use sqlx::mysql::MySqlPool;
621
622 let pool = MySqlPool::connect(db_url)
623 .await
624 .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
625
626 Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
627 }
628 #[cfg(not(feature = "mysql"))]
629 {
630 anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
631 }
632 } else if db_url.starts_with("sqlite")
633 || std::path::Path::new(db_url)
634 .extension()
635 .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
636 {
637 #[cfg(feature = "sqlite")]
638 {
639 use sqlx::sqlite::SqlitePool;
640
641 let pool = SqlitePool::connect(db_url)
642 .await
643 .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
644
645 Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
646 }
647 #[cfg(not(feature = "sqlite"))]
648 {
649 anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
650 }
651 } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
652 #[cfg(feature = "sqlserver")]
653 {
654 use bb8::Pool;
655 use bb8_tiberius::ConnectionManager;
656 use tiberius::Config;
657
658 let config = Config::from_ado_string(db_url).map_err(|e| {
659 anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
660 })?;
661 let mgr = ConnectionManager::build(config).map_err(|e| {
662 anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
663 })?;
664 let pool = Pool::builder()
665 .max_size(2)
666 .build(mgr)
667 .await
668 .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
669
670 Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
671 }
672 #[cfg(not(feature = "sqlserver"))]
673 {
674 anyhow::bail!(
675 "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
676 )
677 }
678 } else {
679 anyhow::bail!("Unrecognized database URL scheme: {db_url}")
680 }
681}
682
683#[cfg(test)]
684#[allow(clippy::unwrap_used)] mod tests {
686 use std::collections::HashMap;
687
688 use fraiseql_core::{
689 schema::{
690 AutoParams, CompiledSchema, CursorType, FieldDefinition, FieldType, MutationDefinition,
691 QueryDefinition, TypeDefinition,
692 },
693 validation::CustomTypeRegistry,
694 };
695 use indexmap::IndexMap;
696
697 use super::*;
698
699 struct MockIntrospector {
701 relations: Vec<RelationInfo>,
702 columns: HashMap<String, Vec<(String, String, bool)>>,
703 json_samples: HashMap<(String, String), Vec<serde_json::Value>>,
704 db_type: DatabaseType,
705 }
706
707 impl MockIntrospector {
708 fn new(db_type: DatabaseType) -> Self {
709 Self {
710 relations: Vec::new(),
711 columns: HashMap::new(),
712 json_samples: HashMap::new(),
713 db_type,
714 }
715 }
716
717 fn with_relation(
718 mut self,
719 schema: &str,
720 name: &str,
721 kind: fraiseql_core::db::RelationKind,
722 ) -> Self {
723 self.relations.push(RelationInfo {
724 schema: schema.to_string(),
725 name: name.to_string(),
726 kind,
727 });
728 self
729 }
730
731 fn with_columns(mut self, table: &str, cols: Vec<(&str, &str, bool)>) -> Self {
732 self.columns.insert(
733 table.to_string(),
734 cols.into_iter()
735 .map(|(n, t, nullable)| (n.to_string(), t.to_string(), nullable))
736 .collect(),
737 );
738 self
739 }
740
741 fn with_json_samples(
742 mut self,
743 table: &str,
744 column: &str,
745 samples: Vec<serde_json::Value>,
746 ) -> Self {
747 self.json_samples.insert((table.to_string(), column.to_string()), samples);
748 self
749 }
750 }
751
752 impl DatabaseIntrospector for MockIntrospector {
753 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
754 Ok(Vec::new())
755 }
756
757 async fn get_columns(
758 &self,
759 table_name: &str,
760 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
761 Ok(self.columns.get(table_name).cloned().unwrap_or_default())
762 }
763
764 async fn get_indexed_columns(
765 &self,
766 _table_name: &str,
767 ) -> fraiseql_core::Result<Vec<String>> {
768 Ok(Vec::new())
769 }
770
771 fn database_type(&self) -> DatabaseType {
772 self.db_type
773 }
774
775 async fn list_relations(&self) -> fraiseql_core::Result<Vec<RelationInfo>> {
776 Ok(self.relations.clone())
777 }
778
779 async fn get_sample_json_rows(
780 &self,
781 table_name: &str,
782 column_name: &str,
783 _limit: usize,
784 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
785 Ok(self
786 .json_samples
787 .get(&(table_name.to_string(), column_name.to_string()))
788 .cloned()
789 .unwrap_or_default())
790 }
791 }
792
793 fn make_query(name: &str, return_type: &str, sql_source: &str) -> QueryDefinition {
794 QueryDefinition {
795 name: name.to_string(),
796 return_type: return_type.to_string(),
797 returns_list: true,
798 nullable: false,
799 arguments: vec![],
800 sql_source: Some(sql_source.to_string()),
801 description: None,
802 auto_params: AutoParams::default(),
803 deprecation: None,
804 jsonb_column: "data".to_string(),
805 relay: false,
806 relay_cursor_column: None,
807 relay_cursor_type: CursorType::default(),
808 inject_params: IndexMap::default(),
809 cache_ttl_seconds: None,
810 additional_views: vec![],
811 requires_role: None,
812 rest_path: None,
813 rest_method: None,
814 native_columns: HashMap::new(),
815 }
816 }
817
818 fn make_type(name: &str, fields: Vec<(&str, FieldType)>) -> TypeDefinition {
819 TypeDefinition {
820 name: name.into(),
821 fields: fields
822 .into_iter()
823 .map(|(n, ft)| FieldDefinition::new(n, ft))
824 .collect(),
825 description: None,
826 sql_source: "".into(),
827 jsonb_column: "data".to_string(),
828 sql_projection_hint: None,
829 implements: vec![],
830 requires_role: None,
831 is_error: false,
832 relay: false,
833 relationships: Vec::new(),
834 }
835 }
836
837 fn make_schema(types: Vec<TypeDefinition>, queries: Vec<QueryDefinition>) -> CompiledSchema {
838 CompiledSchema {
839 types,
840 queries,
841 enums: vec![],
842 input_types: vec![],
843 interfaces: vec![],
844 unions: vec![],
845 mutations: vec![],
846 subscriptions: vec![],
847 directives: vec![],
848 observers: Vec::new(),
849 fact_tables: HashMap::default(),
850 federation: None,
851 security: None,
852 observers_config: None,
853 subscriptions_config: None,
854 validation_config: None,
855 debug_config: None,
856 mcp_config: None,
857 schema_sdl: None,
858 schema_format_version: None,
859 custom_scalars: CustomTypeRegistry::default(),
860 ..Default::default()
861 }
862 }
863
864 #[tokio::test]
865 async fn test_valid_schema_no_warnings() {
866 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
867 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
868 .with_columns("v_user", vec![("data", "jsonb", false), ("pk_user", "bigint", false)])
869 .with_json_samples(
870 "v_user",
871 "data",
872 vec![serde_json::json!({"name": "Alice", "email": "alice@example.com"})],
873 );
874
875 let schema = make_schema(
876 vec![make_type(
877 "User",
878 vec![("name", FieldType::String), ("email", FieldType::String)],
879 )],
880 vec![make_query("users", "User", "v_user")],
881 );
882
883 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
884 assert!(
885 report.warnings.is_empty(),
886 "Expected no warnings, got: {:?}",
887 report.warnings.len()
888 );
889 }
890
891 #[tokio::test]
892 async fn test_missing_relation() {
893 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
894 let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
895
896 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
897 assert_eq!(report.warnings.len(), 1);
898 assert!(
899 matches!(&report.warnings[0], DatabaseWarning::MissingRelation { sql_source, .. } if sql_source == "v_user")
900 );
901 }
902
903 #[tokio::test]
904 async fn test_missing_additional_view() {
905 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
906 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
907 .with_columns("v_user", vec![("data", "jsonb", false)]);
908
909 let mut query = make_query("users", "User", "v_user");
910 query.additional_views = vec!["v_missing".to_string()];
911
912 let schema = make_schema(vec![], vec![query]);
913
914 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
915 assert_eq!(report.warnings.len(), 1);
916 assert!(
917 matches!(&report.warnings[0], DatabaseWarning::MissingAdditionalView { view_name, .. } if view_name == "v_missing")
918 );
919 }
920
921 #[tokio::test]
922 async fn test_missing_jsonb_column() {
923 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
924 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
925 .with_columns("v_user", vec![("pk_user", "bigint", false)]);
926
927 let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
928
929 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
930 assert_eq!(report.warnings.len(), 1);
931 assert!(
932 matches!(&report.warnings[0], DatabaseWarning::MissingJsonColumn { column_name, .. } if column_name == "data")
933 );
934 }
935
936 #[tokio::test]
937 async fn test_wrong_json_column_type() {
938 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
939 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
940 .with_columns("v_user", vec![("data", "text", false)]);
941
942 let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
943
944 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
945 assert_eq!(report.warnings.len(), 1);
946 assert!(
947 matches!(&report.warnings[0], DatabaseWarning::WrongJsonColumnType { actual_type, .. } if actual_type == "text")
948 );
949 }
950
951 #[tokio::test]
952 async fn test_sqlserver_nvarchar_no_warning() {
953 let introspector = MockIntrospector::new(DatabaseType::SQLServer)
954 .with_relation("dbo", "v_user", fraiseql_core::db::RelationKind::View)
955 .with_columns("v_user", vec![("data", "nvarchar", false)]);
956
957 let schema = make_schema(vec![], vec![make_query("users", "User", "v_user")]);
958
959 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
960 assert!(
962 !report
963 .warnings
964 .iter()
965 .any(|w| matches!(w, DatabaseWarning::WrongJsonColumnType { .. }))
966 );
967 }
968
969 #[tokio::test]
970 async fn test_missing_cursor_column() {
971 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
972 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
973 .with_columns("v_user", vec![("data", "jsonb", false)]);
974
975 let mut query = make_query("users", "User", "v_user");
976 query.relay = true;
977 query.relay_cursor_column = Some("pk_user".to_string());
978
979 let schema = make_schema(vec![], vec![query]);
980
981 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
982 assert!(report.warnings.iter().any(|w| matches!(w, DatabaseWarning::MissingCursorColumn { column_name, .. } if column_name == "pk_user")));
983 }
984
985 #[tokio::test]
986 async fn test_missing_json_key() {
987 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
988 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
989 .with_columns("v_user", vec![("data", "jsonb", false)])
990 .with_json_samples("v_user", "data", vec![serde_json::json!({"name": "Alice"})]);
991
992 let schema = make_schema(
993 vec![make_type(
994 "User",
995 vec![("name", FieldType::String), ("email", FieldType::String)],
996 )],
997 vec![make_query("users", "User", "v_user")],
998 );
999
1000 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1001 assert!(report.warnings.iter().any(|w| matches!(w, DatabaseWarning::MissingJsonKey { field_name, .. } if field_name == "email")));
1002 }
1003
1004 #[tokio::test]
1005 async fn test_empty_json_sample_no_l3_warnings() {
1006 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
1007 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
1008 .with_columns("v_user", vec![("data", "jsonb", false)]);
1009
1010 let schema = make_schema(
1011 vec![make_type("User", vec![("name", FieldType::String)])],
1012 vec![make_query("users", "User", "v_user")],
1013 );
1014
1015 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1016 assert!(
1018 !report
1019 .warnings
1020 .iter()
1021 .any(|w| matches!(w, DatabaseWarning::MissingJsonKey { .. }))
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn test_schema_qualified_match() {
1027 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
1028 .with_relation("etl_log", "v_foo", fraiseql_core::db::RelationKind::View)
1029 .with_columns("v_foo", vec![("data", "jsonb", false)]);
1030
1031 let schema = make_schema(vec![], vec![make_query("foos", "Foo", "etl_log.v_foo")]);
1032
1033 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1034 assert!(
1036 !report
1037 .warnings
1038 .iter()
1039 .any(|w| matches!(w, DatabaseWarning::MissingRelation { .. }))
1040 );
1041 }
1042
1043 #[tokio::test]
1044 async fn test_schema_qualified_wrong_schema() {
1045 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL).with_relation(
1046 "public",
1047 "v_foo",
1048 fraiseql_core::db::RelationKind::View,
1049 );
1050
1051 let schema = make_schema(vec![], vec![make_query("foos", "Foo", "etl_log.v_foo")]);
1052
1053 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1054 assert_eq!(report.warnings.len(), 1);
1055 assert!(
1056 matches!(&report.warnings[0], DatabaseWarning::MissingRelation { sql_source, .. } if sql_source == "etl_log.v_foo")
1057 );
1058 }
1059
1060 #[tokio::test]
1061 async fn test_mutation_missing_sql_source() {
1062 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
1063
1064 let mut schema = make_schema(vec![], vec![]);
1065 schema.mutations.push(MutationDefinition {
1066 name: "createUser".to_string(),
1067 sql_source: Some("fn_create_user".to_string()),
1068 ..MutationDefinition::new("createUser", "User")
1069 });
1070
1071 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1072 assert_eq!(report.warnings.len(), 1);
1073 assert!(
1074 matches!(&report.warnings[0], DatabaseWarning::MissingRelation { sql_source, .. } if sql_source == "fn_create_user")
1075 );
1076 }
1077
1078 #[tokio::test]
1079 async fn test_query_no_sql_source_skipped() {
1080 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL);
1081
1082 let mut query = make_query("users", "User", "v_user");
1083 query.sql_source = None;
1084
1085 let schema = make_schema(vec![], vec![query]);
1086
1087 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1088 assert!(report.warnings.is_empty());
1089 }
1090
1091 #[tokio::test]
1092 async fn test_multiple_samples_merge_keys() {
1093 let introspector = MockIntrospector::new(DatabaseType::PostgreSQL)
1094 .with_relation("public", "v_user", fraiseql_core::db::RelationKind::View)
1095 .with_columns("v_user", vec![("data", "jsonb", false)])
1096 .with_json_samples(
1097 "v_user",
1098 "data",
1099 vec![
1100 serde_json::json!({"name": "Alice", "email": "alice@example.com"}),
1101 serde_json::json!({"email": "bob@example.com", "age": 30}),
1102 ],
1103 );
1104
1105 let schema = make_schema(
1106 vec![make_type(
1107 "User",
1108 vec![
1109 ("name", FieldType::String),
1110 ("email", FieldType::String),
1111 ("age", FieldType::Int),
1112 ],
1113 )],
1114 vec![make_query("users", "User", "v_user")],
1115 );
1116
1117 let report = validate_schema_against_database(&schema, &introspector).await.unwrap();
1118 assert!(
1120 !report
1121 .warnings
1122 .iter()
1123 .any(|w| matches!(w, DatabaseWarning::MissingJsonKey { .. }))
1124 );
1125 }
1126
1127 #[test]
1128 fn test_to_snake_case() {
1129 assert_eq!(to_snake_case("firstName"), "first_name");
1130 assert_eq!(to_snake_case("name"), "name");
1131 assert_eq!(to_snake_case("HTMLParser"), "h_t_m_l_parser");
1132 assert_eq!(to_snake_case("already_snake"), "already_snake");
1133 }
1134
1135 #[test]
1136 fn test_is_json_type_postgres() {
1137 assert!(is_json_type("jsonb", DatabaseType::PostgreSQL));
1138 assert!(is_json_type("json", DatabaseType::PostgreSQL));
1139 assert!(!is_json_type("text", DatabaseType::PostgreSQL));
1140 }
1141
1142 #[test]
1143 fn test_is_json_type_mysql() {
1144 assert!(is_json_type("json", DatabaseType::MySQL));
1145 assert!(!is_json_type("varchar", DatabaseType::MySQL));
1146 }
1147
1148 #[test]
1149 fn test_is_json_type_sqlite() {
1150 assert!(is_json_type("json", DatabaseType::SQLite));
1151 assert!(is_json_type("JSON", DatabaseType::SQLite));
1152 assert!(!is_json_type("text", DatabaseType::SQLite));
1153 }
1154
1155 #[test]
1156 fn test_is_json_type_sqlserver() {
1157 assert!(is_json_type("nvarchar", DatabaseType::SQLServer));
1159 assert!(is_json_type("varchar", DatabaseType::SQLServer));
1160 }
1161
1162 #[test]
1163 fn test_display_warnings() {
1164 let warning = DatabaseWarning::MissingRelation {
1165 query_name: "users".to_string(),
1166 sql_source: "v_user".to_string(),
1167 };
1168 assert_eq!(
1169 warning.to_string(),
1170 "query `users`: sql_source `v_user` does not exist in database"
1171 );
1172 }
1173}