rustio_core/contract_validator.rs
1//! Schema Contract runtime validator (Phase 14, commit 3).
2//!
3//! Compares a Rust [`ModelSchema`](crate::contract::ModelSchema)
4//! contract against the actual PostgreSQL schema, surfacing every
5//! drift point as a structured [`SchemaIssue`].
6//!
7//! # Architecture
8//!
9//! ```text
10//! Rust ModelSchema (compile-time contract) Postgres information_schema (live)
11//! │ │
12//! └────────────────► validator ◄───────────────────┘
13//! │
14//! ▼
15//! SchemaReport
16//! { errors, warnings, status }
17//! ```
18//!
19//! The validator is **read-only**: it issues two `SELECT` statements
20//! against `information_schema` per model and never mutates the
21//! database. Safe to run on a production replica.
22//!
23//! # What it detects (per the spec)
24//!
25//! | Case | Severity |
26//! |--------------------|----------|
27//! | Missing table | ERROR |
28//! | Missing column | ERROR |
29//! | Type mismatch | ERROR |
30//! | Nullability drift | ERROR |
31//! | Wrong primary key | ERROR |
32//! | Extra DB column | WARNING |
33//!
34//! # What it does NOT do (yet)
35//!
36//! - No human-readable rendering (commit 4 wires this through
37//! `rustio doctor --check-schema --json`).
38//! - No CHECK-constraint introspection, no column DEFAULT
39//! comparison, no FK validation. These can be added with new
40//! `IssueKind` variants without breaking the report shape.
41//! - No caching. Each call re-queries `information_schema`.
42//!
43//! # Phase scope
44//!
45//! Commit 3 ships only the validator + types + PG-gated tests.
46//! Nothing in `admin/`, `search/`, `migrations`, or `cli/`
47//! references this module yet.
48
49use std::collections::{HashMap, HashSet};
50
51use crate::contract::{HasSchema, ModelSchema};
52use crate::orm::Db;
53
54// ---------------------------------------------------------------------------
55// Public types
56// ---------------------------------------------------------------------------
57
58/// Overall outcome of one model's validation pass. Computed from
59/// the error/warning counts in [`SchemaReport`] and stored
60/// explicitly so consumers don't have to recompute.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ReportStatus {
63 /// Every Rust column resolves correctly against the DB. No
64 /// errors, no warnings.
65 Ok,
66 /// No errors, but at least one warning (e.g. an extra DB column
67 /// not declared in the Rust contract). The app boots fine; the
68 /// warning is informational.
69 Warning,
70 /// One or more errors. The app may still boot but at least one
71 /// query against this model will fail at runtime — fix before
72 /// shipping.
73 Error,
74}
75
76/// Discrete failure mode for a single drift point.
77///
78/// `#[non_exhaustive]` so commit 4+ can add new kinds (CHECK
79/// constraint mismatch, FK target missing, etc.) without breaking
80/// downstream pattern matches.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum IssueKind {
84 /// The table named by the Rust contract does not exist in
85 /// `information_schema.columns`.
86 MissingTable,
87 /// A Rust column has no corresponding DB column.
88 MissingColumn,
89 /// Rust column's `RustType` is not compatible with the DB
90 /// column's `data_type` / `udt_name` (per
91 /// [`RustType::is_compatible_with`](crate::contract::RustType::is_compatible_with)).
92 TypeMismatch,
93 /// Rust says nullable, DB says NOT NULL — or vice versa.
94 NullabilityMismatch,
95 /// The DB's primary key column(s) don't match the contract's
96 /// declared `primary_key`.
97 WrongPrimaryKey,
98 /// A DB column exists that the Rust contract doesn't declare.
99 /// Emitted as a warning — could be a deliberate audit column,
100 /// could be drift.
101 ExtraDbColumn,
102 /// Introspection query failed (network, permissions, malformed
103 /// schema). Reported as an error so the operator knows the
104 /// validator couldn't actually verify anything.
105 QueryFailed,
106}
107
108/// One drift point — column-scoped or table-scoped (`column = None`).
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct SchemaIssue {
111 /// Column name, when the issue is column-scoped. `None` for
112 /// table-scoped issues (`MissingTable`, `WrongPrimaryKey` when
113 /// the PK columns disagree).
114 pub column: Option<String>,
115 /// Discrete kind — drives how a renderer / CI script reacts.
116 pub kind: IssueKind,
117 /// Human-readable single-line description. Templates may
118 /// inline this verbatim.
119 pub message: String,
120 /// What the contract expected, formatted for display. `None`
121 /// when expected can't be summarised in a short string.
122 pub expected: Option<String>,
123 /// What the DB actually has, formatted for display. `None`
124 /// when actual is irrelevant (e.g. `MissingColumn` — by
125 /// definition there's no DB-side value).
126 pub actual: Option<String>,
127}
128
129/// Validation result for one model.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct SchemaReport {
132 /// Table name from the Rust contract — matches the
133 /// `ModelSchema::table` field of the input.
134 pub table: String,
135 /// Overall status. Cached version of "has errors → Error,
136 /// has warnings only → Warning, else → Ok".
137 pub status: ReportStatus,
138 pub errors: Vec<SchemaIssue>,
139 pub warnings: Vec<SchemaIssue>,
140}
141
142impl SchemaReport {
143 /// Convenience: `true` when no errors AND no warnings.
144 pub fn is_ok(&self) -> bool {
145 matches!(self.status, ReportStatus::Ok)
146 }
147
148 /// Convenience: `true` when at least one error was recorded.
149 pub fn has_errors(&self) -> bool {
150 !self.errors.is_empty()
151 }
152}
153
154// ---------------------------------------------------------------------------
155// Public API
156// ---------------------------------------------------------------------------
157
158/// Validate the schema of a single model against the live DB.
159///
160/// The model's `T::SCHEMA` (from the [`HasSchema`](crate::contract::HasSchema)
161/// trait — typically generated by `#[derive(RustioModel)]`) is the
162/// expectation. The DB is the source of truth at runtime; any drift
163/// is recorded in the returned [`SchemaReport`].
164///
165/// Read-only — issues two `SELECT` statements against
166/// `information_schema` and exits.
167pub async fn validate_schema<M: HasSchema>(db: &Db) -> SchemaReport {
168 // Trait associated consts evaluate to a value; bind locally so
169 // we can pass it by reference to the inner async helper.
170 let schema = M::SCHEMA;
171 validate_one(db, &schema).await
172}
173
174/// Validate every schema in the given slice. Sequential — running
175/// in parallel would saturate the connection pool with introspection
176/// queries that are usually cheap individually but multiply when
177/// every model is checked at once.
178///
179/// The slice takes `&'static ModelSchema` references to keep the
180/// caller's allocation strategy explicit. Typical usage:
181///
182/// ```ignore
183/// static A: ModelSchema = MyModel::SCHEMA; // requires const-evaluable ModelSchema (it is)
184/// static B: ModelSchema = OtherModel::SCHEMA;
185/// let reports = validate_all(&db, &[&A, &B]).await;
186/// ```
187pub async fn validate_all(
188 db: &Db,
189 schemas: &[&'static ModelSchema],
190) -> Vec<SchemaReport> {
191 let mut out = Vec::with_capacity(schemas.len());
192 for s in schemas {
193 out.push(validate_one(db, s).await);
194 }
195 out
196}
197
198// ---------------------------------------------------------------------------
199// Implementation
200// ---------------------------------------------------------------------------
201
202/// Inner validation pass — pure on `(Db, &ModelSchema) → SchemaReport`.
203/// All public entry points funnel through this.
204async fn validate_one(db: &Db, schema: &ModelSchema) -> SchemaReport {
205 let mut errors: Vec<SchemaIssue> = Vec::new();
206 let mut warnings: Vec<SchemaIssue> = Vec::new();
207 let table = schema.table;
208
209 // Step 1 — introspect columns. Failure is a hard stop; nothing
210 // else can be checked without column metadata.
211 let db_cols = match query_columns(db, table).await {
212 Ok(cols) => cols,
213 Err(e) => {
214 errors.push(SchemaIssue {
215 column: None,
216 kind: IssueKind::QueryFailed,
217 message: format!(
218 "could not query information_schema.columns for table `{table}`: {e}"
219 ),
220 expected: None,
221 actual: None,
222 });
223 return finalize(table.to_string(), errors, warnings);
224 }
225 };
226
227 // Empty result set ⇒ table doesn't exist (or is in a non-public
228 // schema — out of scope for this commit). Treat as missing.
229 if db_cols.is_empty() {
230 errors.push(SchemaIssue {
231 column: None,
232 kind: IssueKind::MissingTable,
233 message: format!(
234 "table `{table}` declared in Rust contract not found in database (schema 'public')"
235 ),
236 expected: Some(table.to_string()),
237 actual: None,
238 });
239 return finalize(table.to_string(), errors, warnings);
240 }
241
242 // Build name → column map for O(1) forward lookups.
243 let db_map: HashMap<&str, &DbColumn> = db_cols
244 .iter()
245 .map(|c| (c.column_name.as_str(), c))
246 .collect();
247
248 // Step 2 — forward checks: every Rust column must be present in
249 // the DB AND its type + nullability must agree.
250 for rc in schema.columns {
251 let dc = match db_map.get(rc.name) {
252 Some(c) => c,
253 None => {
254 errors.push(SchemaIssue {
255 column: Some(rc.name.to_string()),
256 kind: IssueKind::MissingColumn,
257 message: format!(
258 "column `{table}.{}` declared in Rust contract not present in database",
259 rc.name
260 ),
261 expected: Some(rc.sql_decl.to_string()),
262 actual: None,
263 });
264 continue;
265 }
266 };
267
268 // Type compatibility. PG exposes both the long form
269 // (`data_type`, e.g. "timestamp with time zone") and the
270 // short `udt_name` (e.g. "timestamptz") for the same column;
271 // try both.
272 let type_ok = rc.rust_type.is_compatible_with(&dc.data_type)
273 || rc.rust_type.is_compatible_with(&dc.udt_name);
274 if !type_ok {
275 errors.push(SchemaIssue {
276 column: Some(rc.name.to_string()),
277 kind: IssueKind::TypeMismatch,
278 message: format!(
279 "column `{table}.{}`: Rust type {:?} is not compatible with PG type `{}` (udt: `{}`)",
280 rc.name, rc.rust_type, dc.data_type, dc.udt_name
281 ),
282 expected: Some(format!(
283 "{:?} (compatible with one of {:?})",
284 rc.rust_type,
285 rc.rust_type.pg_compatible()
286 )),
287 actual: Some(dc.data_type.clone()),
288 });
289 }
290
291 // Nullability. PG returns "YES" / "NO" as strings.
292 let pg_nullable = dc.is_nullable.eq_ignore_ascii_case("YES");
293 if pg_nullable != rc.nullable {
294 errors.push(SchemaIssue {
295 column: Some(rc.name.to_string()),
296 kind: IssueKind::NullabilityMismatch,
297 message: format!(
298 "column `{table}.{}`: contract says nullable={}, DB says nullable={}",
299 rc.name, rc.nullable, pg_nullable
300 ),
301 expected: Some(format!("nullable = {}", rc.nullable)),
302 actual: Some(format!("nullable = {pg_nullable}")),
303 });
304 }
305 }
306
307 // Step 3 — primary-key check. Must exactly equal the contract's
308 // single declared PK. Composite PKs are not supported in
309 // commit 1's contract surface.
310 match query_primary_key(db, table).await {
311 Ok(pk_cols) => {
312 let mismatch = pk_cols.len() != 1 || pk_cols[0] != schema.primary_key;
313 if mismatch {
314 errors.push(SchemaIssue {
315 column: Some(schema.primary_key.to_string()),
316 kind: IssueKind::WrongPrimaryKey,
317 message: format!(
318 "primary key drift on `{table}`: contract expects `{}`, DB has [{}]",
319 schema.primary_key,
320 pk_cols.join(", ")
321 ),
322 expected: Some(schema.primary_key.to_string()),
323 actual: Some(if pk_cols.is_empty() {
324 "<no primary key>".to_string()
325 } else {
326 pk_cols.join(", ")
327 }),
328 });
329 }
330 }
331 Err(e) => {
332 errors.push(SchemaIssue {
333 column: None,
334 kind: IssueKind::QueryFailed,
335 message: format!(
336 "could not query primary-key constraints for `{table}`: {e}"
337 ),
338 expected: None,
339 actual: None,
340 });
341 }
342 }
343
344 // Step 4 — reverse check: DB columns not in the Rust contract.
345 // Warning only; could be a deliberate audit column added by an
346 // out-of-band migration.
347 let rust_names: HashSet<&str> = schema.columns.iter().map(|c| c.name).collect();
348 for dc in &db_cols {
349 if !rust_names.contains(dc.column_name.as_str()) {
350 warnings.push(SchemaIssue {
351 column: Some(dc.column_name.clone()),
352 kind: IssueKind::ExtraDbColumn,
353 message: format!(
354 "DB column `{table}.{}` not declared in Rust contract (could be deliberate)",
355 dc.column_name
356 ),
357 expected: None,
358 actual: Some(dc.data_type.clone()),
359 });
360 }
361 }
362
363 finalize(table.to_string(), errors, warnings)
364}
365
366fn finalize(
367 table: String,
368 errors: Vec<SchemaIssue>,
369 warnings: Vec<SchemaIssue>,
370) -> SchemaReport {
371 let status = if !errors.is_empty() {
372 ReportStatus::Error
373 } else if !warnings.is_empty() {
374 ReportStatus::Warning
375 } else {
376 ReportStatus::Ok
377 };
378 SchemaReport {
379 table,
380 status,
381 errors,
382 warnings,
383 }
384}
385
386// ---------------------------------------------------------------------------
387// Postgres introspection
388// ---------------------------------------------------------------------------
389
390#[derive(Debug)]
391struct DbColumn {
392 column_name: String,
393 data_type: String,
394 udt_name: String,
395 is_nullable: String, // "YES" / "NO" per ISO SQL information_schema spec.
396}
397
398/// Query `information_schema.columns` for a single table in the
399/// `public` schema. Returns rows in declaration (ordinal) order.
400async fn query_columns(db: &Db, table: &str) -> Result<Vec<DbColumn>, sqlx::Error> {
401 use sqlx::Row;
402 let rows = sqlx::query(
403 "SELECT column_name, data_type, udt_name, is_nullable
404 FROM information_schema.columns
405 WHERE table_schema = 'public' AND table_name = $1
406 ORDER BY ordinal_position",
407 )
408 .bind(table)
409 .fetch_all(db.pool())
410 .await?;
411
412 let mut out = Vec::with_capacity(rows.len());
413 for row in rows {
414 out.push(DbColumn {
415 column_name: row.try_get("column_name")?,
416 data_type: row.try_get("data_type")?,
417 udt_name: row.try_get("udt_name")?,
418 is_nullable: row.try_get("is_nullable")?,
419 });
420 }
421 Ok(out)
422}
423
424/// Query the primary-key columns of a table. Returns names in
425/// `ordinal_position` order so composite keys (if/when supported)
426/// preserve their declared sequence.
427async fn query_primary_key(db: &Db, table: &str) -> Result<Vec<String>, sqlx::Error> {
428 use sqlx::Row;
429 let rows = sqlx::query(
430 "SELECT kcu.column_name
431 FROM information_schema.table_constraints tc
432 JOIN information_schema.key_column_usage kcu
433 ON tc.constraint_name = kcu.constraint_name
434 AND tc.table_schema = kcu.table_schema
435 WHERE tc.constraint_type = 'PRIMARY KEY'
436 AND tc.table_schema = 'public'
437 AND tc.table_name = $1
438 ORDER BY kcu.ordinal_position",
439 )
440 .bind(table)
441 .fetch_all(db.pool())
442 .await?;
443
444 let mut out = Vec::with_capacity(rows.len());
445 for row in rows {
446 out.push(row.try_get::<String, _>("column_name")?);
447 }
448 Ok(out)
449}
450
451// ---------------------------------------------------------------------------
452// Unit tests — pure (no DB)
453// ---------------------------------------------------------------------------
454//
455// PG-gated end-to-end tests live in `tests/contract_validator_pg.rs`
456// and only run under `RUSTIO_TEST_DB=1 cargo test --ignored`.
457// The unit tests here exercise the small pure helpers
458// (`finalize`, `ReportStatus` derivation) that don't need a DB.
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn finalize_no_issues_is_ok() {
466 let r = finalize("t".into(), vec![], vec![]);
467 assert_eq!(r.status, ReportStatus::Ok);
468 assert!(r.is_ok());
469 assert!(!r.has_errors());
470 }
471
472 #[test]
473 fn finalize_only_warnings_is_warning() {
474 let warn = SchemaIssue {
475 column: Some("extra".into()),
476 kind: IssueKind::ExtraDbColumn,
477 message: "x".into(),
478 expected: None,
479 actual: None,
480 };
481 let r = finalize("t".into(), vec![], vec![warn]);
482 assert_eq!(r.status, ReportStatus::Warning);
483 assert!(!r.is_ok());
484 assert!(!r.has_errors());
485 }
486
487 #[test]
488 fn finalize_any_error_is_error() {
489 let err = SchemaIssue {
490 column: Some("c".into()),
491 kind: IssueKind::TypeMismatch,
492 message: "x".into(),
493 expected: None,
494 actual: None,
495 };
496 // Even with warnings present, a single error promotes to Error.
497 let warn = SchemaIssue {
498 column: None,
499 kind: IssueKind::ExtraDbColumn,
500 message: "y".into(),
501 expected: None,
502 actual: None,
503 };
504 let r = finalize("t".into(), vec![err], vec![warn]);
505 assert_eq!(r.status, ReportStatus::Error);
506 assert!(r.has_errors());
507 }
508}