Skip to main content

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}