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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SchemaValidationTarget {
14 Entity,
16}
17
18#[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 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}