#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SchemaColumn {
pub name: &'static str,
pub sql_type: &'static str,
pub nullable: bool
}
#[derive(Debug, Clone, Copy)]
pub struct TableSchema {
pub table: &'static str,
pub schema: &'static str,
pub columns: &'static [SchemaColumn]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaDrift {
MissingTable,
MissingColumn {
column: String
},
NullabilityMismatch {
column: String,
declared_nullable: bool
},
TypeMismatch {
column: String,
declared: String,
actual: String
}
}
impl std::fmt::Display for SchemaDrift {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingTable => write!(f, "table does not exist"),
Self::MissingColumn {
column
} => write!(f, "column `{column}` is missing"),
Self::NullabilityMismatch {
column,
declared_nullable
} => write!(
f,
"column `{column}` nullability differs: entity says {}",
if *declared_nullable {
"nullable"
} else {
"NOT NULL"
}
),
Self::TypeMismatch {
column,
declared,
actual
} => write!(
f,
"column `{column}` type differs: entity says `{declared}`, database says `{actual}`"
)
}
}
}
#[derive(Debug, Clone)]
pub struct SchemaMismatch {
pub table: String,
pub drifts: Vec<SchemaDrift>
}
impl std::fmt::Display for SchemaMismatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "schema drift in `{}`:", self.table)?;
for drift in &self.drifts {
writeln!(f, " - {drift}")?;
}
Ok(())
}
}
impl std::error::Error for SchemaMismatch {}
#[derive(Debug)]
pub enum SchemaCheckError {
Query(sqlx::Error),
Mismatch(SchemaMismatch)
}
impl std::fmt::Display for SchemaCheckError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Query(e) => write!(f, "schema check query failed: {e}"),
Self::Mismatch(m) => write!(f, "{m}")
}
}
}
impl std::error::Error for SchemaCheckError {}
impl From<sqlx::Error> for SchemaCheckError {
fn from(e: sqlx::Error) -> Self {
Self::Query(e)
}
}
pub async fn assert_table(
pool: &sqlx::PgPool,
declared: &TableSchema
) -> Result<(), SchemaCheckError> {
let rows: Vec<(String, String, String, String)> = sqlx::query_as(
"SELECT column_name, data_type, is_nullable, udt_name \
FROM information_schema.columns \
WHERE table_schema = $1 AND table_name = $2"
)
.bind(declared.schema)
.bind(declared.table)
.fetch_all(pool)
.await?;
let mut drifts = Vec::new();
if rows.is_empty() {
drifts.push(SchemaDrift::MissingTable);
} else {
for col in declared.columns {
let Some((_, data_type, is_nullable, _udt)) =
rows.iter().find(|(name, ..)| name == col.name)
else {
drifts.push(SchemaDrift::MissingColumn {
column: col.name.to_string()
});
continue;
};
let db_nullable = is_nullable == "YES";
if db_nullable != col.nullable {
drifts.push(SchemaDrift::NullabilityMismatch {
column: col.name.to_string(),
declared_nullable: col.nullable
});
}
let declared_family = normalize(col.sql_type);
let actual_family = normalize(data_type);
if actual_family != "user-defined"
&& declared_family != actual_family
&& !(declared_family == "array" && actual_family == "array")
{
drifts.push(SchemaDrift::TypeMismatch {
column: col.name.to_string(),
declared: declared_family,
actual: actual_family
});
}
}
}
if drifts.is_empty() {
Ok(())
} else {
Err(SchemaCheckError::Mismatch(SchemaMismatch {
table: declared.table.to_string(),
drifts
}))
}
}
fn normalize(sql_type: &str) -> String {
let lower = sql_type.trim().to_lowercase();
let base = lower.split('(').next().unwrap_or(&lower).trim().to_string();
if base.ends_with("[]") || base == "array" {
return "array".to_string();
}
match base.as_str() {
"text" | "varchar" | "character varying" | "character" | "char" | "citext" => {
"text".to_string()
}
"timestamptz" | "timestamp with time zone" => "timestamp with time zone".to_string(),
"timestamp" | "timestamp without time zone" => "timestamp without time zone".to_string(),
"int8" | "bigint" | "bigserial" => "bigint".to_string(),
"int4" | "int" | "integer" | "serial" => "integer".to_string(),
"int2" | "smallint" => "smallint".to_string(),
"float8" | "double precision" => "double precision".to_string(),
"float4" | "real" => "real".to_string(),
"bool" | "boolean" => "boolean".to_string(),
other => other.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_folds_text_family() {
assert_eq!(normalize("TEXT"), "text");
assert_eq!(normalize("VARCHAR(255)"), "text");
assert_eq!(normalize("character varying"), "text");
assert_eq!(normalize("character"), "text");
}
#[test]
fn normalize_maps_aliases() {
assert_eq!(normalize("TIMESTAMPTZ"), "timestamp with time zone");
assert_eq!(normalize("BIGINT"), "bigint");
assert_eq!(normalize("DOUBLE PRECISION"), "double precision");
assert_eq!(normalize("BOOLEAN"), "boolean");
assert_eq!(normalize("uuid"), "uuid");
assert_eq!(normalize("JSONB"), "jsonb");
}
#[test]
fn normalize_detects_arrays() {
assert_eq!(normalize("TEXT[]"), "array");
assert_eq!(normalize("ARRAY"), "array");
}
#[test]
fn mismatch_display_lists_all_drifts() {
let m = SchemaMismatch {
table: "users".into(),
drifts: vec![
SchemaDrift::MissingColumn {
column: "email".into()
},
SchemaDrift::NullabilityMismatch {
column: "name".into(),
declared_nullable: false
},
]
};
let text = m.to_string();
assert!(text.contains("schema drift in `users`"));
assert!(text.contains("`email` is missing"));
assert!(text.contains("`name` nullability differs"));
}
}