Skip to main content

sift_query_sqlx/
schema.rs

1use std::sync::Arc;
2
3use sift_query_core::{QueryBuilder, QueryTarget, SearchEntity, ServerScope, ValueType};
4use sqlx::{AssertSqlSafe, Column, Executor, SqlSafeStr, TypeInfo};
5use tokio::time::timeout_at;
6
7use crate::cache::RenderRequest;
8use crate::execution::io::{configure_statement_timeout, finish_statement, prepare};
9use crate::{PgSearchError, PgSiftQuery, TimeoutStage};
10
11/// What the schema contract validator checked.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SchemaValidationTarget {
14    /// Validate the entity's complete dynamic projection and query source.
15    Entity,
16}
17
18/// Successful result from [`PgSiftQuery::validate_schema`].
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct SchemaColumnReport {
21    pub alias: String,
22    pub expected_type: ValueType,
23    pub database_type: String,
24    pub nullable: Option<bool>,
25    pub compatible: bool,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SchemaValidationReport {
30    pub entity: &'static str,
31    pub target: SchemaValidationTarget,
32    pub query_shape_id: crate::QueryShapeId,
33    pub columns: Vec<SchemaColumnReport>,
34}
35
36impl PgSiftQuery {
37    /// Validates an entity's source, projection, predicates, grouping and
38    /// aliases by asking PostgreSQL to plan the generated query.
39    ///
40    /// `EXPLAIN` plans the statement without reading result rows. This makes
41    /// the method suitable for application startup and CI contract checks;
42    /// it does not inspect `information_schema` and does not mutate the
43    /// database.
44    pub async fn validate_schema<E: SearchEntity>(
45        &self,
46    ) -> Result<SchemaValidationReport, PgSearchError> {
47        let query = QueryBuilder::<E>::new(&self.policy).build()?;
48        let scope = ServerScope::<E>::unrestricted();
49        let plan = self.resolve_plan(&query, &scope, QueryTarget::MapPage, "schema_validation")?;
50        let rendered = self.render::<E>(
51            &plan,
52            "schema_validation",
53            "list",
54            None,
55            RenderRequest::List,
56        )?;
57        let prepared = prepare(rendered)?;
58        let deadline = self.deadline::<E>();
59        let sql: Arc<str> = format!("EXPLAIN (FORMAT JSON) {}", prepared.sql).into();
60        let connection = self.acquire(deadline).await?;
61        let mut connection =
62            configure_statement_timeout(connection, deadline, TimeoutStage::SchemaValidation)
63                .await?;
64        let result = timeout_at(
65            deadline,
66            sqlx::query_with(AssertSqlSafe(sql), prepared.arguments).fetch_one(&mut *connection),
67        )
68        .await;
69        let Ok(result) = result else {
70            crate::execution::io::close_hard(connection).await;
71            return Err(PgSearchError::Timeout(TimeoutStage::SchemaValidation));
72        };
73        let _ = finish_statement(connection, result, TimeoutStage::SchemaValidation).await?;
74        let connection = self.acquire(deadline).await?;
75        let mut connection =
76            configure_statement_timeout(connection, deadline, TimeoutStage::SchemaValidation)
77                .await?;
78        let described = timeout_at(
79            deadline,
80            (&mut *connection).describe(AssertSqlSafe(Arc::clone(&prepared.sql)).into_sql_str()),
81        )
82        .await;
83        let Ok(described) = described else {
84            crate::execution::io::close_hard(connection).await;
85            return Err(PgSearchError::Timeout(TimeoutStage::SchemaValidation));
86        };
87        let (_, described) =
88            finish_statement(connection, described, TimeoutStage::SchemaValidation).await?;
89        let mut columns = Vec::with_capacity(plan.projection.len());
90        let mut mismatches = Vec::new();
91        for (index, field_id) in plan.projection.iter().enumerate() {
92            let field =
93                E::META
94                    .field(*field_id)
95                    .ok_or(sift_query_core::ParamError::UnknownField(format!(
96                        "#{}",
97                        field_id.0
98                    )))?;
99            let alias = plan
100                .projection_aliases
101                .as_ref()
102                .and_then(|aliases| aliases.get(index).copied())
103                .or(field.result_alias)
104                .ok_or_else(|| {
105                    sift_query_core::ParamError::NotSelectable(field.public_name.to_owned())
106                })?;
107            let Some(column) = described.columns().get(index) else {
108                mismatches.push(format!("missing result column {alias}"));
109                columns.push(SchemaColumnReport {
110                    alias: alias.to_owned(),
111                    expected_type: field.value_type,
112                    database_type: "<missing>".to_owned(),
113                    nullable: None,
114                    compatible: false,
115                });
116                continue;
117            };
118            let database_type = column.type_info().name().to_owned();
119            let compatible = column.name() == alias
120                && postgres_type_compatible(database_type.as_str(), field.value_type);
121            if !compatible {
122                mismatches.push(format!(
123                    "{}: expected alias/type {}/ {:?}, got {}/{}",
124                    field.public_name,
125                    alias,
126                    field.value_type,
127                    column.name(),
128                    database_type
129                ));
130            }
131            columns.push(SchemaColumnReport {
132                alias: alias.to_owned(),
133                expected_type: field.value_type,
134                database_type,
135                nullable: described.nullable(index),
136                compatible,
137            });
138        }
139        if described.columns().len() != plan.projection.len() {
140            mismatches.push(format!(
141                "expected {} result columns, got {}",
142                plan.projection.len(),
143                described.columns().len()
144            ));
145        }
146        if !mismatches.is_empty() {
147            return Err(crate::SchemaValidationError {
148                entity: E::META.entity_name,
149                details: mismatches.join("; "),
150            }
151            .into());
152        }
153        Ok(SchemaValidationReport {
154            entity: E::META.entity_name,
155            target: SchemaValidationTarget::Entity,
156            query_shape_id: prepared.shape_id,
157            columns,
158        })
159    }
160}
161
162fn postgres_type_compatible(actual: &str, expected: ValueType) -> bool {
163    match expected {
164        ValueType::Bool => matches!(actual, "BOOL" | "BOOLEAN" | "bool" | "boolean"),
165        ValueType::I32 => matches!(actual, "INT4" | "INTEGER" | "int4" | "integer"),
166        ValueType::I64 => matches!(actual, "INT8" | "BIGINT" | "int8" | "bigint"),
167        ValueType::F32 => matches!(actual, "FLOAT4" | "REAL" | "float4" | "real"),
168        ValueType::F64 => matches!(
169            actual,
170            "FLOAT8" | "DOUBLE PRECISION" | "float8" | "double precision"
171        ),
172        #[cfg(feature = "decimal")]
173        ValueType::Decimal => matches!(actual, "NUMERIC" | "DECIMAL" | "numeric" | "decimal"),
174        ValueType::Text => matches!(
175            actual,
176            "TEXT" | "VARCHAR" | "BPCHAR" | "NAME" | "text" | "varchar" | "bpchar" | "name"
177        ),
178        ValueType::Bytes => matches!(actual, "BYTEA" | "bytea"),
179        #[cfg(feature = "chrono")]
180        ValueType::Date => matches!(actual, "DATE" | "date"),
181        #[cfg(feature = "chrono")]
182        ValueType::Time => matches!(
183            actual,
184            "TIME" | "TIME WITHOUT TIME ZONE" | "time" | "time without time zone"
185        ),
186        #[cfg(feature = "chrono")]
187        ValueType::DateTime => matches!(
188            actual,
189            "TIMESTAMP"
190                | "TIMESTAMP WITHOUT TIME ZONE"
191                | "timestamp"
192                | "timestamp without time zone"
193        ),
194        #[cfg(feature = "chrono")]
195        ValueType::Timestamp => matches!(
196            actual,
197            "TIMESTAMPTZ" | "TIMESTAMP WITH TIME ZONE" | "timestamptz" | "timestamp with time zone"
198        ),
199        #[cfg(feature = "uuid")]
200        ValueType::Uuid => matches!(actual, "UUID" | "uuid"),
201        #[cfg(feature = "json")]
202        ValueType::Json => matches!(actual, "JSON" | "JSONB" | "json" | "jsonb"),
203        #[cfg(feature = "postgres-types")]
204        ValueType::ArrayI64 => matches!(actual, "_INT8" | "_int8"),
205        #[cfg(feature = "postgres-types")]
206        ValueType::ArrayText => matches!(actual, "_TEXT" | "_text"),
207        #[cfg(feature = "postgres-types")]
208        ValueType::Int8Range => matches!(actual, "INT8RANGE" | "int8range"),
209        #[cfg(feature = "postgres-types")]
210        ValueType::Network => matches!(actual, "INET" | "CIDR" | "inet" | "cidr"),
211        _ => false,
212    }
213}