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, SourceKind, SourceProbe, sql_source_probes},
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 MissingFunction {
54 mutation_name: String,
56 sql_source: String,
58 },
59 MissingTypeSource {
67 type_name: String,
69 sql_source: String,
71 },
72 MissingAdditionalView {
74 query_name: String,
76 view_name: String,
78 },
79 MissingJsonColumn {
81 query_name: String,
83 sql_source: String,
85 column_name: String,
87 },
88 WrongJsonColumnType {
90 query_name: String,
92 sql_source: String,
94 column_name: String,
96 actual_type: String,
98 },
99 MissingCursorColumn {
101 query_name: String,
103 sql_source: String,
105 column_name: String,
107 },
108 MissingJsonKey {
110 query_name: String,
112 type_name: String,
114 sql_source: String,
116 json_column: String,
118 field_name: String,
120 json_key: String,
122 },
123 NativeColumnFallback {
129 query_name: String,
131 sql_source: String,
133 arg_name: String,
135 },
136 TypeConvertibility {
141 query_name: String,
143 sql_source: String,
145 arg_name: String,
147 graphql_type: String,
149 column_type: String,
151 },
152}
153
154impl fmt::Display for DatabaseWarning {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 match self {
157 Self::MissingRelation {
158 query_name,
159 sql_source,
160 } => {
161 write!(
162 f,
163 "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
164 )
165 },
166 Self::MissingFunction {
167 mutation_name,
168 sql_source,
169 } => {
170 write!(
171 f,
172 "mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
173 )
174 },
175 Self::MissingTypeSource {
176 type_name,
177 sql_source,
178 } => {
179 write!(
180 f,
181 "type `{type_name}`: sql_source `{sql_source}` does not exist in the \
182 database — if `{type_name}` is an embedded value object it should declare \
183 no source (a value object has no independent identity; it is fetched via \
184 its parent)"
185 )
186 },
187 Self::MissingAdditionalView {
188 query_name,
189 view_name,
190 } => {
191 write!(
192 f,
193 "query `{query_name}`: additional_view `{view_name}` does not exist in database"
194 )
195 },
196 Self::MissingJsonColumn {
197 query_name,
198 sql_source,
199 column_name,
200 } => {
201 write!(
202 f,
203 "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
204 )
205 },
206 Self::WrongJsonColumnType {
207 query_name,
208 sql_source,
209 column_name,
210 actual_type,
211 } => {
212 write!(
213 f,
214 "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
215 )
216 },
217 Self::MissingCursorColumn {
218 query_name,
219 sql_source,
220 column_name,
221 } => {
222 write!(
223 f,
224 "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
225 )
226 },
227 Self::MissingJsonKey {
228 query_name,
229 type_name,
230 sql_source,
231 json_column,
232 field_name,
233 json_key,
234 } => {
235 write!(
236 f,
237 "query `{query_name}`: field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
238 )
239 },
240 Self::NativeColumnFallback {
241 query_name,
242 sql_source,
243 arg_name,
244 } => {
245 write!(
246 f,
247 "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
248 (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
249 `{sql_source}`. Add a native column with an index for O(log n) lookup."
250 )
251 },
252 Self::TypeConvertibility {
253 query_name,
254 sql_source,
255 arg_name,
256 graphql_type,
257 column_type,
258 } => {
259 write!(
260 f,
261 "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
262 `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
263 Align the argument type with the column, or store the field in `data` for JSONB extraction."
264 )
265 },
266 }
267 }
268}
269
270pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
272 let lower = data_type.to_lowercase();
273 match db_type {
274 DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
275 DatabaseType::MySQL => lower == "json",
276 DatabaseType::SQLite => lower.contains("json"),
277 DatabaseType::SQLServer => true,
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285enum SqlFamily {
286 Numeric,
288 Boolean,
290 Uuid,
292 Text,
294 Temporal,
296 Json,
298 Other,
300}
301
302fn sql_type_family(sql_type: &str) -> SqlFamily {
307 let lower = sql_type.trim().to_lowercase();
308 let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
309 match base {
310 "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
311 | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
312 | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
313 "boolean" | "bool" | "bit" => SqlFamily::Boolean,
314 "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
315 "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
316 | "character" | "clob" | "string" => SqlFamily::Text,
317 "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
318 | "interval" | "smalldatetime" => SqlFamily::Temporal,
319 "json" | "jsonb" => SqlFamily::Json,
320 _ => SqlFamily::Other,
321 }
322}
323
324fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
333 let base = match field_type {
335 FieldType::List(inner) => inner.as_ref(),
336 other => other,
337 };
338 let family = sql_type_family(sql_type);
339 if family == SqlFamily::Other {
341 return true;
342 }
343 match base {
344 FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
345 FieldType::Boolean => family == SqlFamily::Boolean,
346 FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
347 _ => true,
348 }
349}
350
351fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
353 match sql_source.split_once('.') {
354 Some((schema, table)) => (Some(schema), table),
355 None => (None, sql_source),
356 }
357}
358
359fn relation_exists(
361 schema_qualified: &HashMap<(String, String), RelationInfo>,
362 unqualified: &HashMap<String, Vec<String>>,
363 sql_source: &str,
364) -> bool {
365 let (schema, name) = split_schema_qualified(sql_source);
366 if let Some(s) = schema {
367 schema_qualified.contains_key(&(s.to_string(), name.to_string()))
368 } else {
369 unqualified.contains_key(name)
370 }
371}
372
373async fn relation_source_exists(
382 introspector: &impl DatabaseIntrospector,
383 schema_qualified: &HashMap<(String, String), RelationInfo>,
384 unqualified: &HashMap<String, Vec<String>>,
385 sql_source: &str,
386) -> fraiseql_core::Result<bool> {
387 let (schema, name) = split_schema_qualified(sql_source);
388 if let Some(s) = schema {
389 if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
390 return Ok(exists);
391 }
392 }
393 Ok(relation_exists(schema_qualified, unqualified, sql_source))
395}
396
397async fn function_source_exists(
403 introspector: &impl DatabaseIntrospector,
404 sql_source: &str,
405) -> fraiseql_core::Result<bool> {
406 let (schema, name) = split_schema_qualified(sql_source);
407 Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
408}
409
410pub async fn validate_schema_against_database(
423 schema: &CompiledSchema,
424 introspector: &impl DatabaseIntrospector,
425) -> fraiseql_core::Result<DatabaseValidationReport> {
426 const AUTO_PARAM_NAMES: &[&str] = &[
429 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
430 ];
431
432 let mut warnings = Vec::new();
433 let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
434 let db_type = introspector.database_type();
435
436 let relations = introspector.list_relations().await?;
438 let (schema_qualified, unqualified) = build_relation_maps(&relations);
439
440 for query in &schema.queries {
442 if let Some(ref source) = query.sql_source {
443 if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
445 .await?
446 {
447 warnings.push(DatabaseWarning::MissingRelation {
448 query_name: query.name.clone(),
449 sql_source: source.clone(),
450 });
451 continue; }
453
454 let columns = introspector.get_columns(source).await?;
458 let column_map: HashMap<String, String> =
459 columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
460
461 let jsonb_col = &query.jsonb_column;
463 if !jsonb_col.is_empty() {
464 if let Some(actual_type) = column_map.get(jsonb_col) {
465 if !is_json_type(actual_type, db_type) {
466 warnings.push(DatabaseWarning::WrongJsonColumnType {
467 query_name: query.name.clone(),
468 sql_source: source.clone(),
469 column_name: jsonb_col.clone(),
470 actual_type: actual_type.clone(),
471 });
472 }
473 } else {
474 warnings.push(DatabaseWarning::MissingJsonColumn {
475 query_name: query.name.clone(),
476 sql_source: source.clone(),
477 column_name: jsonb_col.clone(),
478 });
479 }
480 }
481
482 if query.relay {
484 if let Some(ref cursor_col) = query.relay_cursor_column {
485 if !column_map.contains_key(cursor_col) {
486 warnings.push(DatabaseWarning::MissingCursorColumn {
487 query_name: query.name.clone(),
488 sql_source: source.clone(),
489 column_name: cursor_col.clone(),
490 });
491 }
492 }
493 }
494
495 if !jsonb_col.is_empty() {
497 let json_type_ok =
498 column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
499
500 if json_type_ok {
501 validate_json_keys(
502 schema,
503 query,
504 source,
505 jsonb_col,
506 introspector,
507 source, &mut warnings,
509 )
510 .await?;
511 }
512 }
513
514 let direct_args: Vec<&str> = query
519 .arguments
520 .iter()
521 .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
522 .map(|a| a.name.as_str())
523 .collect();
524
525 let (query_native, arg_fallbacks) = detect_query_native_columns(
526 &direct_args,
527 query.inject_params.keys().map(String::as_str),
528 &column_map,
529 );
530 for arg_name in arg_fallbacks {
531 warnings.push(DatabaseWarning::NativeColumnFallback {
532 query_name: query.name.clone(),
533 sql_source: source.clone(),
534 arg_name,
535 });
536 }
537
538 for arg in &query.arguments {
542 if let Some(col_type) = query_native.get(&arg.name) {
543 if !arg_type_convertible(&arg.arg_type, col_type) {
544 warnings.push(DatabaseWarning::TypeConvertibility {
545 query_name: query.name.clone(),
546 sql_source: source.clone(),
547 arg_name: arg.name.clone(),
548 graphql_type: arg.arg_type.to_graphql_string(),
549 column_type: col_type.clone(),
550 });
551 }
552 }
553 }
554
555 if !query_native.is_empty() {
556 native_columns.insert(query.name.clone(), query_native);
557 }
558
559 for view in &query.additional_views {
561 if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
562 .await?
563 {
564 warnings.push(DatabaseWarning::MissingAdditionalView {
565 query_name: query.name.clone(),
566 view_name: view.clone(),
567 });
568 }
569 }
570 }
571 }
572
573 for mutation in &schema.mutations {
577 if let Some(ref source) = mutation.sql_source {
578 if !function_source_exists(introspector, source).await? {
579 warnings.push(DatabaseWarning::MissingFunction {
580 mutation_name: mutation.name.clone(),
581 sql_source: source.clone(),
582 });
583 }
584 }
585 }
586
587 for ty in &schema.types {
604 let source = ty.sql_source.as_str();
605 if ty.is_error || source.is_empty() {
606 continue;
607 }
608 if !relation_source_exists(introspector, &schema_qualified, &unqualified, source).await? {
609 warnings.push(DatabaseWarning::MissingTypeSource {
610 type_name: ty.name.to_string(),
611 sql_source: source.to_string(),
612 });
613 }
614 }
615
616 Ok(DatabaseValidationReport {
617 warnings,
618 native_columns,
619 })
620}
621
622fn build_relation_maps(
624 relations: &[RelationInfo],
625) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
626 let mut schema_qualified = HashMap::new();
627 let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
628
629 for rel in relations {
630 schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
631 unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
632 }
633
634 (schema_qualified, unqualified)
635}
636
637pub async fn find_unbacked_sources(
654 schema: &CompiledSchema,
655 introspector: &impl DatabaseIntrospector,
656) -> fraiseql_core::Result<Vec<SourceProbe>> {
657 let relations = introspector.list_relations().await?;
658 let (schema_qualified, unqualified) = build_relation_maps(&relations);
659
660 let mut unbacked = Vec::new();
661 for probe in sql_source_probes(schema) {
662 let exists = match probe.kind {
663 SourceKind::Function => introspector
664 .function_exists(probe.schema.as_deref(), &probe.name)
665 .await?
666 .unwrap_or(true),
667 SourceKind::Relation => {
668 if let Some(s) = &probe.schema {
669 match introspector.qualified_relation_exists(s, &probe.name).await? {
670 Some(exists) => exists,
671 None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
672 }
673 } else {
674 unqualified.contains_key(&probe.name)
675 }
676 },
677 };
678 if !exists {
679 unbacked.push(probe);
680 }
681 }
682 Ok(unbacked)
683}
684
685async fn validate_json_keys(
687 schema: &CompiledSchema,
688 query: &fraiseql_core::schema::QueryDefinition,
689 source: &str,
690 jsonb_col: &str,
691 introspector: &impl DatabaseIntrospector,
692 table_name: &str,
693 warnings: &mut Vec<DatabaseWarning>,
694) -> fraiseql_core::Result<()> {
695 let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
696
697 if samples.is_empty() {
698 return Ok(());
699 }
700
701 let mut all_keys = HashSet::new();
703 for sample in &samples {
704 if let serde_json::Value::Object(map) = sample {
705 for key in map.keys() {
706 all_keys.insert(key.clone());
707 }
708 }
709 }
710
711 if all_keys.is_empty() {
712 return Ok(());
713 }
714
715 let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
717
718 if let Some(type_def) = type_def {
719 for field in &type_def.fields {
720 let field_str = field.name.as_str();
721 let json_key = fraiseql_core::utils::to_snake_case(field_str);
725 if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
728 continue;
729 }
730 if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
731 warnings.push(DatabaseWarning::MissingJsonKey {
732 query_name: query.name.clone(),
733 type_name: type_def.name.as_str().to_string(),
734 sql_source: source.to_string(),
735 json_column: jsonb_col.to_string(),
736 field_name: field_str.to_string(),
737 json_key,
738 });
739 }
740 }
741 }
742
743 Ok(())
744}
745
746pub enum AnyIntrospector {
751 Postgres(fraiseql_core::db::PostgresIntrospector),
753 #[cfg(feature = "mysql")]
754 MySql(fraiseql_core::db::MySqlIntrospector),
756 #[cfg(feature = "sqlite")]
757 Sqlite(fraiseql_core::db::SqliteIntrospector),
759 #[cfg(feature = "sqlserver")]
760 SqlServer(fraiseql_core::db::SqlServerIntrospector),
762}
763
764impl DatabaseIntrospector for AnyIntrospector {
765 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
766 match self {
767 Self::Postgres(i) => i.list_fact_tables().await,
768 #[cfg(feature = "mysql")]
769 Self::MySql(i) => i.list_fact_tables().await,
770 #[cfg(feature = "sqlite")]
771 Self::Sqlite(i) => i.list_fact_tables().await,
772 #[cfg(feature = "sqlserver")]
773 Self::SqlServer(i) => i.list_fact_tables().await,
774 }
775 }
776
777 async fn get_columns(
778 &self,
779 table_name: &str,
780 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
781 match self {
782 Self::Postgres(i) => i.get_columns(table_name).await,
783 #[cfg(feature = "mysql")]
784 Self::MySql(i) => i.get_columns(table_name).await,
785 #[cfg(feature = "sqlite")]
786 Self::Sqlite(i) => i.get_columns(table_name).await,
787 #[cfg(feature = "sqlserver")]
788 Self::SqlServer(i) => i.get_columns(table_name).await,
789 }
790 }
791
792 async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
793 match self {
794 Self::Postgres(i) => i.get_indexed_columns(table_name).await,
795 #[cfg(feature = "mysql")]
796 Self::MySql(i) => i.get_indexed_columns(table_name).await,
797 #[cfg(feature = "sqlite")]
798 Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
799 #[cfg(feature = "sqlserver")]
800 Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
801 }
802 }
803
804 fn database_type(&self) -> DatabaseType {
805 match self {
806 Self::Postgres(i) => i.database_type(),
807 #[cfg(feature = "mysql")]
808 Self::MySql(i) => i.database_type(),
809 #[cfg(feature = "sqlite")]
810 Self::Sqlite(i) => i.database_type(),
811 #[cfg(feature = "sqlserver")]
812 Self::SqlServer(i) => i.database_type(),
813 }
814 }
815
816 async fn get_sample_jsonb(
817 &self,
818 table_name: &str,
819 column_name: &str,
820 ) -> fraiseql_core::Result<Option<serde_json::Value>> {
821 match self {
822 Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
823 #[cfg(feature = "mysql")]
824 Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
825 #[cfg(feature = "sqlite")]
826 Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
827 #[cfg(feature = "sqlserver")]
828 Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
829 }
830 }
831
832 async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
833 match self {
834 Self::Postgres(i) => i.list_relations().await,
835 #[cfg(feature = "mysql")]
836 Self::MySql(i) => i.list_relations().await,
837 #[cfg(feature = "sqlite")]
838 Self::Sqlite(i) => i.list_relations().await,
839 #[cfg(feature = "sqlserver")]
840 Self::SqlServer(i) => i.list_relations().await,
841 }
842 }
843
844 async fn get_sample_json_rows(
845 &self,
846 table_name: &str,
847 column_name: &str,
848 limit: usize,
849 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
850 match self {
851 Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
852 #[cfg(feature = "mysql")]
853 Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
854 #[cfg(feature = "sqlite")]
855 Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
856 #[cfg(feature = "sqlserver")]
857 Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
858 }
859 }
860
861 async fn function_exists(
862 &self,
863 schema: Option<&str>,
864 name: &str,
865 ) -> fraiseql_core::Result<Option<bool>> {
866 match self {
869 Self::Postgres(i) => i.function_exists(schema, name).await,
870 #[cfg(feature = "mysql")]
871 Self::MySql(i) => i.function_exists(schema, name).await,
872 #[cfg(feature = "sqlite")]
873 Self::Sqlite(i) => i.function_exists(schema, name).await,
874 #[cfg(feature = "sqlserver")]
875 Self::SqlServer(i) => i.function_exists(schema, name).await,
876 }
877 }
878
879 async fn qualified_relation_exists(
880 &self,
881 schema: &str,
882 name: &str,
883 ) -> fraiseql_core::Result<Option<bool>> {
884 match self {
885 Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
886 #[cfg(feature = "mysql")]
887 Self::MySql(i) => i.qualified_relation_exists(schema, name).await,
888 #[cfg(feature = "sqlite")]
889 Self::Sqlite(i) => i.qualified_relation_exists(schema, name).await,
890 #[cfg(feature = "sqlserver")]
891 Self::SqlServer(i) => i.qualified_relation_exists(schema, name).await,
892 }
893 }
894}
895
896#[allow(clippy::unused_async)] pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
907 if db_url.starts_with("postgres") {
908 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
909 use tokio_postgres::NoTls;
910
911 let mut cfg = Config::new();
912 cfg.url = Some(db_url.to_string());
913 cfg.manager = Some(ManagerConfig {
914 recycling_method: RecyclingMethod::Fast,
915 });
916 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
917
918 let pool = cfg
919 .create_pool(Some(Runtime::Tokio1), NoTls)
920 .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
921
922 Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
923 } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
924 #[cfg(feature = "mysql")]
925 {
926 use sqlx::mysql::MySqlPool;
927
928 let pool = MySqlPool::connect(db_url)
929 .await
930 .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
931
932 Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
933 }
934 #[cfg(not(feature = "mysql"))]
935 {
936 anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
937 }
938 } else if db_url.starts_with("sqlite")
939 || std::path::Path::new(db_url)
940 .extension()
941 .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
942 {
943 #[cfg(feature = "sqlite")]
944 {
945 use sqlx::sqlite::SqlitePool;
946
947 let pool = SqlitePool::connect(db_url)
948 .await
949 .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
950
951 Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
952 }
953 #[cfg(not(feature = "sqlite"))]
954 {
955 anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
956 }
957 } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
958 #[cfg(feature = "sqlserver")]
959 {
960 use bb8::Pool;
961 use bb8_tiberius::ConnectionManager;
962 use tiberius::Config;
963
964 let config = Config::from_ado_string(db_url).map_err(|e| {
965 anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
966 })?;
967 let mgr = ConnectionManager::build(config).map_err(|e| {
968 anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
969 })?;
970 let pool = Pool::builder()
971 .max_size(2)
972 .build(mgr)
973 .await
974 .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
975
976 Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
977 }
978 #[cfg(not(feature = "sqlserver"))]
979 {
980 anyhow::bail!(
981 "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
982 )
983 }
984 } else {
985 anyhow::bail!("Unrecognized database URL scheme: {db_url}")
986 }
987}
988
989fn detect_query_native_columns<'a>(
1002 direct_arg_names: &[&str],
1003 inject_param_names: impl Iterator<Item = &'a str>,
1004 column_map: &HashMap<String, String>,
1005) -> (HashMap<String, String>, Vec<String>) {
1006 let mut native: HashMap<String, String> = HashMap::new();
1007 let mut arg_fallbacks: Vec<String> = Vec::new();
1008
1009 for arg in direct_arg_names {
1010 if let Some(col_type) = column_map.get(*arg) {
1011 native.insert((*arg).to_string(), col_type.clone());
1012 } else {
1013 arg_fallbacks.push((*arg).to_string());
1014 }
1015 }
1016
1017 for name in inject_param_names {
1018 if let Some(col_type) = column_map.get(name) {
1019 native.insert(name.to_string(), col_type.clone());
1020 }
1021 }
1022
1023 (native, arg_fallbacks)
1024}
1025
1026#[cfg(test)]
1027#[path = "database_validator_tests.rs"]
1028mod database_validator_tests;