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,
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 `sql_source` relation.
88        sql_source:  String,
89        /// The JSON column being sampled.
90        json_column: String,
91        /// The GraphQL field name.
92        field_name:  String,
93        /// The snake_case key looked up in the JSON.
94        json_key:    String,
95    },
96    /// L2: a direct query argument has no matching native column — will fall back to JSONB
97    /// extraction.
98    ///
99    /// For best performance, consider adding a native column with the same name
100    /// and an index on the `sql_source` table/view.
101    NativeColumnFallback {
102        /// Name of the query.
103        query_name: String,
104        /// The `sql_source` relation.
105        sql_source: String,
106        /// The argument name that has no matching native column.
107        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
191/// Check if a SQL data type represents a JSON column for the given database.
192pub(crate) fn is_json_type(data_type: &str, db_type: DatabaseType) -> bool {
193    let lower = data_type.to_lowercase();
194    match db_type {
195        DatabaseType::PostgreSQL => lower == "jsonb" || lower == "json",
196        DatabaseType::MySQL => lower == "json",
197        DatabaseType::SQLite => lower.contains("json"),
198        // SQL Server has no native JSON type — always attempt JSON
199        // validation for the configured jsonb_column
200        DatabaseType::SQLServer => true,
201    }
202}
203
204/// Split a potentially schema-qualified name into (optional_schema, name).
205fn 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
212/// Check if a relation exists in the relation lookup maps.
213fn 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
226/// Convert a `camelCase` or `PascalCase` field name to `snake_case`.
227///
228/// This matches the convention used by FraiseQL for JSONB key extraction.
229pub(crate) fn to_snake_case(name: &str) -> String {
230    let mut result = String::with_capacity(name.len() + 4);
231    for (i, ch) in name.chars().enumerate() {
232        if ch.is_uppercase() {
233            if i > 0 {
234                result.push('_');
235            }
236            result.push(ch.to_lowercase().next().unwrap_or(ch));
237        } else {
238            result.push(ch);
239        }
240    }
241    result
242}
243
244/// Validate a compiled schema against a live database.
245///
246/// Performs three levels of validation:
247/// - **L1**: Checks that `sql_source` relations exist
248/// - **L2**: Checks column existence and JSON column types
249/// - **L3**: Checks JSONB key existence via sampling
250///
251/// All diagnostics are warnings — the report never causes compilation to fail.
252///
253/// # Errors
254///
255/// Returns `FraiseQLError` if database introspection queries fail.
256pub async fn validate_schema_against_database(
257    schema: &CompiledSchema,
258    introspector: &impl DatabaseIntrospector,
259) -> fraiseql_core::Result<DatabaseValidationReport> {
260    // Auto-wired argument names excluded from direct-arg native column detection.
261    // Must stay in sync with AUTO_PARAM_NAMES in fraiseql-core/runtime/executor/query.rs.
262    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    // L1: Build relation lookup maps
271    let relations = introspector.list_relations().await?;
272    let (schema_qualified, unqualified) = build_relation_maps(&relations);
273
274    // Validate queries
275    for query in &schema.queries {
276        if let Some(ref source) = query.sql_source {
277            // L1: Check relation exists
278            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; // Skip L2/L3 if relation doesn't exist
284            }
285
286            // L2: Get columns for the relation.
287            // Pass the full source (possibly schema-qualified like "benchmark.tv_post") so
288            // the introspector can use the explicit schema when present.
289            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            // L2: Check jsonb_column
294            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            // L2: Check relay_cursor_column
315            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            // L3: Sample JSON keys if jsonb_column is valid JSON type
328            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, // pass full schema-qualified source for sample queries
340                        &mut warnings,
341                    )
342                    .await?;
343                }
344            }
345
346            // L2: Detect native columns for direct (non-auto-param) arguments AND
347            // inject params. Both are filtered against the view at runtime, so a name
348            // that matches a real column must use the native-column path
349            // (`WHERE col = $N`) rather than the JSONB fallback (`data->>'name'`).
350            let direct_args: Vec<&str> = query
351                .arguments
352                .iter()
353                .filter(|a| !AUTO_PARAM_NAMES.contains(&a.name.as_str()))
354                .map(|a| a.name.as_str())
355                .collect();
356
357            let (query_native, arg_fallbacks) = detect_query_native_columns(
358                &direct_args,
359                query.inject_params.keys().map(String::as_str),
360                &column_map,
361            );
362            for arg_name in arg_fallbacks {
363                warnings.push(DatabaseWarning::NativeColumnFallback {
364                    query_name: query.name.clone(),
365                    sql_source: source.clone(),
366                    arg_name,
367                });
368            }
369            if !query_native.is_empty() {
370                native_columns.insert(query.name.clone(), query_native);
371            }
372
373            // L1: Check additional_views
374            for view in &query.additional_views {
375                if !relation_exists(&schema_qualified, &unqualified, view) {
376                    warnings.push(DatabaseWarning::MissingAdditionalView {
377                        query_name: query.name.clone(),
378                        view_name:  view.clone(),
379                    });
380                }
381            }
382        }
383    }
384
385    // Validate mutations (L1 only)
386    for mutation in &schema.mutations {
387        if let Some(ref source) = mutation.sql_source {
388            if !relation_exists(&schema_qualified, &unqualified, source) {
389                warnings.push(DatabaseWarning::MissingRelation {
390                    query_name: mutation.name.clone(),
391                    sql_source: source.clone(),
392                });
393            }
394        }
395    }
396
397    Ok(DatabaseValidationReport {
398        warnings,
399        native_columns,
400    })
401}
402
403/// Build lookup maps from the list of relations.
404fn build_relation_maps(
405    relations: &[RelationInfo],
406) -> (HashMap<(String, String), RelationInfo>, HashMap<String, Vec<String>>) {
407    let mut schema_qualified = HashMap::new();
408    let mut unqualified: HashMap<String, Vec<String>> = HashMap::new();
409
410    for rel in relations {
411        schema_qualified.insert((rel.schema.clone(), rel.name.clone()), rel.clone());
412        unqualified.entry(rel.name.clone()).or_default().push(rel.schema.clone());
413    }
414
415    (schema_qualified, unqualified)
416}
417
418/// Validate JSON keys in sampled data for L3 checking.
419async fn validate_json_keys(
420    schema: &CompiledSchema,
421    query: &fraiseql_core::schema::QueryDefinition,
422    source: &str,
423    jsonb_col: &str,
424    introspector: &impl DatabaseIntrospector,
425    table_name: &str,
426    warnings: &mut Vec<DatabaseWarning>,
427) -> fraiseql_core::Result<()> {
428    let samples = introspector.get_sample_json_rows(table_name, jsonb_col, 5).await?;
429
430    if samples.is_empty() {
431        return Ok(());
432    }
433
434    // Merge all top-level keys from sampled rows
435    let mut all_keys = HashSet::new();
436    for sample in &samples {
437        if let serde_json::Value::Object(map) = sample {
438            for key in map.keys() {
439                all_keys.insert(key.clone());
440            }
441        }
442    }
443
444    if all_keys.is_empty() {
445        return Ok(());
446    }
447
448    // Find the type definition for this query's return type
449    let type_def = schema.types.iter().find(|t| t.name.as_str() == query.return_type);
450
451    if let Some(type_def) = type_def {
452        for field in &type_def.fields {
453            let field_str = field.name.as_str();
454            let json_key = to_snake_case(field_str);
455            // Skip fields that are top-level columns (not from JSONB)
456            // Convention: fields like "id", "pk_*", "fk_*" are columns, not JSON keys
457            if field_str == "id" || field_str.starts_with("pk_") || field_str.starts_with("fk_") {
458                continue;
459            }
460            if !all_keys.contains(&json_key) && !all_keys.contains(field_str) {
461                warnings.push(DatabaseWarning::MissingJsonKey {
462                    query_name: query.name.clone(),
463                    sql_source: source.to_string(),
464                    json_column: jsonb_col.to_string(),
465                    field_name: field_str.to_string(),
466                    json_key,
467                });
468            }
469        }
470    }
471
472    Ok(())
473}
474
475/// Enum dispatch for database introspectors.
476///
477/// Uses enum dispatch instead of `Box<dyn DatabaseIntrospector>` because the
478/// trait uses `async_fn_in_trait` and cannot be object-safe.
479pub enum AnyIntrospector {
480    /// PostgreSQL introspector.
481    Postgres(fraiseql_core::db::PostgresIntrospector),
482    #[cfg(feature = "mysql")]
483    /// MySQL introspector.
484    MySql(fraiseql_core::db::MySqlIntrospector),
485    #[cfg(feature = "sqlite")]
486    /// SQLite introspector.
487    Sqlite(fraiseql_core::db::SqliteIntrospector),
488    #[cfg(feature = "sqlserver")]
489    /// SQL Server introspector.
490    SqlServer(fraiseql_core::db::SqlServerIntrospector),
491}
492
493impl DatabaseIntrospector for AnyIntrospector {
494    async fn list_fact_tables(&self) -> fraiseql_core::Result<Vec<String>> {
495        match self {
496            Self::Postgres(i) => i.list_fact_tables().await,
497            #[cfg(feature = "mysql")]
498            Self::MySql(i) => i.list_fact_tables().await,
499            #[cfg(feature = "sqlite")]
500            Self::Sqlite(i) => i.list_fact_tables().await,
501            #[cfg(feature = "sqlserver")]
502            Self::SqlServer(i) => i.list_fact_tables().await,
503        }
504    }
505
506    async fn get_columns(
507        &self,
508        table_name: &str,
509    ) -> fraiseql_core::Result<Vec<(String, String, bool)>> {
510        match self {
511            Self::Postgres(i) => i.get_columns(table_name).await,
512            #[cfg(feature = "mysql")]
513            Self::MySql(i) => i.get_columns(table_name).await,
514            #[cfg(feature = "sqlite")]
515            Self::Sqlite(i) => i.get_columns(table_name).await,
516            #[cfg(feature = "sqlserver")]
517            Self::SqlServer(i) => i.get_columns(table_name).await,
518        }
519    }
520
521    async fn get_indexed_columns(&self, table_name: &str) -> fraiseql_core::Result<Vec<String>> {
522        match self {
523            Self::Postgres(i) => i.get_indexed_columns(table_name).await,
524            #[cfg(feature = "mysql")]
525            Self::MySql(i) => i.get_indexed_columns(table_name).await,
526            #[cfg(feature = "sqlite")]
527            Self::Sqlite(i) => i.get_indexed_columns(table_name).await,
528            #[cfg(feature = "sqlserver")]
529            Self::SqlServer(i) => i.get_indexed_columns(table_name).await,
530        }
531    }
532
533    fn database_type(&self) -> DatabaseType {
534        match self {
535            Self::Postgres(i) => i.database_type(),
536            #[cfg(feature = "mysql")]
537            Self::MySql(i) => i.database_type(),
538            #[cfg(feature = "sqlite")]
539            Self::Sqlite(i) => i.database_type(),
540            #[cfg(feature = "sqlserver")]
541            Self::SqlServer(i) => i.database_type(),
542        }
543    }
544
545    async fn get_sample_jsonb(
546        &self,
547        table_name: &str,
548        column_name: &str,
549    ) -> fraiseql_core::Result<Option<serde_json::Value>> {
550        match self {
551            Self::Postgres(i) => i.get_sample_jsonb(table_name, column_name).await,
552            #[cfg(feature = "mysql")]
553            Self::MySql(i) => i.get_sample_jsonb(table_name, column_name).await,
554            #[cfg(feature = "sqlite")]
555            Self::Sqlite(i) => i.get_sample_jsonb(table_name, column_name).await,
556            #[cfg(feature = "sqlserver")]
557            Self::SqlServer(i) => i.get_sample_jsonb(table_name, column_name).await,
558        }
559    }
560
561    async fn list_relations(&self) -> fraiseql_core::Result<Vec<fraiseql_core::db::RelationInfo>> {
562        match self {
563            Self::Postgres(i) => i.list_relations().await,
564            #[cfg(feature = "mysql")]
565            Self::MySql(i) => i.list_relations().await,
566            #[cfg(feature = "sqlite")]
567            Self::Sqlite(i) => i.list_relations().await,
568            #[cfg(feature = "sqlserver")]
569            Self::SqlServer(i) => i.list_relations().await,
570        }
571    }
572
573    async fn get_sample_json_rows(
574        &self,
575        table_name: &str,
576        column_name: &str,
577        limit: usize,
578    ) -> fraiseql_core::Result<Vec<serde_json::Value>> {
579        match self {
580            Self::Postgres(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
581            #[cfg(feature = "mysql")]
582            Self::MySql(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
583            #[cfg(feature = "sqlite")]
584            Self::Sqlite(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
585            #[cfg(feature = "sqlserver")]
586            Self::SqlServer(i) => i.get_sample_json_rows(table_name, column_name, limit).await,
587        }
588    }
589}
590
591/// Create an introspector from a database URL.
592///
593/// Detects the database type from the URL scheme and creates the appropriate
594/// introspector with a connection pool.
595///
596/// # Errors
597///
598/// Returns error if the URL scheme is unrecognized or the connection pool
599/// cannot be created.
600#[allow(clippy::unused_async)] // Reason: callers always .await this; feature-gated branches do use await
601pub async fn create_introspector(db_url: &str) -> anyhow::Result<AnyIntrospector> {
602    if db_url.starts_with("postgres") {
603        use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
604        use tokio_postgres::NoTls;
605
606        let mut cfg = Config::new();
607        cfg.url = Some(db_url.to_string());
608        cfg.manager = Some(ManagerConfig {
609            recycling_method: RecyclingMethod::Fast,
610        });
611        cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
612
613        let pool = cfg
614            .create_pool(Some(Runtime::Tokio1), NoTls)
615            .map_err(|e| anyhow::anyhow!("Failed to create PostgreSQL pool: {e}"))?;
616
617        Ok(AnyIntrospector::Postgres(fraiseql_core::db::PostgresIntrospector::new(pool)))
618    } else if db_url.starts_with("mysql") || db_url.starts_with("mariadb") {
619        #[cfg(feature = "mysql")]
620        {
621            use sqlx::mysql::MySqlPool;
622
623            let pool = MySqlPool::connect(db_url)
624                .await
625                .map_err(|e| anyhow::anyhow!("Failed to create MySQL pool: {e}"))?;
626
627            Ok(AnyIntrospector::MySql(fraiseql_core::db::MySqlIntrospector::new(pool)))
628        }
629        #[cfg(not(feature = "mysql"))]
630        {
631            anyhow::bail!("MySQL support not compiled in. Rebuild with `--features mysql`.")
632        }
633    } else if db_url.starts_with("sqlite")
634        || std::path::Path::new(db_url)
635            .extension()
636            .is_some_and(|ext| ext.eq_ignore_ascii_case("db") || ext.eq_ignore_ascii_case("sqlite"))
637    {
638        #[cfg(feature = "sqlite")]
639        {
640            use sqlx::sqlite::SqlitePool;
641
642            let pool = SqlitePool::connect(db_url)
643                .await
644                .map_err(|e| anyhow::anyhow!("Failed to create SQLite pool: {e}"))?;
645
646            Ok(AnyIntrospector::Sqlite(fraiseql_core::db::SqliteIntrospector::new(pool)))
647        }
648        #[cfg(not(feature = "sqlite"))]
649        {
650            anyhow::bail!("SQLite support not compiled in. Rebuild with `--features sqlite`.")
651        }
652    } else if db_url.starts_with("mssql") || db_url.starts_with("server=") {
653        #[cfg(feature = "sqlserver")]
654        {
655            use bb8::Pool;
656            use bb8_tiberius::ConnectionManager;
657            use tiberius::Config;
658
659            let config = Config::from_ado_string(db_url).map_err(|e| {
660                anyhow::anyhow!("Failed to parse SQL Server connection string: {e}")
661            })?;
662            let mgr = ConnectionManager::build(config).map_err(|e| {
663                anyhow::anyhow!("Failed to build SQL Server connection manager: {e}")
664            })?;
665            let pool = Pool::builder()
666                .max_size(2)
667                .build(mgr)
668                .await
669                .map_err(|e| anyhow::anyhow!("Failed to create SQL Server pool: {e}"))?;
670
671            Ok(AnyIntrospector::SqlServer(fraiseql_core::db::SqlServerIntrospector::new(pool)))
672        }
673        #[cfg(not(feature = "sqlserver"))]
674        {
675            anyhow::bail!(
676                "SQL Server support not compiled in. Rebuild with `--features sqlserver`."
677            )
678        }
679    } else {
680        anyhow::bail!("Unrecognized database URL scheme: {db_url}")
681    }
682}
683
684/// Build a query's native-column map from its explicit (non-auto-param) arguments
685/// and its inject-param names, consulting the introspected `column_map`.
686///
687/// A name that matches a real view column is rendered as a native-column predicate
688/// (`WHERE col = $N`) at runtime; a name with no matching column falls back to the
689/// JSONB path (`data->>'name'`). inject params were previously omitted here, so an
690/// inject-scoped list query against a view that keeps e.g. `tenant_id` as a real
691/// column (and not inside `data`) rendered `data->>'tenant_id'` and matched no rows.
692///
693/// Returns the native-column map plus the explicit-arg names that did NOT resolve to
694/// a column — the caller emits a `NativeColumnFallback` warning for each. Inject-param
695/// misses are intentionally silent: a claim may legitimately live in the `data` JSONB.
696fn detect_query_native_columns<'a>(
697    direct_arg_names: &[&str],
698    inject_param_names: impl Iterator<Item = &'a str>,
699    column_map: &HashMap<String, String>,
700) -> (HashMap<String, String>, Vec<String>) {
701    let mut native: HashMap<String, String> = HashMap::new();
702    let mut arg_fallbacks: Vec<String> = Vec::new();
703
704    for arg in direct_arg_names {
705        if let Some(col_type) = column_map.get(*arg) {
706            native.insert((*arg).to_string(), col_type.clone());
707        } else {
708            arg_fallbacks.push((*arg).to_string());
709        }
710    }
711
712    for name in inject_param_names {
713        if let Some(col_type) = column_map.get(name) {
714            native.insert(name.to_string(), col_type.clone());
715        }
716    }
717
718    (native, arg_fallbacks)
719}
720
721#[cfg(test)]
722#[path = "database_validator_tests.rs"]
723mod database_validator_tests;