diff --git a/crates/entity-core/src/lib.rs b/crates/entity-core/src/lib.rs index 4ad6a72..50eeebe 100644 --- a/crates/entity-core/src/lib.rs +++ b/crates/entity-core/src/lib.rs @@ -35,6 +35,8 @@ pub mod outbox; pub mod policy; pub mod prelude; +#[cfg(feature = "postgres")] +pub mod schema; #[cfg(feature = "streams")] pub mod stream; pub mod transaction; diff --git a/crates/entity-core/src/schema.rs b/crates/entity-core/src/schema.rs new file mode 100644 index 0000000..fc99a6c --- /dev/null +++ b/crates/entity-core/src/schema.rs @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Runtime schema assertion for derived entities. +//! +//! Generated repositories build their SQL from entity metadata, so a +//! drifted table (renamed column, missed migration, nullability change) +//! surfaces as a runtime decode error instead of a compile error. This +//! module restores an equivalent guarantee with one integration test: +//! compare every entity's declared columns against +//! `information_schema.columns` and report all mismatches at once. +//! +//! ```rust,ignore +//! #[sqlx::test] +//! async fn schema_matches(pool: PgPool) { +//! User::assert_schema(&pool).await.expect("users drifted"); +//! Parcel::assert_schema(&pool).await.expect("parcels drifted"); +//! } +//! ``` +//! +//! # Type comparison +//! +//! Declared SQL types are normalised before comparison (`TEXT` ↔ +//! `character varying` ↔ `character` are one text family; parametrised +//! types drop their arguments; arrays compare as arrays). Columns whose +//! database type is `USER-DEFINED` (Postgres enums, domains) skip the +//! type check — the macro cannot know the enum's SQL name unless +//! `#[column(pg_enum = ...)]` is used, and presence + nullability still +//! guard those columns. + +/// Declared shape of one entity column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchemaColumn { + /// Column name. + pub name: &'static str, + + /// Declared SQL type as generated for DDL (e.g. `TEXT`, `UUID`, + /// `TIMESTAMPTZ`, `TEXT[]`). + pub sql_type: &'static str, + + /// Whether the column accepts NULL. + pub nullable: bool +} + +/// Declared shape of an entity's table. +#[derive(Debug, Clone, Copy)] +pub struct TableSchema { + /// Table name (unqualified). + pub table: &'static str, + + /// Database schema the table lives in. + pub schema: &'static str, + + /// Declared columns. + pub columns: &'static [SchemaColumn] +} + +/// One detected divergence between the entity and the live table. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SchemaDrift { + /// The table does not exist at all. + MissingTable, + + /// A declared column is absent from the table. + MissingColumn { + /// Declared column name. + column: String + }, + + /// Column exists but NULL-ability differs. + NullabilityMismatch { + /// Column name. + column: String, + /// Whether the entity declares the column nullable. + declared_nullable: bool + }, + + /// Column exists but its type family differs. + TypeMismatch { + /// Column name. + column: String, + /// Normalised declared type. + declared: String, + /// Type reported by `information_schema`. + 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}`" + ) + } + } +} + +/// All divergences found for one table. +#[derive(Debug, Clone)] +pub struct SchemaMismatch { + /// Table the drift belongs to. + pub table: String, + + /// Every detected divergence. + pub drifts: Vec +} + +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 {} + +/// Errors from the assertion itself (query failure vs. actual drift). +#[derive(Debug)] +pub enum SchemaCheckError { + /// The `information_schema` query failed. + Query(sqlx::Error), + + /// The table diverges from the entity declaration. + 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 for SchemaCheckError { + fn from(e: sqlx::Error) -> Self { + Self::Query(e) + } +} + +/// Compare a declared table shape against the live database. +/// +/// Reports every divergence at once instead of failing on the first. +/// +/// # Errors +/// +/// [`SchemaCheckError::Query`] if `information_schema` cannot be read; +/// [`SchemaCheckError::Mismatch`] listing all drifts otherwise. +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 + })) + } +} + +/// Normalise a SQL type to a comparable family name. +/// +/// Drops parameters (`VARCHAR(255)` → text family), folds the text +/// family together and maps common aliases to the names +/// `information_schema.data_type` uses. +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")); + } +} diff --git a/crates/entity-derive-impl/src/entity.rs b/crates/entity-derive-impl/src/entity.rs index cb759cf..ee99bf2 100644 --- a/crates/entity-derive-impl/src/entity.rs +++ b/crates/entity-derive-impl/src/entity.rs @@ -71,7 +71,6 @@ mod events; mod hooks; mod insertable; mod mappers; -#[cfg(feature = "migrations")] mod migrations; #[cfg(feature = "aggregate_root")] pub mod new_entity; @@ -82,6 +81,7 @@ mod projection; mod query; mod repository; mod row; +mod schema_check; mod sql; mod streams; #[cfg(feature = "transactions")] @@ -116,6 +116,7 @@ fn generate(entity: EntityDef) -> TokenStream { let mappers = mappers::generate(&entity); let sql = sql::generate(&entity); let view = view::generate(&entity); + let schema_check = schema_check::generate(&entity); // Opt-out generators. Each entity-attribute group is gated behind a // Cargo feature so users can shrink their build by switching them @@ -176,6 +177,7 @@ fn generate(entity: EntityDef) -> TokenStream { #repository #row #view + #schema_check #insertable #mappers #new_entity diff --git a/crates/entity-derive-impl/src/entity/schema_check.rs b/crates/entity-derive-impl/src/entity/schema_check.rs new file mode 100644 index 0000000..86409e0 --- /dev/null +++ b/crates/entity-derive-impl/src/entity/schema_check.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2025-2026 RAprogramm +// SPDX-License-Identifier: MIT + +//! Schema-assertion codegen: `{Entity}::SCHEMA` and +//! `{Entity}::assert_schema`. +//! +//! Generated repositories build SQL from entity metadata, so table +//! drift surfaces as runtime decode errors. The generated constant +//! describes the declared columns (name, DDL type, nullability) and +//! `assert_schema(pool)` compares it against +//! `information_schema.columns` via `entity_core::schema::assert_table` +//! — one integration test per entity restores a compile-time-like +//! guarantee: +//! +//! ```rust,ignore +//! User::assert_schema(&pool).await.expect("users table drifted"); +//! ``` + +use proc_macro2::TokenStream; +use quote::quote; + +use super::{ + migrations::types::{PostgresTypeMapper, TypeMapper}, + parse::{DatabaseDialect, EntityDef, SqlLevel} +}; +use crate::utils::marker; + +/// Generate the schema constant and assertion method. +/// +/// Returns empty tokens for `sql = "none"` entities or non-Postgres +/// dialects. +pub fn generate(entity: &EntityDef) -> TokenStream { + if entity.sql == SqlLevel::None || entity.dialect != DatabaseDialect::Postgres { + return TokenStream::new(); + } + + let entity_name = entity.name(); + let table = entity.table_name(); + let schema = if entity.schema.is_empty() { + "public".to_string() + } else { + entity.schema.clone() + }; + let marker = marker::generated(); + let mapper = PostgresTypeMapper; + + let columns: Vec = entity + .column_fields() + .into_iter() + .map(|f| { + let name = f.name_str(); + let sql_type = mapper.map_type(f.ty(), f.column()); + let type_str = sql_type.to_sql_string(); + let nullable = sql_type.nullable; + quote! { + ::entity_core::schema::SchemaColumn { + name: #name, + sql_type: #type_str, + nullable: #nullable + } + } + }) + .collect(); + + let schema_doc = + format!("Declared column shape of `{table}`, generated from the entity definition."); + + quote! { + #marker + impl #entity_name { + #[doc = #schema_doc] + pub const SCHEMA: ::entity_core::schema::TableSchema = + ::entity_core::schema::TableSchema { + table: #table, + schema: #schema, + columns: &[#(#columns),*] + }; + + /// Assert the live table matches the entity declaration. + /// + /// Compares every declared column against + /// `information_schema.columns` (presence, nullability, type + /// family) and reports all drifts at once. Run it once per + /// entity in an integration test. + pub async fn assert_schema( + pool: &sqlx::PgPool + ) -> Result<(), ::entity_core::schema::SchemaCheckError> { + ::entity_core::schema::assert_table(pool, &Self::SCHEMA).await + } + } + } +} + +#[cfg(test)] +mod tests { + use quote::quote; + use syn::DeriveInput; + + use super::*; + + fn parse_entity(tokens: proc_macro2::TokenStream) -> EntityDef { + let input: DeriveInput = syn::parse2(tokens).expect("test entity must parse"); + EntityDef::from_derive_input(&input).expect("test entity must be valid") + } + + #[test] + fn emits_schema_constant_and_assertion() { + let entity = parse_entity(quote! { + #[entity(table = "users")] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(create, update, response)] + pub username: Option, + #[auto] + pub created_at: chrono::DateTime, + } + }); + let code = generate(&entity).to_string(); + assert!(code.contains("pub const SCHEMA")); + assert!(code.contains("pub async fn assert_schema")); + assert!(code.contains("name : \"username\" , sql_type : \"TEXT\" , nullable : true")); + assert!(code.contains("name : \"id\" , sql_type : \"UUID\" , nullable : false")); + } + + #[test] + fn sql_none_emits_nothing() { + let entity = parse_entity(quote! { + #[entity(table = "dto_only", sql = "none")] + pub struct DtoOnly { + #[id] + pub id: uuid::Uuid, + } + }); + assert!(generate(&entity).is_empty()); + } +} diff --git a/wiki/Best-Practices-en.md b/wiki/Best-Practices-en.md index 515cc54..6a4d98b 100644 --- a/wiki/Best-Practices-en.md +++ b/wiki/Best-Practices-en.md @@ -259,6 +259,21 @@ fn test_update_request_is_partial() { } ``` +## Schema Assertion + +Generated SQL is correct by construction, but the *table* can drift (missed migration, manual ALTER, renamed column) — that surfaces as runtime decode errors. Every Postgres entity gets `{Entity}::SCHEMA` (declared columns: name, DDL type, nullability) and `{Entity}::assert_schema(pool)`, which compares the declaration against `information_schema.columns` and reports **all** drifts at once. Run one integration test per entity: + +```rust +#[tokio::test] +async fn entities_match_database_schema() { + let pool = test_pool().await; + User::assert_schema(&pool).await.expect("users drifted"); + Order::assert_schema(&pool).await.expect("orders drifted"); +} +``` + +Type comparison is family-based (`TEXT`/`VARCHAR`/`CHAR` fold together, arrays compare as arrays); Postgres enums (`USER-DEFINED`) skip the type check but keep presence + nullability guarantees. + ## Project Organization ### Recommended Structure