1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SchemaColumn {
34 pub name: &'static str,
36
37 pub sql_type: &'static str,
40
41 pub nullable: bool
43}
44
45#[derive(Debug, Clone, Copy)]
47pub struct TableSchema {
48 pub table: &'static str,
50
51 pub schema: &'static str,
53
54 pub columns: &'static [SchemaColumn]
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum SchemaDrift {
61 MissingTable,
63
64 MissingColumn {
66 column: String
68 },
69
70 NullabilityMismatch {
72 column: String,
74 declared_nullable: bool
76 },
77
78 TypeMismatch {
80 column: String,
82 declared: String,
84 actual: String
86 }
87}
88
89impl std::fmt::Display for SchemaDrift {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 match self {
92 Self::MissingTable => write!(f, "table does not exist"),
93 Self::MissingColumn {
94 column
95 } => write!(f, "column `{column}` is missing"),
96 Self::NullabilityMismatch {
97 column,
98 declared_nullable
99 } => write!(
100 f,
101 "column `{column}` nullability differs: entity says {}",
102 if *declared_nullable {
103 "nullable"
104 } else {
105 "NOT NULL"
106 }
107 ),
108 Self::TypeMismatch {
109 column,
110 declared,
111 actual
112 } => write!(
113 f,
114 "column `{column}` type differs: entity says `{declared}`, database says `{actual}`"
115 )
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
122pub struct SchemaMismatch {
123 pub table: String,
125
126 pub drifts: Vec<SchemaDrift>
128}
129
130impl std::fmt::Display for SchemaMismatch {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 writeln!(f, "schema drift in `{}`:", self.table)?;
133 for drift in &self.drifts {
134 writeln!(f, " - {drift}")?;
135 }
136 Ok(())
137 }
138}
139
140impl std::error::Error for SchemaMismatch {}
141
142#[derive(Debug)]
144pub enum SchemaCheckError {
145 Query(sqlx::Error),
147
148 Mismatch(SchemaMismatch)
150}
151
152impl std::fmt::Display for SchemaCheckError {
153 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 match self {
155 Self::Query(e) => write!(f, "schema check query failed: {e}"),
156 Self::Mismatch(m) => write!(f, "{m}")
157 }
158 }
159}
160
161impl std::error::Error for SchemaCheckError {}
162
163impl From<sqlx::Error> for SchemaCheckError {
164 fn from(e: sqlx::Error) -> Self {
165 Self::Query(e)
166 }
167}
168
169pub async fn assert_table(
178 pool: &sqlx::PgPool,
179 declared: &TableSchema
180) -> Result<(), SchemaCheckError> {
181 let rows: Vec<(String, String, String, String)> = sqlx::query_as(
182 "SELECT column_name, data_type, is_nullable, udt_name \
183 FROM information_schema.columns \
184 WHERE table_schema = $1 AND table_name = $2"
185 )
186 .bind(declared.schema)
187 .bind(declared.table)
188 .fetch_all(pool)
189 .await?;
190
191 let mut drifts = Vec::new();
192 if rows.is_empty() {
193 drifts.push(SchemaDrift::MissingTable);
194 } else {
195 for col in declared.columns {
196 let Some((_, data_type, is_nullable, _udt)) =
197 rows.iter().find(|(name, ..)| name == col.name)
198 else {
199 drifts.push(SchemaDrift::MissingColumn {
200 column: col.name.to_string()
201 });
202 continue;
203 };
204
205 let db_nullable = is_nullable == "YES";
206 if db_nullable != col.nullable {
207 drifts.push(SchemaDrift::NullabilityMismatch {
208 column: col.name.to_string(),
209 declared_nullable: col.nullable
210 });
211 }
212
213 let declared_family = normalize(col.sql_type);
214 let actual_family = normalize(data_type);
215 if actual_family != "user-defined"
216 && declared_family != actual_family
217 && !(declared_family == "array" && actual_family == "array")
218 {
219 drifts.push(SchemaDrift::TypeMismatch {
220 column: col.name.to_string(),
221 declared: declared_family,
222 actual: actual_family
223 });
224 }
225 }
226 }
227
228 if drifts.is_empty() {
229 Ok(())
230 } else {
231 Err(SchemaCheckError::Mismatch(SchemaMismatch {
232 table: declared.table.to_string(),
233 drifts
234 }))
235 }
236}
237
238fn normalize(sql_type: &str) -> String {
244 let lower = sql_type.trim().to_lowercase();
245 let base = lower.split('(').next().unwrap_or(&lower).trim().to_string();
246
247 if base.ends_with("[]") || base == "array" {
248 return "array".to_string();
249 }
250
251 match base.as_str() {
252 "text" | "varchar" | "character varying" | "character" | "char" | "citext" => {
253 "text".to_string()
254 }
255 "timestamptz" | "timestamp with time zone" => "timestamp with time zone".to_string(),
256 "timestamp" | "timestamp without time zone" => "timestamp without time zone".to_string(),
257 "int8" | "bigint" | "bigserial" => "bigint".to_string(),
258 "int4" | "int" | "integer" | "serial" => "integer".to_string(),
259 "int2" | "smallint" => "smallint".to_string(),
260 "float8" | "double precision" => "double precision".to_string(),
261 "float4" | "real" => "real".to_string(),
262 "bool" | "boolean" => "boolean".to_string(),
263 other => other.to_string()
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn normalize_folds_text_family() {
273 assert_eq!(normalize("TEXT"), "text");
274 assert_eq!(normalize("VARCHAR(255)"), "text");
275 assert_eq!(normalize("character varying"), "text");
276 assert_eq!(normalize("character"), "text");
277 }
278
279 #[test]
280 fn normalize_maps_aliases() {
281 assert_eq!(normalize("TIMESTAMPTZ"), "timestamp with time zone");
282 assert_eq!(normalize("BIGINT"), "bigint");
283 assert_eq!(normalize("DOUBLE PRECISION"), "double precision");
284 assert_eq!(normalize("BOOLEAN"), "boolean");
285 assert_eq!(normalize("uuid"), "uuid");
286 assert_eq!(normalize("JSONB"), "jsonb");
287 }
288
289 #[test]
290 fn normalize_detects_arrays() {
291 assert_eq!(normalize("TEXT[]"), "array");
292 assert_eq!(normalize("ARRAY"), "array");
293 }
294
295 #[test]
296 fn mismatch_display_lists_all_drifts() {
297 let m = SchemaMismatch {
298 table: "users".into(),
299 drifts: vec![
300 SchemaDrift::MissingColumn {
301 column: "email".into()
302 },
303 SchemaDrift::NullabilityMismatch {
304 column: "name".into(),
305 declared_nullable: false
306 },
307 ]
308 };
309 let text = m.to_string();
310 assert!(text.contains("schema drift in `users`"));
311 assert!(text.contains("`email` is missing"));
312 assert!(text.contains("`name` nullability differs"));
313 }
314}