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; the date/time
25//! families fold their aliases to the spelled-out names
26//! `information_schema.data_type` reports — `TIMESTAMPTZ` ↔ `timestamp
27//! with time zone`, `TIME` ↔ `time without time zone`, `TIMETZ` ↔ `time
28//! with time zone`; parametrised types drop their arguments; arrays
29//! compare as arrays). Columns whose database type is `USER-DEFINED`
30//! (Postgres enums, domains) skip the type check — the macro cannot know
31//! the enum's SQL name unless `#[column(pg_enum = ...)]` is used, and
32//! presence + nullability still guard those columns.
33
34/// Declared shape of one entity column.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct SchemaColumn {
37    /// Column name.
38    pub name: &'static str,
39
40    /// Declared SQL type as generated for DDL (e.g. `TEXT`, `UUID`,
41    /// `TIMESTAMPTZ`, `TEXT[]`).
42    pub sql_type: &'static str,
43
44    /// Whether the column accepts NULL.
45    pub nullable: bool
46}
47
48/// Declared shape of an entity's table.
49#[derive(Debug, Clone, Copy)]
50pub struct TableSchema {
51    /// Table name (unqualified).
52    pub table: &'static str,
53
54    /// Database schema the table lives in.
55    pub schema: &'static str,
56
57    /// Declared columns.
58    pub columns: &'static [SchemaColumn]
59}
60
61/// One detected divergence between the entity and the live table.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum SchemaDrift {
64    /// The table does not exist at all.
65    MissingTable,
66
67    /// A declared column is absent from the table.
68    MissingColumn {
69        /// Declared column name.
70        column: String
71    },
72
73    /// Column exists but NULL-ability differs.
74    NullabilityMismatch {
75        /// Column name.
76        column:            String,
77        /// Whether the entity declares the column nullable.
78        declared_nullable: bool
79    },
80
81    /// Column exists but its type family differs.
82    TypeMismatch {
83        /// Column name.
84        column:   String,
85        /// Normalised declared type.
86        declared: String,
87        /// Type reported by `information_schema`.
88        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/// All divergences found for one table.
124#[derive(Debug, Clone)]
125pub struct SchemaMismatch {
126    /// Table the drift belongs to.
127    pub table: String,
128
129    /// Every detected divergence.
130    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/// Errors from the assertion itself (query failure vs. actual drift).
146#[derive(Debug)]
147pub enum SchemaCheckError {
148    /// The `information_schema` query failed.
149    Query(sqlx::Error),
150
151    /// The table diverges from the entity declaration.
152    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
172/// Compare a declared table shape against the live database.
173///
174/// Reports every divergence at once instead of failing on the first.
175///
176/// # Errors
177///
178/// [`SchemaCheckError::Query`] if `information_schema` cannot be read;
179/// [`SchemaCheckError::Mismatch`] listing all drifts otherwise.
180pub 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
241/// Normalise a SQL type to a comparable family name.
242///
243/// Drops parameters (`VARCHAR(255)` → text family), folds the text
244/// family together and maps common aliases to the names
245/// `information_schema.data_type` uses.
246fn 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        // A `TIME` column declared from a NaiveTime field must match the
297        // spelled-out name `information_schema.data_type` reports, exactly
298        // as the timestamp family already does.
299        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}