Skip to main content

entity_core/
schema.rs

1// SPDX-FileCopyrightText: 2025-2026 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Runtime schema assertion for derived entities.
5//!
6//! Generated repositories build their SQL from entity metadata, so a
7//! drifted table (renamed column, missed migration, nullability change)
8//! surfaces as a runtime decode error instead of a compile error. This
9//! module restores an equivalent guarantee with one integration test:
10//! compare every entity's declared columns against
11//! `information_schema.columns` and report all mismatches at once.
12//!
13//! ```rust,ignore
14//! #[sqlx::test]
15//! async fn schema_matches(pool: PgPool) {
16//!     User::assert_schema(&pool).await.expect("users drifted");
17//!     Parcel::assert_schema(&pool).await.expect("parcels drifted");
18//! }
19//! ```
20//!
21//! # Type comparison
22//!
23//! Declared SQL types are normalised before comparison (`TEXT` ↔
24//! `character varying` ↔ `character` are one text family; parametrised
25//! types drop their arguments; arrays compare as arrays). Columns whose
26//! database type is `USER-DEFINED` (Postgres enums, domains) skip the
27//! type check — the macro cannot know the enum's SQL name unless
28//! `#[column(pg_enum = ...)]` is used, and presence + nullability still
29//! guard those columns.
30
31/// Declared shape of one entity column.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct SchemaColumn {
34    /// Column name.
35    pub name: &'static str,
36
37    /// Declared SQL type as generated for DDL (e.g. `TEXT`, `UUID`,
38    /// `TIMESTAMPTZ`, `TEXT[]`).
39    pub sql_type: &'static str,
40
41    /// Whether the column accepts NULL.
42    pub nullable: bool
43}
44
45/// Declared shape of an entity's table.
46#[derive(Debug, Clone, Copy)]
47pub struct TableSchema {
48    /// Table name (unqualified).
49    pub table: &'static str,
50
51    /// Database schema the table lives in.
52    pub schema: &'static str,
53
54    /// Declared columns.
55    pub columns: &'static [SchemaColumn]
56}
57
58/// One detected divergence between the entity and the live table.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum SchemaDrift {
61    /// The table does not exist at all.
62    MissingTable,
63
64    /// A declared column is absent from the table.
65    MissingColumn {
66        /// Declared column name.
67        column: String
68    },
69
70    /// Column exists but NULL-ability differs.
71    NullabilityMismatch {
72        /// Column name.
73        column:            String,
74        /// Whether the entity declares the column nullable.
75        declared_nullable: bool
76    },
77
78    /// Column exists but its type family differs.
79    TypeMismatch {
80        /// Column name.
81        column:   String,
82        /// Normalised declared type.
83        declared: String,
84        /// Type reported by `information_schema`.
85        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/// All divergences found for one table.
121#[derive(Debug, Clone)]
122pub struct SchemaMismatch {
123    /// Table the drift belongs to.
124    pub table: String,
125
126    /// Every detected divergence.
127    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/// Errors from the assertion itself (query failure vs. actual drift).
143#[derive(Debug)]
144pub enum SchemaCheckError {
145    /// The `information_schema` query failed.
146    Query(sqlx::Error),
147
148    /// The table diverges from the entity declaration.
149    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
169/// Compare a declared table shape against the live database.
170///
171/// Reports every divergence at once instead of failing on the first.
172///
173/// # Errors
174///
175/// [`SchemaCheckError::Query`] if `information_schema` cannot be read;
176/// [`SchemaCheckError::Mismatch`] listing all drifts otherwise.
177pub 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
238/// Normalise a SQL type to a comparable family name.
239///
240/// Drops parameters (`VARCHAR(255)` → text family), folds the text
241/// family together and maps common aliases to the names
242/// `information_schema.data_type` uses.
243fn 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}