Skip to main content

max / makenotwork

4.3 KB · 123 lines History Blame Raw
1 //! Guards against tri-source enum drift: every domain enum's variant set must
2 //! equal the Postgres `CHECK (... IN (...))` list on its backing column. The
3 //! enums decode fail-closed (an unknown string errors the whole row read), so a
4 //! value the DB permits but the Rust enum can't decode would poison any query
5 //! that touches such a row. This test makes that drift fail in CI instead of at
6 //! the first read of a poisoned row.
7 //!
8 //! `EnumType::VARIANTS` is the single source of truth (generated by
9 //! `impl_str_enum!`); the DB side is read from the live, fully-migrated schema
10 //! so later `ALTER ... CHECK` migrations are accounted for.
11
12 use crate::harness::TestHarness;
13 use makenotwork::db;
14 use std::collections::BTreeSet;
15
16 /// Extract the single-quoted string literals from a `pg_get_constraintdef`
17 /// rendering (Postgres renders `col IN ('a','b')` as
18 /// `CHECK ((col)::text = ANY (ARRAY['a'::text, 'b'::text]))`; the allowed values
19 /// are exactly the single-quoted tokens).
20 fn quoted_values(constraint_def: &str) -> BTreeSet<String> {
21 let mut out = BTreeSet::new();
22 let mut chars = constraint_def.char_indices().peekable();
23 while let Some((_, c)) = chars.next() {
24 if c == '\'' {
25 let mut val = String::new();
26 for (_, c2) in chars.by_ref() {
27 if c2 == '\'' {
28 break;
29 }
30 val.push(c2);
31 }
32 out.insert(val);
33 }
34 }
35 out
36 }
37
38 /// Read the union of allowed values across every single-column CHECK constraint
39 /// on exactly `table.column`. Constraining to `conkey = [column]` excludes
40 /// cross-column CHECKs (which would leak an unrelated column's literals).
41 async fn db_check_values(pool: &sqlx::PgPool, table: &str, column: &str) -> BTreeSet<String> {
42 let defs: Vec<String> = sqlx::query_scalar(
43 "SELECT pg_get_constraintdef(c.oid) \
44 FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid \
45 WHERE t.relname = $1 AND c.contype = 'c' \
46 AND c.conkey = ARRAY[ \
47 (SELECT a.attnum FROM pg_attribute a WHERE a.attrelid = t.oid AND a.attname = $2) \
48 ]::smallint[]",
49 )
50 .bind(table)
51 .bind(column)
52 .fetch_all(pool)
53 .await
54 .unwrap();
55
56 assert!(
57 !defs.is_empty(),
58 "no CHECK constraint found on {table}.{column} — update the enum-drift registry"
59 );
60 defs.iter().flat_map(|d| quoted_values(d)).collect()
61 }
62
63 fn variant_set(variants: &[&str]) -> BTreeSet<String> {
64 variants
65 .iter()
66 .map(std::string::ToString::to_string)
67 .collect()
68 }
69
70 #[tokio::test]
71 async fn enum_variants_match_db_check_constraints() {
72 let h = TestHarness::new().await;
73
74 // (label, table, column, Rust variant set). Each pair's DB CHECK list must
75 // equal the enum's VARIANTS. Add a row when a new enum backs a CHECK column.
76 // Only enum-backed columns that actually carry an `IN (...)` CHECK. Columns
77 // that store a broad external status set (e.g. subscriptions.status mirrors
78 // Stripe) intentionally have no CHECK and are not listed. Grow this as new
79 // CHECK-constrained enum columns land.
80 let cases: Vec<(&str, &str, &str, BTreeSet<String>)> = vec![
81 (
82 "TransactionStatus",
83 "transactions",
84 "status",
85 variant_set(db::TransactionStatus::VARIANTS),
86 ),
87 (
88 "DiscountType",
89 "promo_codes",
90 "discount_type",
91 variant_set(db::DiscountType::VARIANTS),
92 ),
93 (
94 "CodePurpose",
95 "promo_codes",
96 "code_purpose",
97 variant_set(db::CodePurpose::VARIANTS),
98 ),
99 (
100 "FollowTargetType",
101 "follows",
102 "target_type",
103 variant_set(db::FollowTargetType::VARIANTS),
104 ),
105 ];
106
107 let mut failures = Vec::new();
108 for (label, table, column, variants) in &cases {
109 let db_values = db_check_values(&h.db, table, column).await;
110 if &db_values != variants {
111 failures.push(format!(
112 "{label} ({table}.{column}):\n rust VARIANTS = {variants:?}\n db CHECK = {db_values:?}"
113 ));
114 }
115 }
116
117 assert!(
118 failures.is_empty(),
119 "enum <-> DB CHECK drift detected:\n{}",
120 failures.join("\n")
121 );
122 }
123