Skip to main content

fraiseql_cli/schema/
database_validator.rs

1//! Compile-time database validation for schema definitions.
2//!
3//! Validates a compiled schema against a live database at three levels:
4//! - **L1**: `sql_source` relation exists in the database
5//! - **L2**: Columns and JSON column types match
6//! - **L3**: JSONB keys exist in sampled rows (best-effort)
7//!
8//! All diagnostics are warnings — compilation never fails due to validation.
9
10use std::{
11    collections::{HashMap, HashSet},
12    fmt,
13};
14
15use fraiseql_core::{
16    db::{
17        DatabaseType,
18        introspector::{DatabaseIntrospector, RelationInfo},
19    },
20    schema::{CompiledSchema, FieldType},
21};
22
23/// Report containing all database validation warnings and discovered metadata.
24pub struct DatabaseValidationReport {
25    /// All warnings emitted during validation.
26    pub warnings:       Vec<DatabaseWarning>,
27    /// Native columns discovered per query during L2 validation.
28    ///
29    /// Key: query name. Value: map of argument name → PostgreSQL type string
30    /// (e.g. `"uuid"`, `"integer"`, `"text"`).
31    ///
32    /// Only contains entries for queries that have at least one direct argument
33    /// with a matching native column on their `sql_source`.
34    pub native_columns: HashMap<String, HashMap<String, String>>,
35}
36
37/// A single database validation warning.
38#[derive(Debug)]
39pub enum DatabaseWarning {
40    /// L1: `sql_source` relation does not exist.
41    MissingRelation {
42        /// Name of the query or mutation.
43        query_name: String,
44        /// The `sql_source` value that was not found.
45        sql_source: String,
46    },
47    /// L1: `additional_view` does not exist.
48    MissingAdditionalView {
49        /// Name of the query.
50        query_name: String,
51        /// The view name that was not found.
52        view_name:  String,
53    },
54    /// L2: `jsonb_column` does not exist on the relation.
55    MissingJsonColumn {
56        /// Name of the query.
57        query_name:  String,
58        /// The `sql_source` relation.
59        sql_source:  String,
60        /// The missing column name.
61        column_name: String,
62    },
63    /// L2: `jsonb_column` exists but is not a JSON/JSONB type.
64    WrongJsonColumnType {
65        /// Name of the query.
66        query_name:  String,
67        /// The `sql_source` relation.
68        sql_source:  String,
69        /// The column name.
70        column_name: String,
71        /// The actual SQL data type.
72        actual_type: String,
73    },
74    /// L2: `relay_cursor_column` does not exist on the relation.
75    MissingCursorColumn {
76        /// Name of the query.
77        query_name:  String,
78        /// The `sql_source` relation.
79        sql_source:  String,
80        /// The missing cursor column name.
81        column_name: String,
82    },
83    /// L3: a JSON key path is declared but not found in sampled data.
84    MissingJsonKey {
85        /// Name of the query.
86        query_name:  String,
87        /// The GraphQL type that declares the field (where to fix it).
88        type_name:   String,
89        /// The `sql_source` relation.
90        sql_source:  String,
91        /// The JSON column being sampled.
92        json_column: String,
93        /// The GraphQL field name.
94        field_name:  String,
95        /// The snake_case key looked up in the JSON.
96        json_key:    String,
97    },
98    /// L2: a direct query argument has no matching native column — will fall back to JSONB
99    /// extraction.
100    ///
101    /// For best performance, consider adding a native column with the same name
102    /// and an index on the `sql_source` table/view.
103    NativeColumnFallback {
104        /// Name of the query.
105        query_name: String,
106        /// The `sql_source` relation.
107        sql_source: String,
108        /// The argument name that has no matching native column.
109        arg_name:   String,
110    },
111    /// L2: a direct query argument resolves to a native column whose SQL type cannot
112    /// cleanly drive the predicate for the argument's GraphQL scalar type (e.g. an
113    /// `Int` argument filtering a `uuid` column — `WHERE col = $N` errors or never
114    /// matches).
115    TypeConvertibility {
116        /// Name of the query.
117        query_name:   String,
118        /// The `sql_source` relation.
119        sql_source:   String,
120        /// The argument name.
121        arg_name:     String,
122        /// The argument's declared GraphQL type (e.g. `Int`).
123        graphql_type: String,
124        /// The native column's SQL type (e.g. `uuid`).
125        column_type:  String,
126    },
127}
128
129impl fmt::Display for DatabaseWarning {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::MissingRelation {
133                query_name,
134                sql_source,
135            } => {
136                write!(
137                    f,
138                    "query `{query_name}`: sql_source `{sql_source}` does not exist in database"
139                )
140            },
141            Self::MissingAdditionalView {
142                query_name,
143                view_name,
144            } => {
145                write!(
146                    f,
147                    "query `{query_name}`: additional_view `{view_name}` does not exist in database"
148                )
149            },
150            Self::MissingJsonColumn {
151                query_name,
152                sql_source,
153                column_name,
154            } => {
155                write!(
156                    f,
157                    "query `{query_name}`: column `{column_name}` not found on `{sql_source}`"
158                )
159            },
160            Self::WrongJsonColumnType {
161                query_name,
162                sql_source,
163                column_name,
164                actual_type,
165            } => {
166                write!(
167                    f,
168                    "query `{query_name}`: column `{column_name}` on `{sql_source}` is `{actual_type}`, expected json/jsonb"
169                )
170            },
171            Self::MissingCursorColumn {
172                query_name,
173                sql_source,
174                column_name,
175            } => {
176                write!(
177                    f,
178                    "query `{query_name}`: relay cursor column `{column_name}` not found on `{sql_source}`"
179                )
180            },
181            Self::MissingJsonKey {
182                query_name,
183                type_name,
184                sql_source,
185                json_column,
186                field_name,
187                json_key,
188            } => {
189                write!(
190                    f,
191                    "query `{query_name}`: field `{type_name}.{field_name}` (key `{json_key}`) not found in `{sql_source}.{json_column}` sample data"
192                )
193            },
194            Self::NativeColumnFallback {
195                query_name,
196                sql_source,
197                arg_name,
198            } => {
199                write!(
200                    f,
201                    "query `{query_name}`: argument `{arg_name}` will use JSONB extraction \
202                     (`{sql_source}.data->>''{arg_name}''`) — no native column `{arg_name}` found on \
203                     `{sql_source}`. Add a native column with an index for O(log n) lookup."
204                )
205            },
206            Self::TypeConvertibility {
207                query_name,
208                sql_source,
209                arg_name,
210                graphql_type,
211                column_type,
212            } => {
213                write!(
214                    f,
215                    "query `{query_name}`: argument `{arg_name}` is `{graphql_type}` but the native column \
216                     `{sql_source}.{arg_name}` is `{column_type}` — the predicate may error or never match. \
217                     Align the argument type with the column, or store the field in `data` for JSONB extraction."
218                )
219            },
220        }
221    }
222}
223
224/// Check if a SQL data type represents a JSON column for the given database.
225pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
226    let lower = data_type.to_lowercase();
227    match db_type {
228        DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
229        DatabaseType::MySQL => lower == "json",
230        DatabaseType::SQLite => lower.contains("json"),
231        // SQL Server has no native JSON type — always attempt JSON
232        // validation for the configured jsonb_column
233        DatabaseType::SQLServer => true,
234    }
235}
236
237/// Coarse SQL type families, used to flag argument↔column type mismatches.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239enum SqlFamily {
240    /// Integer/decimal/float numeric types.
241    Numeric,
242    /// Boolean.
243    Boolean,
244    /// UUID / `uniqueidentifier`.
245    Uuid,
246    /// Character/string types.
247    Text,
248    /// Date/time/interval types.
249    Temporal,
250    /// `json` / `jsonb`.
251    Json,
252    /// Anything else (custom domains, enums, arrays, geometry, …) — never flagged.
253    Other,
254}
255
256/// Classify a (any-dialect) SQL type string into a coarse [`SqlFamily`].
257///
258/// Strips length/precision and multi-word suffixes (`character varying`,
259/// `timestamp with time zone`, `double precision`) down to the leading base word.
260fn sql_type_family(sql_type: &str) -> SqlFamily {
261    let lower = sql_type.trim().to_lowercase();
262    let base = lower.split(['(', ' ', '[']).next().unwrap_or(lower.as_str());
263    match base {
264        "smallint" | "integer" | "int" | "int2" | "int4" | "int8" | "bigint" | "serial"
265        | "bigserial" | "smallserial" | "numeric" | "decimal" | "real" | "double" | "float"
266        | "float4" | "float8" | "money" | "mediumint" | "tinyint" => SqlFamily::Numeric,
267        "boolean" | "bool" | "bit" => SqlFamily::Boolean,
268        "uuid" | "uniqueidentifier" => SqlFamily::Uuid,
269        "text" | "varchar" | "char" | "bpchar" | "name" | "citext" | "nvarchar" | "nchar"
270        | "character" | "clob" | "string" => SqlFamily::Text,
271        "date" | "time" | "timestamp" | "timestamptz" | "timetz" | "datetime" | "datetime2"
272        | "interval" | "smalldatetime" => SqlFamily::Temporal,
273        "json" | "jsonb" => SqlFamily::Json,
274        _ => SqlFamily::Other,
275    }
276}
277
278/// Whether a query argument's GraphQL type can cleanly drive an equality predicate
279/// against a native column of `sql_type`.
280///
281/// Conservative by design: only the strongly-typed scalars (`Int`, `Float`,
282/// `Decimal`, `Boolean`, `UUID`) can flag a mismatch, and only against a *known*
283/// incompatible column family. Permissive scalars (`String`, `ID`, date/time, `JSON`,
284/// custom scalars) and non-scalar references never warn — the runtime binds them as
285/// text-coercible parameters, and `ID` intentionally spans uuid / integer / text keys.
286fn arg_type_convertible(field_type: &FieldType, sql_type: &str) -> bool {
287    // A list filter (`[ID!]`) drives the predicate with its element type.
288    let base = match field_type {
289        FieldType::List(inner) => inner.as_ref(),
290        other => other,
291    };
292    let family = sql_type_family(sql_type);
293    // Unknown column family → don't second-guess it.
294    if family == SqlFamily::Other {
295        return true;
296    }
297    match base {
298        FieldType::Int | FieldType::Float | FieldType::Decimal => family == SqlFamily::Numeric,
299        FieldType::Boolean => family == SqlFamily::Boolean,
300        FieldType::Uuid => matches!(family, SqlFamily::Uuid | SqlFamily::Text),
301        _ => true,
302    }
303}
304
305/// Split a potentially schema-qualified name into (optional_schema, name).
306fn split_schema_qualified(sql_source: &str) -> (Option<&str>, &str) {
307    match sql_source.split_once('.') {
308        Some((schema, table)) => (Some(schema), table),
309        None => (None, sql_source),
310    }
311}
312
313/// Check if a relation exists in the relation lookup maps.
314fn relation_exists(
315    schema_qualified: &HashMap<(String, String), RelationInfo>,
316    unqualified: &HashMap<String, Vec<String>>,
317    sql_source: &str,
318) -> bool {
319    let (schema, name) = split_schema_qualified(sql_source);
320    if let Some(s) = schema {
321        schema_qualified.contains_key(&(s.to_string(), name.to_string()))
322    } else {
323        unqualified.contains_key(name)
324    }
325}
326
327/// Convert a `camelCase` or `PascalCase` field name to `snake_case`.
328///
329/// This matches the convention used by FraiseQL for JSONB key extraction.
330pub(crate) fn to_snake_case(name: &str) -> String {
331    let mut result = String::with_capacity(name.len() + 4);
332    for (i, ch) in name.chars().enumerate() {
333        if ch.is_uppercase() {
334            if i > 0 {
335                result.push('_');
336            }
337            result.push(ch.to_lowercase().next().unwrap_or(ch));
338        } else {
339            result.push(ch);
340        }
341    }
342    result
343}
344
345/// Validate a compiled schema against a live database.
346///
347/// Performs three levels of validation:
348/// - **L1**: Checks that `sql_source` relations exist
349/// - **L2**: Checks column existence and JSON column types
350/// - **L3**: Checks JSONB key existence via sampling
351///
352/// All diagnostics are warnings — the report never causes compilation to fail.
353///
354/// # Errors
355///
356/// Returns `FraiseQLError` if database introspection queries fail.
357pub async fn validate_schema_against_database(
358    schema: &CompiledSchema,
359    introspector: &impl DatabaseIntrospector,
360) -> fraiseql_core::Result<DatabaseValidationReport> {
361    // Auto-wired argument names excluded from direct-arg native column detection.
362    // Must stay in sync with AUTO_PARAM_NAMES in fraiseql-core/runtime/executor/query.rs.
363    const AUTO_PARAM_NAMES: &[&str] = &[
364        "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
365    ];
366
367    let mut warnings = Vec::new();
368    let mut native_columns: HashMap<String, HashMap<String, String>> = HashMap::new();
369    let db_type = introspector.database_type();
370
371    // L1: Build relation lookup maps
372    let relations = introspector.list_relations().await?;
373    let (schema_qualified, unqualified) = build_relation_maps(&relations);
374
375    // Validate queries
376    for query in &schema.queries {
377        if let Some(ref source) = query.sql_source {
378            // L1: Check relation exists
379            if !relation_exists(&schema_qualified, &unqualified, source) {
380                warnings.push(DatabaseWarning::MissingRelation {
381                    query_name: query.name.clone(),
382                    sql_source: source.clone(),
383                });
384                continue; // Skip L2/L3 if relation doesn't exist
385            }
386
387            // L2: Get columns for the relation.
388            // Pass the full source (possibly schema-qualified like "benchmark.tv_post") so
389            // the introspector can use the explicit schema when present.
390            let columns = introspector.get_columns(source).await?;
391            let column_map: HashMap<String, String> =
392                columns.into_iter().map(|(name, dtype, _)| (name, dtype)).collect();
393
394            // L2: Check jsonb_column
395            let jsonb_col = &query.jsonb_column;
396            if !jsonb_col.is_empty() {
397                if let Some(actual_type) = column_map.get(jsonb_col) {
398                    if !is_json_type(actual_type, db_type) {
399                        warnings.push(DatabaseWarning::WrongJsonColumnType {
400                            query_name:  query.name.clone(),
401                            sql_source:  source.clone(),
402                            column_name: jsonb_col.clone(),
403                            actual_type: actual_type.clone(),
404                        });
405                    }
406                } else {
407                    warnings.push(DatabaseWarning::MissingJsonColumn {
408                        query_name:  query.name.clone(),
409                        sql_source:  source.clone(),
410                        column_name: jsonb_col.clone(),
411                    });
412                }
413            }
414
415            // L2: Check relay_cursor_column
416            if query.relay {
417                if let Some(ref cursor_col) = query.relay_cursor_column {
418                    if !column_map.contains_key(cursor_col) {
419                        warnings.push(DatabaseWarning::MissingCursorColumn {
420                            query_name:  query.name.clone(),
421                            sql_source:  source.clone(),
422                            column_name: cursor_col.clone(),
423                        });
424                    }
425                }
426            }
427
428            // L3: Sample JSON keys if jsonb_column is valid JSON type
429            if !jsonb_col.is_empty() {
430                let json_type_ok =
431                    column_map.get(jsonb_col).is_some_and(|t| is_json_type(t, db_type));
432
433                if json_type_ok {
434                    validate_json_keys(
435                        schema,
436                        query,
437                        source,
438                        jsonb_col,
439                        introspector,
440                        source, // pass full schema-qualified source for sample queries
441                        &mut warnings,
442                    )
443                    .await?;
444                }
445            }
446
447            // L2: Detect native columns for direct (non-auto-param) arguments AND
448            // inject params. Both are filtered against the view at runtime, so a name
449            // that matches a real column must use the native-column path
450            // (`WHERE col = $N`) rather than the JSONB fallback (`data->>'name'`).
451            let direct_args: Vec<&str> = query
452                .arguments
453                .iter()
454                .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
455                .map(|a| a.name.as_str())
456                .collect();
457
458            let (query_native, arg_fallbacks) = detect_query_native_columns(
459                &direct_args,
460                query.inject_params.keys().map(String::as_str),
461                &column_map,
462            );
463            for arg_name in arg_fallbacks {
464                warnings.push(DatabaseWarning::NativeColumnFallback {
465                    query_name: query.name.clone(),
466                    sql_source: source.clone(),
467                    arg_name,
468                });
469            }
470
471            // L2 (type-convertibility): a direct argument that resolved to a native
472            // column whose SQL type cannot cleanly drive the predicate is a likely
473            // authoring bug (e.g. an `Int` argument filtering a `uuid` column).
474            for arg in &query.arguments {
475                if let Some(col_type) = query_native.get(&arg.name) {
476                    if !arg_type_convertible(&arg.arg_type, col_type) {
477                        warnings.push(DatabaseWarning::TypeConvertibility {
478                            query_name:   query.name.clone(),
479                            sql_source:   source.clone(),
480                            arg_name:     arg.name.clone(),
481                            graphql_type: arg.arg_type.to_graphql_string(),
482                            column_type:  col_type.clone(),
483                        });
484                    }
485                }
486            }
487
488            if !query_native.is_empty() {
489                native_columns.insert(query.name.clone(), query_native);
490            }
491
492            // L1: Check additional_views
493            for view in &query.additional_views {
494                if !relation_exists(&schema_qualified, &unqualified, view) {
495                    warnings.push(DatabaseWarning::MissingAdditionalView {
496                        query_name: query.name.clone(),
497                        view_name:  view.clone(),
498                    });
499                }
500            }
501        }
502    }
503
504    // Validate mutations (L1 only)
505    for mutation in &schema.mutations {
506        if let Some(ref source) = mutation.sql_source {
507            if !relation_exists(&schema_qualified, &unqualified, source) {
508                warnings.push(DatabaseWarning::MissingRelation {
509                    query_name: mutation.name.clone(),
510                    sql_source: source.clone(),
511                });
512            }
513        }
514    }
515
516    Ok(DatabaseValidationReport {
517        warnings,
518        native_columns,
519    })
520}
521
522/// Build lookup maps from the list of relations.
523fn build_relation_maps(
524    relations: &[RelationInfo],
525) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
526    let mut schema_qualified = HashMap::new();
527    let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
528
529    for rel in relations {
530        schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
531        unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
532    }
533
534    (schema_qualified, unqualified)
535}
536
537/// Validate JSON keys in sampled data for L3 checking.
538async fn validate_json_keys(
539    schema: &CompiledSchema,
540    query: &fraiseql_core::schema::QueryDefinition,
541    source: &str,
542    jsonb_col: &str,
543    introspector: &impl DatabaseIntrospector,
544    table_name: &str,
545    warnings: &mut Vec<DatabaseWarning>,
546) -> fraiseql_core::Result<()> {
547    let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
548
549    if samples.is_empty() {
550        return Ok(());
551    }
552
553    // Merge all top-level keys from sampled rows
554    let mut all_keys = HashSet::new();
555    for sample in &samples {
556        if let serde_json::Value::Object(map) = sample {
557            for key in map.keys() {
558                all_keys.insert(key.clone());
559            }
560        }
561    }
562
563    if all_keys.is_empty() {
564        return Ok(());
565    }
566
567    // Find the type definition for this query's return type
568    let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
569
570    if let Some(type_def) = type_def {
571        for field in &type_def.fields {
572            let field_str = field.name.as_str();
573            let json_key = to_snake_case(field_str);
574            // Skip fields that are top-level columns (not from JSONB)
575            // Convention: fields like "id", "pk_*", "fk_*" are columns, not JSON keys
576            if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
577                continue;
578            }
579            if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
580                warnings.push(DatabaseWarning::MissingJsonKey {
581                    query_name: query.name.clone(),
582                    type_name: type_def.name.as_str().to_string(),
583                    sql_source: source.to_string(),
584                    json_column: jsonb_col.to_string(),
585                    field_name: field_str.to_string(),
586                    json_key,
587                });
588            }
589        }
590    }
591
592    Ok(())
593}
594
595/// Enum dispatch for database introspectors.
596///
597/// Uses enum dispatch instead of `Box<dyn DatabaseIntrospector>` because the
598/// trait uses `async_fn_in_trait` and cannot be object-safe.
599pub enum AnyIntrospector {
600    /// PostgreSQL introspector.
601    Postgres(fraiseql_core::db::PostgresIntrospector),
602    #[cfg(feature = "mysql")]
603    /// MySQL introspector.
604    MySql(fraiseql_core::db::MySqlIntrospector),
605    #[cfg(feature = "sqlite")]
606    /// SQLite introspector.
607    Sqlite(fraiseql_core::db::SqliteIntrospector),
608    #[cfg(feature = "sqlserver")]
609    /// SQL Server introspector.
610    SqlServer(fraiseql_core::db::SqlServerIntrospector),
611}
612
613impl DatabaseIntrospector for AnyIntrospector {
614    async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
615        match self {
616            Self::Postgres(i) => i.list_fact_tables().await,
617            #[cfg(feature = "mysql")]
618            Self::MySql(i) => i.list_fact_tables().await,
619            #[cfg(feature = "sqlite")]
620            Self::Sqlite(i) => i.list_fact_tables().await,
621            #[cfg(feature = "sqlserver")]
622            Self::SqlServer(i) => i.list_fact_tables().await,
623        }
624    }
625
626    async fn get_columns(
627        &self,
628        table_name: &str,
629    ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
630        match self {
631            Self::Postgres(i) => i.get_columns(table_name).await,
632            #[cfg(feature = "mysql")]
633            Self::MySql(i) => i.get_columns(table_name).await,
634            #[cfg(feature = "sqlite")]
635            Self::Sqlite(i) => i.get_columns(table_name).await,
636            #[cfg(feature = "sqlserver")]
637            Self::SqlServer(i) => i.get_columns(table_name).await,
638        }
639    }
640
641    async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
642        match self {
643            Self::Postgres(i) => i.get_indexed_columns(table_name).await,
644            #[cfg(feature = "mysql")]
645            Self::MySql(i) => i.get_indexed_columns(table_name).await,
646            #[cfg(feature = "sqlite")]
647            Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
648            #[cfg(feature = "sqlserver")]
649            Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
650        }
651    }
652
653    fn database_type(&self) -> DatabaseType {
654        match self {
655            Self::Postgres(i) => i.database_type(),
656            #[cfg(feature = "mysql")]
657            Self::MySql(i) => i.database_type(),
658            #[cfg(feature = "sqlite")]
659            Self::Sqlite(i) => i.database_type(),
660            #[cfg(feature = "sqlserver")]
661            Self::SqlServer(i) => i.database_type(),
662        }
663    }
664
665    async fn get_sample_jsonb(
666        &self,
667        table_name: &str,
668        column_name: &str,
669    ) -> fraiseql_core::Result<Option<serde_json::Value>> {
670        match self {
671            Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
672            #[cfg(feature = "mysql")]
673            Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
674            #[cfg(feature = "sqlite")]
675            Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
676            #[cfg(feature = "sqlserver")]
677            Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
678        }
679    }
680
681    async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
682        match self {
683            Self::Postgres(i) => i.list_relations().await,
684            #[cfg(feature = "mysql")]
685            Self::MySql(i) => i.list_relations().await,
686            #[cfg(feature = "sqlite")]
687            Self::Sqlite(i) => i.list_relations().await,
688            #[cfg(feature = "sqlserver")]
689            Self::SqlServer(i) => i.list_relations().await,
690        }
691    }
692
693    async fn get_sample_json_rows(
694        &self,
695        table_name: &str,
696        column_name: &str,
697        limit: usize,
698    ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
699        match self {
700            Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
701            #[cfg(feature = "mysql")]
702            Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
703            #[cfg(feature = "sqlite")]
704            Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
705            #[cfg(feature = "sqlserver")]
706            Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
707        }
708    }
709}
710
711/// Create an introspector from a database URL.
712///
713/// Detects the database type from the URL scheme and creates the appropriate
714/// introspector with a connection pool.
715///
716/// # Errors
717///
718/// Returns error if the URL scheme is unrecognized or the connection pool
719/// cannot be created.
720#[allow(clippy::unused_async)] // Reason: callers always .await this; feature-gated branches do use await
721pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
722    if db_url.starts_with("postgres") {
723        use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
724        use tokio_postgres::NoTls;
725
726        let mut cfg = Config::new();
727        cfg.url = Some(db_url.to_string());
728        cfg.manager = Some(ManagerConfig {
729            recycling_method: RecyclingMethod::Fast,
730        });
731        cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
732
733        let pool = cfg
734            .create_pool(Some(Runtime::Tokio1), NoTls)
735            .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
736
737        Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
738    } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
739        #[cfg(feature = "mysql")]
740        {
741            use sqlx::mysql::MySqlPool;
742
743            let pool = MySqlPool::connect(db_url)
744                .await
745                .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
746
747            Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
748        }
749        #[cfg(not(feature = "mysql"))]
750        {
751            anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
752        }
753    } else if db_url.starts_with("sqlite")
754        || std::path::Path::new(db_url)
755            .extension()
756            .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
757    {
758        #[cfg(feature = "sqlite")]
759        {
760            use sqlx::sqlite::SqlitePool;
761
762            let pool = SqlitePool::connect(db_url)
763                .await
764                .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
765
766            Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
767        }
768        #[cfg(not(feature = "sqlite"))]
769        {
770            anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
771        }
772    } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
773        #[cfg(feature = "sqlserver")]
774        {
775            use bb8::Pool;
776            use bb8_tiberius::ConnectionManager;
777            use tiberius::Config;
778
779            let config = Config::from_ado_string(db_url).map_err(|e| {
780                anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
781            })?;
782            let mgr = ConnectionManager::build(config).map_err(|e| {
783                anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
784            })?;
785            let pool = Pool::builder()
786                .max_size(2)
787                .build(mgr)
788                .await
789                .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
790
791            Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
792        }
793        #[cfg(not(feature = "sqlserver"))]
794        {
795            anyhow::bail!(
796                "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
797            )
798        }
799    } else {
800        anyhow::bail!("Unrecognized database URL scheme: {db_url}")
801    }
802}
803
804/// Build a query's native-column map from its explicit (non-auto-param) arguments
805/// and its inject-param names, consulting the introspected `column_map`.
806///
807/// A name that matches a real view column is rendered as a native-column predicate
808/// (`WHERE col = $N`) at runtime; a name with no matching column falls back to the
809/// JSONB path (`data->>'name'`). inject params were previously omitted here, so an
810/// inject-scoped list query against a view that keeps e.g. `tenant_id` as a real
811/// column (and not inside `data`) rendered `data->>'tenant_id'` and matched no rows.
812///
813/// Returns the native-column map plus the explicit-arg names that did NOT resolve to
814/// a column — the caller emits a `NativeColumnFallback` warning for each. Inject-param
815/// misses are intentionally silent: a claim may legitimately live in the `data` JSONB.
816fn detect_query_native_columns<'a>(
817    direct_arg_names: &[&str],
818    inject_param_names: impl Iterator<Item = &'a str>,
819    column_map: &HashMap<String, String>,
820) -> (HashMap<String, String>, Vec<String>) {
821    let mut native: HashMap<String, String> = HashMap::new();
822    let mut arg_fallbacks: Vec<String> = Vec::new();
823
824    for arg in direct_arg_names {
825        if let Some(col_type) = column_map.get(*arg) {
826            native.insert((*arg).to_string(), col_type.clone());
827        } else {
828            arg_fallbacks.push((*arg).to_string());
829        }
830    }
831
832    for name in inject_param_names {
833        if let Some(col_type) = column_map.get(name) {
834            native.insert(name.to_string(), col_type.clone());
835        }
836    }
837
838    (native, arg_fallbacks)
839}
840
841#[cfg(test)]
842#[path = "database_validator_tests.rs"]
843mod database_validator_tests;