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 MissingAdditionalView {
61 query_name: String,
63 view_name: String,
65 },
66 MissingJsonColumn {
68 query_name: String,
70 sql_source: String,
72 column_name: String,
74 },
75 WrongJsonColumnType {
77 query_name: String,
79 sql_source: String,
81 column_name: String,
83 actual_type: String,
85 },
86 MissingCursorColumn {
88 query_name: String,
90 sql_source: String,
92 column_name: String,
94 },
95 MissingJsonKey {
97 query_name: String,
99 type_name: String,
101 sql_source: String,
103 json_column: String,
105 field_name: String,
107 json_key: String,
109 },
110 NativeColumnFallback {
116 query_name: String,
118 sql_source: String,
120 arg_name: String,
122 },
123 TypeConvertibility {
128 query_name: String,
130 sql_source: String,
132 arg_name: String,
134 graphql_type: String,
136 column_type: String,
138 },
139}
140
141impl fmt::Display for DatabaseWarning {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 match self {
144 Self::MissingRelation {
145 query_name,
146 sql_source,
147 } => {
148 write!(
149 f,
150 "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
151 )
152 },
153 Self::MissingFunction {
154 mutation_name,
155 sql_source,
156 } => {
157 write!(
158 f,
159 "mutation `{mutation_name}`: sql_source function `{sql_source}` does not exist in database"
160 )
161 },
162 Self::MissingAdditionalView {
163 query_name,
164 view_name,
165 } => {
166 write!(
167 f,
168 "query `{query_name}`: additional_view `{view_name}` does not exist in database"
169 )
170 },
171 Self::MissingJsonColumn {
172 query_name,
173 sql_source,
174 column_name,
175 } => {
176 write!(
177 f,
178 "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
179 )
180 },
181 Self::WrongJsonColumnType {
182 query_name,
183 sql_source,
184 column_name,
185 actual_type,
186 } => {
187 write!(
188 f,
189 "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
190 )
191 },
192 Self::MissingCursorColumn {
193 query_name,
194 sql_source,
195 column_name,
196 } => {
197 write!(
198 f,
199 "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
200 )
201 },
202 Self::MissingJsonKey {
203 query_name,
204 type_name,
205 sql_source,
206 json_column,
207 field_name,
208 json_key,
209 } => {
210 write!(
211 f,
212 "query `{query_name}`: field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
213 )
214 },
215 Self::NativeColumnFallback {
216 query_name,
217 sql_source,
218 arg_name,
219 } => {
220 write!(
221 f,
222 "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
223 (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
224 `{sql_source}`. Add a native column with an index for O(log n) lookup."
225 )
226 },
227 Self::TypeConvertibility {
228 query_name,
229 sql_source,
230 arg_name,
231 graphql_type,
232 column_type,
233 } => {
234 write!(
235 f,
236 "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
237 `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
238 Align the argument type with the column, or store the field in `data` for JSONB extraction."
239 )
240 },
241 }
242 }
243}
244
245pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
247 let lower = data_type.to_lowercase();
248 match db_type {
249 DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
250 DatabaseType::MySQL => lower == "json",
251 DatabaseType::SQLite => lower.contains("json"),
252 DatabaseType::SQLServer => true,
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260enum SqlFamily {
261 Numeric,
263 Boolean,
265 Uuid,
267 Text,
269 Temporal,
271 Json,
273 Other,
275}
276
277fn sql_type_family(sql_type: &str) -> SqlFamily {
282 let lower = sql_type.trim().to_lowercase();
283 let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
284 match base {
285 "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
286 | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
287 | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
288 "boolean" | "bool" | "bit" => SqlFamily::Boolean,
289 "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
290 "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
291 | "character" | "clob" | "string" => SqlFamily::Text,
292 "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
293 | "interval" | "smalldatetime" => SqlFamily::Temporal,
294 "json" | "jsonb" => SqlFamily::Json,
295 _ => SqlFamily::Other,
296 }
297}
298
299fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
308 let base = match field_type {
310 FieldType::List(inner) => inner.as_ref(),
311 other => other,
312 };
313 let family = sql_type_family(sql_type);
314 if family == SqlFamily::Other {
316 return true;
317 }
318 match base {
319 FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
320 FieldType::Boolean => family == SqlFamily::Boolean,
321 FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
322 _ => true,
323 }
324}
325
326fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
328 match sql_source.split_once('.') {
329 Some((schema, table)) => (Some(schema), table),
330 None => (None, sql_source),
331 }
332}
333
334fn relation_exists(
336 schema_qualified: &HashMap<(String, String), RelationInfo>,
337 unqualified: &HashMap<String, Vec<String>>,
338 sql_source: &str,
339) -> bool {
340 let (schema, name) = split_schema_qualified(sql_source);
341 if let Some(s) = schema {
342 schema_qualified.contains_key(&(s.to_string(), name.to_string()))
343 } else {
344 unqualified.contains_key(name)
345 }
346}
347
348async fn relation_source_exists(
357 introspector: &impl DatabaseIntrospector,
358 schema_qualified: &HashMap<(String, String), RelationInfo>,
359 unqualified: &HashMap<String, Vec<String>>,
360 sql_source: &str,
361) -> fraiseql_core::Result<bool> {
362 let (schema, name) = split_schema_qualified(sql_source);
363 if let Some(s) = schema {
364 if let Some(exists) = introspector.qualified_relation_exists(s, name).await? {
365 return Ok(exists);
366 }
367 }
368 Ok(relation_exists(schema_qualified, unqualified, sql_source))
370}
371
372async fn function_source_exists(
378 introspector: &impl DatabaseIntrospector,
379 sql_source: &str,
380) -> fraiseql_core::Result<bool> {
381 let (schema, name) = split_schema_qualified(sql_source);
382 Ok(introspector.function_exists(schema, name).await?.unwrap_or(true))
383}
384
385pub async fn validate_schema_against_database(
398 schema: &CompiledSchema,
399 introspector: &impl DatabaseIntrospector,
400) -> fraiseql_core::Result<DatabaseValidationReport> {
401 const AUTO_PARAM_NAMES: &[&str] = &[
404 "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
405 ];
406
407 let mut warnings = Vec::new();
408 let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
409 let db_type = introspector.database_type();
410
411 let relations = introspector.list_relations().await?;
413 let (schema_qualified, unqualified) = build_relation_maps(&relations);
414
415 for query in &schema.queries {
417 if let Some(ref source) = query.sql_source {
418 if !relation_source_exists(introspector, &schema_qualified, &unqualified, source)
420 .await?
421 {
422 warnings.push(DatabaseWarning::MissingRelation {
423 query_name: query.name.clone(),
424 sql_source: source.clone(),
425 });
426 continue; }
428
429 let columns = introspector.get_columns(source).await?;
433 let column_map: HashMap<String, String> =
434 columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
435
436 let jsonb_col = &query.jsonb_column;
438 if !jsonb_col.is_empty() {
439 if let Some(actual_type) = column_map.get(jsonb_col) {
440 if !is_json_type(actual_type, db_type) {
441 warnings.push(DatabaseWarning::WrongJsonColumnType {
442 query_name: query.name.clone(),
443 sql_source: source.clone(),
444 column_name: jsonb_col.clone(),
445 actual_type: actual_type.clone(),
446 });
447 }
448 } else {
449 warnings.push(DatabaseWarning::MissingJsonColumn {
450 query_name: query.name.clone(),
451 sql_source: source.clone(),
452 column_name: jsonb_col.clone(),
453 });
454 }
455 }
456
457 if query.relay {
459 if let Some(ref cursor_col) = query.relay_cursor_column {
460 if !column_map.contains_key(cursor_col) {
461 warnings.push(DatabaseWarning::MissingCursorColumn {
462 query_name: query.name.clone(),
463 sql_source: source.clone(),
464 column_name: cursor_col.clone(),
465 });
466 }
467 }
468 }
469
470 if !jsonb_col.is_empty() {
472 let json_type_ok =
473 column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
474
475 if json_type_ok {
476 validate_json_keys(
477 schema,
478 query,
479 source,
480 jsonb_col,
481 introspector,
482 source, &mut warnings,
484 )
485 .await?;
486 }
487 }
488
489 let direct_args: Vec<&str> = query
494 .arguments
495 .iter()
496 .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
497 .map(|a| a.name.as_str())
498 .collect();
499
500 let (query_native, arg_fallbacks) = detect_query_native_columns(
501 &direct_args,
502 query.inject_params.keys().map(String::as_str),
503 &column_map,
504 );
505 for arg_name in arg_fallbacks {
506 warnings.push(DatabaseWarning::NativeColumnFallback {
507 query_name: query.name.clone(),
508 sql_source: source.clone(),
509 arg_name,
510 });
511 }
512
513 for arg in &query.arguments {
517 if let Some(col_type) = query_native.get(&arg.name) {
518 if !arg_type_convertible(&arg.arg_type, col_type) {
519 warnings.push(DatabaseWarning::TypeConvertibility {
520 query_name: query.name.clone(),
521 sql_source: source.clone(),
522 arg_name: arg.name.clone(),
523 graphql_type: arg.arg_type.to_graphql_string(),
524 column_type: col_type.clone(),
525 });
526 }
527 }
528 }
529
530 if !query_native.is_empty() {
531 native_columns.insert(query.name.clone(), query_native);
532 }
533
534 for view in &query.additional_views {
536 if !relation_source_exists(introspector, &schema_qualified, &unqualified, view)
537 .await?
538 {
539 warnings.push(DatabaseWarning::MissingAdditionalView {
540 query_name: query.name.clone(),
541 view_name: view.clone(),
542 });
543 }
544 }
545 }
546 }
547
548 for mutation in &schema.mutations {
552 if let Some(ref source) = mutation.sql_source {
553 if !function_source_exists(introspector, source).await? {
554 warnings.push(DatabaseWarning::MissingFunction {
555 mutation_name: mutation.name.clone(),
556 sql_source: source.clone(),
557 });
558 }
559 }
560 }
561
562 Ok(DatabaseValidationReport {
563 warnings,
564 native_columns,
565 })
566}
567
568fn build_relation_maps(
570 relations: &[RelationInfo],
571) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
572 let mut schema_qualified = HashMap::new();
573 let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
574
575 for rel in relations {
576 schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
577 unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
578 }
579
580 (schema_qualified, unqualified)
581}
582
583pub async fn find_unbacked_sources(
600 schema: &CompiledSchema,
601 introspector: &impl DatabaseIntrospector,
602) -> fraiseql_core::Result<Vec<SourceProbe>> {
603 let relations = introspector.list_relations().await?;
604 let (schema_qualified, unqualified) = build_relation_maps(&relations);
605
606 let mut unbacked = Vec::new();
607 for probe in sql_source_probes(schema) {
608 let exists = match probe.kind {
609 SourceKind::Function => introspector
610 .function_exists(probe.schema.as_deref(), &probe.name)
611 .await?
612 .unwrap_or(true),
613 SourceKind::Relation => {
614 if let Some(s) = &probe.schema {
615 match introspector.qualified_relation_exists(s, &probe.name).await? {
616 Some(exists) => exists,
617 None => schema_qualified.contains_key(&(s.clone(), probe.name.clone())),
618 }
619 } else {
620 unqualified.contains_key(&probe.name)
621 }
622 },
623 };
624 if !exists {
625 unbacked.push(probe);
626 }
627 }
628 Ok(unbacked)
629}
630
631async fn validate_json_keys(
633 schema: &CompiledSchema,
634 query: &fraiseql_core::schema::QueryDefinition,
635 source: &str,
636 jsonb_col: &str,
637 introspector: &impl DatabaseIntrospector,
638 table_name: &str,
639 warnings: &mut Vec<DatabaseWarning>,
640) -> fraiseql_core::Result<()> {
641 let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
642
643 if samples.is_empty() {
644 return Ok(());
645 }
646
647 let mut all_keys = HashSet::new();
649 for sample in &samples {
650 if let serde_json::Value::Object(map) = sample {
651 for key in map.keys() {
652 all_keys.insert(key.clone());
653 }
654 }
655 }
656
657 if all_keys.is_empty() {
658 return Ok(());
659 }
660
661 let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
663
664 if let Some(type_def) = type_def {
665 for field in &type_def.fields {
666 let field_str = field.name.as_str();
667 let json_key = fraiseql_core::utils::to_snake_case(field_str);
671 if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
674 continue;
675 }
676 if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
677 warnings.push(DatabaseWarning::MissingJsonKey {
678 query_name: query.name.clone(),
679 type_name: type_def.name.as_str().to_string(),
680 sql_source: source.to_string(),
681 json_column: jsonb_col.to_string(),
682 field_name: field_str.to_string(),
683 json_key,
684 });
685 }
686 }
687 }
688
689 Ok(())
690}
691
692pub enum AnyIntrospector {
697 Postgres(fraiseql_core::db::PostgresIntrospector),
699 #[cfg(feature = "mysql")]
700 MySql(fraiseql_core::db::MySqlIntrospector),
702 #[cfg(feature = "sqlite")]
703 Sqlite(fraiseql_core::db::SqliteIntrospector),
705 #[cfg(feature = "sqlserver")]
706 SqlServer(fraiseql_core::db::SqlServerIntrospector),
708}
709
710impl DatabaseIntrospector for AnyIntrospector {
711 async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
712 match self {
713 Self::Postgres(i) => i.list_fact_tables().await,
714 #[cfg(feature = "mysql")]
715 Self::MySql(i) => i.list_fact_tables().await,
716 #[cfg(feature = "sqlite")]
717 Self::Sqlite(i) => i.list_fact_tables().await,
718 #[cfg(feature = "sqlserver")]
719 Self::SqlServer(i) => i.list_fact_tables().await,
720 }
721 }
722
723 async fn get_columns(
724 &self,
725 table_name: &str,
726 ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
727 match self {
728 Self::Postgres(i) => i.get_columns(table_name).await,
729 #[cfg(feature = "mysql")]
730 Self::MySql(i) => i.get_columns(table_name).await,
731 #[cfg(feature = "sqlite")]
732 Self::Sqlite(i) => i.get_columns(table_name).await,
733 #[cfg(feature = "sqlserver")]
734 Self::SqlServer(i) => i.get_columns(table_name).await,
735 }
736 }
737
738 async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
739 match self {
740 Self::Postgres(i) => i.get_indexed_columns(table_name).await,
741 #[cfg(feature = "mysql")]
742 Self::MySql(i) => i.get_indexed_columns(table_name).await,
743 #[cfg(feature = "sqlite")]
744 Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
745 #[cfg(feature = "sqlserver")]
746 Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
747 }
748 }
749
750 fn database_type(&self) -> DatabaseType {
751 match self {
752 Self::Postgres(i) => i.database_type(),
753 #[cfg(feature = "mysql")]
754 Self::MySql(i) => i.database_type(),
755 #[cfg(feature = "sqlite")]
756 Self::Sqlite(i) => i.database_type(),
757 #[cfg(feature = "sqlserver")]
758 Self::SqlServer(i) => i.database_type(),
759 }
760 }
761
762 async fn get_sample_jsonb(
763 &self,
764 table_name: &str,
765 column_name: &str,
766 ) -> fraiseql_core::Result<Option<serde_json::Value>> {
767 match self {
768 Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
769 #[cfg(feature = "mysql")]
770 Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
771 #[cfg(feature = "sqlite")]
772 Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
773 #[cfg(feature = "sqlserver")]
774 Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
775 }
776 }
777
778 async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
779 match self {
780 Self::Postgres(i) => i.list_relations().await,
781 #[cfg(feature = "mysql")]
782 Self::MySql(i) => i.list_relations().await,
783 #[cfg(feature = "sqlite")]
784 Self::Sqlite(i) => i.list_relations().await,
785 #[cfg(feature = "sqlserver")]
786 Self::SqlServer(i) => i.list_relations().await,
787 }
788 }
789
790 async fn get_sample_json_rows(
791 &self,
792 table_name: &str,
793 column_name: &str,
794 limit: usize,
795 ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
796 match self {
797 Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
798 #[cfg(feature = "mysql")]
799 Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
800 #[cfg(feature = "sqlite")]
801 Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
802 #[cfg(feature = "sqlserver")]
803 Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
804 }
805 }
806
807 async fn function_exists(
808 &self,
809 schema: Option<&str>,
810 name: &str,
811 ) -> fraiseql_core::Result<Option<bool>> {
812 match self {
815 Self::Postgres(i) => i.function_exists(schema, name).await,
816 #[cfg(feature = "mysql")]
817 Self::MySql(i) => i.function_exists(schema, name).await,
818 #[cfg(feature = "sqlite")]
819 Self::Sqlite(i) => i.function_exists(schema, name).await,
820 #[cfg(feature = "sqlserver")]
821 Self::SqlServer(i) => i.function_exists(schema, name).await,
822 }
823 }
824
825 async fn qualified_relation_exists(
826 &self,
827 schema: &str,
828 name: &str,
829 ) -> fraiseql_core::Result<Option<bool>> {
830 match self {
831 Self::Postgres(i) => i.qualified_relation_exists(schema, name).await,
832 #[cfg(feature = "mysql")]
833 Self::MySql(i) => i.qualified_relation_exists(schema, name).await,
834 #[cfg(feature = "sqlite")]
835 Self::Sqlite(i) => i.qualified_relation_exists(schema, name).await,
836 #[cfg(feature = "sqlserver")]
837 Self::SqlServer(i) => i.qualified_relation_exists(schema, name).await,
838 }
839 }
840}
841
842#[allow(clippy::unused_async)] pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
853 if db_url.starts_with("postgres") {
854 use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
855 use tokio_postgres::NoTls;
856
857 let mut cfg = Config::new();
858 cfg.url = Some(db_url.to_string());
859 cfg.manager = Some(ManagerConfig {
860 recycling_method: RecyclingMethod::Fast,
861 });
862 cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
863
864 let pool = cfg
865 .create_pool(Some(Runtime::Tokio1), NoTls)
866 .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
867
868 Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
869 } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
870 #[cfg(feature = "mysql")]
871 {
872 use sqlx::mysql::MySqlPool;
873
874 let pool = MySqlPool::connect(db_url)
875 .await
876 .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
877
878 Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
879 }
880 #[cfg(not(feature = "mysql"))]
881 {
882 anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
883 }
884 } else if db_url.starts_with("sqlite")
885 || std::path::Path::new(db_url)
886 .extension()
887 .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
888 {
889 #[cfg(feature = "sqlite")]
890 {
891 use sqlx::sqlite::SqlitePool;
892
893 let pool = SqlitePool::connect(db_url)
894 .await
895 .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
896
897 Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
898 }
899 #[cfg(not(feature = "sqlite"))]
900 {
901 anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
902 }
903 } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
904 #[cfg(feature = "sqlserver")]
905 {
906 use bb8::Pool;
907 use bb8_tiberius::ConnectionManager;
908 use tiberius::Config;
909
910 let config = Config::from_ado_string(db_url).map_err(|e| {
911 anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
912 })?;
913 let mgr = ConnectionManager::build(config).map_err(|e| {
914 anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
915 })?;
916 let pool = Pool::builder()
917 .max_size(2)
918 .build(mgr)
919 .await
920 .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
921
922 Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
923 }
924 #[cfg(not(feature = "sqlserver"))]
925 {
926 anyhow::bail!(
927 "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
928 )
929 }
930 } else {
931 anyhow::bail!("Unrecognized database URL scheme: {db_url}")
932 }
933}
934
935fn detect_query_native_columns<'a>(
948 direct_arg_names: &[&str],
949 inject_param_names: impl Iterator<Item = &'a str>,
950 column_map: &HashMap<String, String>,
951) -> (HashMap<String, String>, Vec<String>) {
952 let mut native: HashMap<String, String> = HashMap::new();
953 let mut arg_fallbacks: Vec<String> = Vec::new();
954
955 for arg in direct_arg_names {
956 if let Some(col_type) = column_map.get(*arg) {
957 native.insert((*arg).to_string(), col_type.clone());
958 } else {
959 arg_fallbacks.push((*arg).to_string());
960 }
961 }
962
963 for name in inject_param_names {
964 if let Some(col_type) = column_map.get(name) {
965 native.insert(name.to_string(), col_type.clone());
966 }
967 }
968
969 (native, arg_fallbacks)
970}
971
972#[cfg(test)]
973#[path = "database_validator_tests.rs"]
974mod database_validator_tests;