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