Skip to main content

ppoppo_schema_constrained/
lib.rs

1//! **NOT a stable public API.** Engine-tier binding primitive — published to
2//! crates.io only because the SDK closure requires it on the registry; 3rd
3//! parties never name this crate. They meet the value-sets it binds through an
4//! SDK product facade or a wire contract, never here.
5//!
6//! # Schema-Constrained Value-Sets (the enum ↔ `CHECK` anchor)
7//!
8//! A *schema-constrained* enum is a domain value-set whose members are also
9//! enumerated by a PostgreSQL `CHECK (col IN (…))` constraint. The enum and the
10//! constraint are two reifications of one fact ("the legal values of this
11//! column"); left unbound they drift independently — a migration widens the
12//! `CHECK`, or a variant is added, and the other side silently goes stale.
13//!
14//! This crate is the single seam that binds them. The same drift class exists
15//! on both sides of the monorepo (PAS `scaccounts`, PCS `scchat`) and neither
16//! core may depend on the other, so the binding primitive is hoisted out of
17//! both — a pure, dependency-free trait + macro (`std::BTreeSet` only), which
18//! both cores (that ban IO/transport crates) can depend on.
19//!
20//! ## Why engine tier and not `crates/shared/`
21//!
22//! It sat in `crates/shared/` (`publish = false`) until `RFC_202607252223`
23//! T-03, which is when the placement was first *tested* rather than assumed:
24//! `ppoppo-identity` needs to enroll its own `EntityType`, and `engine →
25//! shared` is forbidden by the crate lattice (`xtask::policy::rules::taxonomy`)
26//! — so the enrollment was unreachable, and the vocabulary had to keep a
27//! second PAS-local enum alive just to carry it.
28//!
29//! The fix was to notice that the folder was wrong, not the lattice. Engine
30//! tier means *published substrate that no 3rd party names* — a dependency-free
31//! trait + macro consumed by two service cores and one vocabulary crate is
32//! exactly that. The move corrected a misfile that predates the tier; it did
33//! not trade a principle for convenience.
34//!
35//! ## The anchor triple (per `STS_SSOT_GOVERNANCE`)
36//!
37//! - **Owner**: the domain enum in `accounts-core` / `accounts-api` /
38//!   `chat-core` (the closest reified form of the value-set decision).
39//! - **Anchor**: domain-specific — these are tuned domain vocabularies with no
40//!   external standard, so *this crate doc-comment is the anchor of record*
41//!   (governance §4). (Formerly PAS
42//!   `ADR_202605242324_schema-constrained-value-sets.md`, folded into
43//!   `accounts-core` on its retirement, then hoisted here when PCS adopted the
44//!   same gate.)
45//! - **Verification**: [`bindings`](SchemaConstrained::bindings) feeds each
46//!   service's `schema_check_drift.rs` DB test
47//!   (`accounts-api/tests/` for `scaccounts`, `chat-api/tests/` for `scchat`),
48//!   which reads each `CHECK` from the *materialized* schema
49//!   (`pg_get_constraintdef`) and asserts set-equality with `ALL`. The
50//!   compile-time half lives in the [`impl_schema_constrained!`] macro: it
51//!   emits an exhaustive `match`, so adding a variant without listing it fails
52//!   to build.
53//!
54//! ## Why a DB test and not a file parse
55//!
56//! The value-sets evolve through `ALTER … DROP/ADD CONSTRAINT` migrations (e.g.
57//! `lifecycle_state` gained `tombstoned`; `oauth_audit_events.event_type`
58//! gained `otp_issue`/`otp_verify`). The authoritative set is therefore the
59//! *result of applying every migration*, which only the database knows —
60//! parsing the baseline `.sql` would report phantom drift. Asking Postgres via
61//! `pg_get_constraintdef` is the only correct anchor (and avoids the brittle
62//! bespoke-parser ops-tax rejected in `STS_RATE_LIMITS_PPOPPO` §Anchor
63//! "Rationale" option A).
64//!
65//! ## Two more rejected alternatives
66//!
67//! - **`#[sqlx::Type]` alone.** Binds the column *type* (text), not the
68//!   `CHECK`'s value-set — a typo in the enum still compiles and the set still
69//!   drifts. Complementary at the query boundary, not a substitute for the
70//!   verification.
71//! - **Accept drift under human review.** Leaves security-adjacent value-sets
72//!   (audit taxonomy, lifecycle, step-up purpose) under governance §2's
73//!   "aspiration, not enforcement" gate. Rejected.
74//!
75//! ## Caveat — the value-equality half is integration-tier
76//!
77//! The compile-time exhaustiveness guard covers the Rust side on every build.
78//! The `ALL`-vs-`CHECK` set-equality half needs a live database, and there is
79//! no CI job running DB-backed tests — so it bites via each service's
80//! `just test-integration` and the `/deploy-ppoppo` pre-flight, not on a plain
81//! `cargo test`.
82
83#![deny(rust_2018_idioms)]
84#![warn(missing_debug_implementations)]
85
86use std::collections::BTreeSet;
87
88/// One `(constraint, allowed-value-set)` pair, erased of the originating enum
89/// type so the drift test can iterate heterogeneous value-sets.
90#[derive(Debug, Clone)]
91pub struct SchemaBinding {
92    /// The `pg_constraint.conname` (e.g. `"ck_ppnums_entity_type_enum"`).
93    pub constraint: &'static str,
94    /// The DB-text values the owning enum permits — must equal the constraint's
95    /// `IN (…)` set in the materialized schema.
96    pub allowed: BTreeSet<&'static str>,
97}
98
99/// A domain value-set whose members are mirrored by one or more SQL `CHECK`
100/// constraints.
101///
102/// Implemented via [`impl_schema_constrained!`]; never hand-written, so the
103/// compile-time exhaustiveness guard is always emitted alongside.
104pub trait SchemaConstrained: Sized + 'static {
105    /// Every variant, in any order. The macro hand-lists these (no `strum`);
106    /// the exhaustiveness guard makes an omission a build error and the DB test
107    /// makes a stale list a pre-flight failure.
108    const ALL: &'static [Self];
109
110    /// The `CHECK` constraint(s) whose `IN (…)` set must equal
111    /// `{ ALL.map(db_value) }`. More than one when several columns share the
112    /// value-set (e.g. `lifecycle_state` on three columns).
113    const CHECK_CONSTRAINTS: &'static [&'static str];
114
115    /// The DB-text form of a variant — the literal stored in the column and
116    /// named in the `CHECK`. Delegates to the enum's inherent `as_str` /
117    /// `as_wire`.
118    fn db_value(&self) -> &'static str;
119
120    /// Erased `(constraint, allowed)` pairs for the drift test — one per entry
121    /// in [`CHECK_CONSTRAINTS`](Self::CHECK_CONSTRAINTS).
122    fn bindings() -> Vec<SchemaBinding> {
123        let allowed: BTreeSet<&'static str> = Self::ALL.iter().map(Self::db_value).collect();
124        Self::CHECK_CONSTRAINTS
125            .iter()
126            .map(|&constraint| SchemaBinding {
127                constraint,
128                allowed: allowed.clone(),
129            })
130            .collect()
131    }
132}
133
134/// Implement [`SchemaConstrained`] for a unit-variant enum and emit a
135/// compile-time exhaustiveness guard from the same variant list.
136///
137/// ```ignore
138/// ppoppo_schema_constrained::impl_schema_constrained!(EntityType via as_str {
139///     all: [Human, AiAgent, Enterprise, Programmable, Mask],
140///     constraints: ["ck_ppnums_entity_type_enum"],
141/// });
142/// ```
143///
144/// `via $method` is the enum's inherent value accessor (`as_str` for most,
145/// `as_db_str` / `as_wire` for others). Place the invocation next to the enum
146/// so the constraint name lives *on the fact* (governance §6 carrier).
147///
148/// `non_stored` lists render-only variants that exist in the enum but are never
149/// persisted (and so never appear in the `CHECK`) — e.g. `EntityType::Delegated`.
150/// They are excluded from `ALL` yet still covered by the exhaustiveness guard,
151/// so the asymmetry is declared, not hidden.
152#[macro_export]
153macro_rules! impl_schema_constrained {
154    (
155        $ty:ident via $method:ident {
156            all: [ $( $variant:ident ),+ $(,)? ],
157            $( non_stored: [ $( $ns:ident ),+ $(,)? ], )?
158            constraints: [ $( $constraint:literal ),+ $(,)? ] $(,)?
159        }
160    ) => {
161        impl $crate::SchemaConstrained for $ty {
162            const ALL: &'static [Self] = &[ $( $ty::$variant ),+ ];
163            const CHECK_CONSTRAINTS: &'static [&'static str] = &[ $( $constraint ),+ ];
164            fn db_value(&self) -> &'static str {
165                self.$method()
166            }
167        }
168
169        // Compile-time half: if a variant is added to the enum but listed in
170        // neither `all` nor `non_stored`, this match is non-exhaustive and the
171        // build fails — pointing the author at the enrollment.
172        const _: fn($ty) = |x| match x {
173            $( $ty::$variant => () ),+
174            $( , $( $ty::$ns => () ),+ )?
175        };
176    };
177}
178
179/// Finding value-set `CHECK` constraints in migration SQL.
180///
181/// The enrollment registries answer "which enums claim a constraint". This
182/// answers the opposite question — "which constraints exist" — so a service
183/// can assert that every value-set in its schema is either enrolled or
184/// explicitly declared unbound. Both organs need it, and a parser duplicated
185/// per organ is a parser that gets fixed in one of them.
186pub mod migration_scan {
187    use std::collections::BTreeSet;
188
189    /// Names every **single-column value-set** CHECK in one SQL text.
190    ///
191    /// Both spellings count, because both occur: `pg_dump` writes
192    /// `CHECK ((col = ANY (ARRAY['a'::text])))` and a hand-written migration
193    /// writes `CHECK (col IN ('a', 'b'))`. Postgres treats them as the same
194    /// constraint; so does this.
195    ///
196    /// Out of scope, and deliberately so — these are not value-sets and have no
197    /// enum to bind to: range checks, regex checks, `IS NULL` checks, and any
198    /// multi-clause CHECK such as `CHECK ((plan = 'enterprise') = (limit IS
199    /// NULL))`. They are excluded by *structure* (the expression must open on a
200    /// bare column followed immediately by the membership operator), not by a
201    /// blocklist that would need maintaining.
202    pub fn value_set_constraints_in(sql: &str) -> BTreeSet<String> {
203        let flat = flatten(sql);
204        value_set_events(&flat)
205            .into_iter()
206            .map(|(_, name)| name)
207            .collect()
208    }
209
210    /// Every `RENAME CONSTRAINT <old> TO <new>` in one SQL text, in text order.
211    ///
212    /// # Why a text scan of migrations needs this at all
213    ///
214    /// [`value_set_constraints_in`] sees a constraint at the name the migration
215    /// that *created* it used. A later migration is free to rename it, and the
216    /// text of the first file never changes — so a corpus scan without this
217    /// reports a constraint that no longer exists, and the guard demands an
218    /// enum bound to a name nothing will ever match while the enum correctly
219    /// names the new one. That is a false positive on every rename, and it
220    /// reads as a missing enrolment, which sends the reader to the wrong file.
221    ///
222    /// Lives here for the same reason the parser does: both organs need it, and
223    /// a resolver duplicated per organ is a resolver that gets fixed in one.
224    pub fn constraint_renames_in(sql: &str) -> Vec<(String, String)> {
225        let flat = flatten(sql);
226        rename_events(&flat)
227            .into_iter()
228            .map(|(_, rename)| rename)
229            .collect()
230    }
231
232    /// Every value-set CHECK across an ordered migration corpus, **at its final
233    /// name** — the set the materialized schema actually holds.
234    ///
235    /// `sources` must be in chronological order — for this repo's stamped
236    /// filenames that is lexicographic order — and the order is load-bearing:
237    /// this is a **fold over statements**, applied exactly as Postgres applies
238    /// them. An `ADD CONSTRAINT` inserts a name; a `RENAME CONSTRAINT` moves a
239    /// name that exists *at that moment* and is a no-op on one that does not;
240    /// a `DROP CONSTRAINT` removes one.
241    /// Nothing is resolved after the fact, so a rename never reaches back to a
242    /// constraint that had not been created yet, and never reaches forward to
243    /// one created later under the retired name.
244    ///
245    /// The earlier shape — collect every rename from the whole corpus, then
246    /// resolve every constraint name through all of them — violated exactly
247    /// that (`RFC_202609080357` T-19): a CHECK re-created under a name a
248    /// previous migration had renamed away was resolved through that old
249    /// rename and dropped out of the demanded set, so an unenrolled enum
250    /// escaped `schema_check_coverage`. The fold also makes a rename cycle
251    /// harmless by construction — each statement applies once, and the result
252    /// is whatever name the cycle leaves the constraint on, which is what the
253    /// database ends with too.
254    ///
255    /// This composition lives here rather than in each service's guard on
256    /// purpose: two organs that each assemble "scan, then resolve" for
257    /// themselves are two places for the second half to be forgotten, which is
258    /// exactly how PCS's guard shipped without it.
259    pub fn value_set_constraints_across<'a>(
260        sources: impl IntoIterator<Item = &'a str>,
261    ) -> BTreeSet<String> {
262        let mut live = BTreeSet::new();
263        for sql in sources {
264            let flat = flatten(sql);
265            for event in events_in(&flat) {
266                match event {
267                    Event::Add(name) => {
268                        live.insert(name);
269                    }
270                    Event::Rename(from, to) => {
271                        if live.remove(&from) {
272                            live.insert(to);
273                        }
274                    }
275                    Event::Drop(name) => {
276                        live.remove(&name);
277                    }
278                }
279            }
280        }
281        live
282    }
283
284    /// One statement the fold cares about, in the order it appears.
285    enum Event {
286        Add(String),
287        Rename(String, String),
288        Drop(String),
289    }
290
291    /// The value-set adds and the constraint renames and drops of one
292    /// flattened text, merged by text position — the only order in which they
293    /// mean anything.
294    fn events_in(flat: &str) -> Vec<Event> {
295        let mut events: Vec<(usize, Event)> = value_set_events(flat)
296            .into_iter()
297            .map(|(at, name)| (at, Event::Add(name)))
298            .chain(
299                rename_events(flat)
300                    .into_iter()
301                    .map(|(at, (from, to))| (at, Event::Rename(from, to))),
302            )
303            .chain(
304                drop_events(flat)
305                    .into_iter()
306                    .map(|(at, name)| (at, Event::Drop(name))),
307            )
308            .collect();
309        events.sort_by_key(|(at, _)| *at);
310        events.into_iter().map(|(_, event)| event).collect()
311    }
312
313    /// `(position, constraint name)` for every single-column value-set CHECK
314    /// in a flattened text.
315    fn value_set_events(flat: &str) -> Vec<(usize, String)> {
316        let mut found = Vec::new();
317        for (at, _) in flat.match_indices("CONSTRAINT ") {
318            let rest = &flat[at + "CONSTRAINT ".len()..];
319            let Some((name, tail)) = rest.split_once(' ') else {
320                continue;
321            };
322            let Some(body) = tail.strip_prefix("CHECK ") else {
323                continue;
324            };
325            let Some(body) = balanced(body) else {
326                continue;
327            };
328            if is_pure_value_set(body) {
329                found.push((at, name.to_string()));
330            }
331        }
332        found
333    }
334
335    /// `(position, (old, new))` for every `RENAME CONSTRAINT` in a flattened
336    /// text.
337    fn rename_events(flat: &str) -> Vec<(usize, (String, String))> {
338        let mut renames = Vec::new();
339        for (at, _) in flat.match_indices("RENAME CONSTRAINT ") {
340            let rest = &flat[at + "RENAME CONSTRAINT ".len()..];
341            let mut parts = rest.split_whitespace();
342            let (Some(from), Some(to_kw), Some(to)) = (parts.next(), parts.next(), parts.next())
343            else {
344                continue;
345            };
346            if !to_kw.eq_ignore_ascii_case("TO") {
347                continue;
348            }
349            renames.push((
350                at,
351                (
352                    from.trim_end_matches(';').to_string(),
353                    to.trim_end_matches(';').to_string(),
354                ),
355            ));
356        }
357        renames
358    }
359
360    /// `(position, name)` for every `DROP CONSTRAINT [IF EXISTS] <name>` in a
361    /// flattened text.
362    ///
363    /// A value-set CHECK that leaves only because its column is dropped is not
364    /// seen here — the fold does not track columns. The migration that drops
365    /// such a column names the CHECK in a `DROP CONSTRAINT` first, which also
366    /// states in the file what the drop removes.
367    fn drop_events(flat: &str) -> Vec<(usize, String)> {
368        let mut drops = Vec::new();
369        for (at, _) in flat.match_indices("DROP CONSTRAINT ") {
370            let rest = &flat[at + "DROP CONSTRAINT ".len()..];
371            let rest = rest.strip_prefix("IF EXISTS ").unwrap_or(rest);
372            let Some(name) = rest.split_whitespace().next() else {
373                continue;
374            };
375            let name = name.trim_end_matches([';', ',']);
376            if !name.is_empty() {
377                drops.push((at, name.to_string()));
378            }
379        }
380        drops
381    }
382
383    /// Drops `--` comments and collapses runs of whitespace, so `find` can
384    /// match across the line breaks migrations put between a constraint name
385    /// and its clause — and cannot match a statement the author commented out.
386    ///
387    /// Comments are stripped **per line, before the lines are joined**: a
388    /// `--` runs to the end of its own line and no further, and stripping
389    /// after the join would delete the whole remainder of the file past the
390    /// first comment. A `--` inside a single-quoted literal is text, not a
391    /// comment, and is kept.
392    fn flatten(sql: &str) -> String {
393        let mut out = String::with_capacity(sql.len());
394        let mut in_space = false;
395        for line in sql.lines() {
396            for ch in strip_line_comment(line)
397                .chars()
398                .chain(std::iter::once('\n'))
399            {
400                if ch.is_whitespace() {
401                    if !in_space {
402                        out.push(' ');
403                    }
404                    in_space = true;
405                } else {
406                    out.push(ch);
407                    in_space = false;
408                }
409            }
410        }
411        out
412    }
413
414    /// The part of one line before its `--` comment, if any, ignoring a `--`
415    /// that sits inside a single-quoted literal.
416    fn strip_line_comment(line: &str) -> &str {
417        let mut in_quote = false;
418        let mut prev_dash = false;
419        for (i, ch) in line.char_indices() {
420            match ch {
421                '\'' => {
422                    in_quote = !in_quote;
423                    prev_dash = false;
424                }
425                '-' if !in_quote => {
426                    if prev_dash {
427                        return &line[..i - 1];
428                    }
429                    prev_dash = true;
430                }
431                _ => prev_dash = false,
432            }
433        }
434        line
435    }
436
437    /// The contents of the leading `(...)` group, without its outer parens.
438    fn balanced(s: &str) -> Option<&str> {
439        let s = s.strip_prefix('(')?;
440        let mut depth = 1usize;
441        for (i, ch) in s.char_indices() {
442            match ch {
443                '(' => depth += 1,
444                ')' => {
445                    depth -= 1;
446                    if depth == 0 {
447                        return Some(&s[..i]);
448                    }
449                }
450                _ => {}
451            }
452        }
453        None
454    }
455
456    /// Is this expression *nothing but* a membership test on one column?
457    ///
458    /// The "nothing but" is the whole difficulty. An earlier version stripped
459    /// leading parens and checked only that the expression *began* with
460    /// `col IN (` — which accepts
461    /// `((state = ANY (…)) AND (x IS NOT NULL)) OR (…)`, a compound predicate
462    /// whose first clause happens to be a membership test. PAS has exactly one
463    /// of those (`ck_signup_sessions_reservation_pair`) and it was misreported
464    /// as an unbound value-set, i.e. the guard demanded an enum for a
465    /// constraint that can never have one.
466    ///
467    /// So the test is structural on both ends: unwrap redundant parens, then
468    /// require the membership group to consume the entire remainder.
469    fn is_pure_value_set(expr: &str) -> bool {
470        let mut expr = expr.trim();
471        // Unwrap only parens that wrap the *whole* expression.
472        while let Some(inner) = balanced(expr) {
473            if inner.len() + 2 == expr.len() {
474                expr = inner.trim();
475            } else {
476                break;
477            }
478        }
479
480        // The column is either bare (`kind`) or parenthesised, which is how
481        // `pg_dump` writes a cast of a varchar column: `(kind)::text`.
482        let mut after = if expr.starts_with('(') {
483            match balanced(expr) {
484                Some(inner)
485                    if !inner.is_empty()
486                        && inner.chars().all(|c| c.is_alphanumeric() || c == '_') =>
487                {
488                    expr[inner.len() + 2..].trim_start()
489                }
490                _ => return false,
491            }
492        } else {
493            let column_len = expr
494                .find(|c: char| !(c.is_alphanumeric() || c == '_'))
495                .unwrap_or(expr.len());
496            if column_len == 0 {
497                return false;
498            }
499            expr[column_len..].trim_start()
500        };
501        loop {
502            if let Some(r) = after.strip_prefix(')') {
503                after = r.trim_start();
504                continue;
505            }
506            if let Some(r) = after.strip_prefix("::") {
507                after = r
508                    .trim_start_matches(|ch: char| ch.is_alphanumeric() || ch == '_')
509                    .trim_start();
510                continue;
511            }
512            break;
513        }
514
515        let list = if let Some(r) = after.strip_prefix("IN ") {
516            r.trim_start()
517        } else if let Some(r) = after.strip_prefix("= ANY ") {
518            r.trim_start()
519        } else {
520            return false;
521        };
522        // The membership group must be the last thing in the expression — no
523        // trailing ` AND …`, no ` OR …`.
524        match balanced(list) {
525            Some(inner) => list.len() == inner.len() + 2,
526            None => false,
527        }
528    }
529
530    #[cfg(test)]
531    #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
532    mod tests {
533        use super::{constraint_renames_in, value_set_constraints_across};
534
535        // ─── rename resolution ────────────────────────────────────────────
536        //
537        // The rename half is half the guard, so it is tested like one: a fold
538        // that silently saw no renames would restore exactly the false
539        // positive it exists to remove, and would do it quietly.
540
541        #[test]
542        fn renames_parse_across_the_line_breaks_migrations_use() {
543            let sql = "ALTER TABLE scchat.t\n    RENAME CONSTRAINT one_check\n                  TO two_check;";
544            assert_eq!(
545                constraint_renames_in(sql),
546                vec![("one_check".to_string(), "two_check".to_string())],
547            );
548        }
549
550        #[test]
551        fn a_rename_chain_lands_on_its_final_name() {
552            let corpus = value_set_constraints_across([
553                "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a')); \
554                 ALTER TABLE t ADD CONSTRAINT untouched_check CHECK (mode IN ('m'));",
555                "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check;",
556                "ALTER TABLE t RENAME CONSTRAINT b_check TO c_check;",
557            ]);
558            assert!(corpus.contains("c_check"), "{corpus:?}");
559            assert!(
560                !corpus.contains("a_check") && !corpus.contains("b_check"),
561                "neither retired name is demanded: {corpus:?}"
562            );
563            assert!(
564                corpus.contains("untouched_check"),
565                "a constraint nobody renamed keeps its name: {corpus:?}"
566            );
567        }
568
569        #[test]
570        fn a_cyclic_rename_ends_where_the_database_ends() {
571            // A cycle is a migration bug, but it is one Postgres executes
572            // happily — each statement applies once — so the guard must
573            // terminate AND report the name the schema is actually left with.
574            let corpus = value_set_constraints_across([
575                "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a')); \
576                 ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
577                 ALTER TABLE t RENAME CONSTRAINT b_check TO a_check;",
578            ]);
579            assert_eq!(
580                corpus.into_iter().collect::<Vec<_>>(),
581                vec!["a_check".to_string()]
582            );
583        }
584
585        #[test]
586        fn a_corpus_reports_the_check_at_its_final_name_only() {
587            // The whole point: the creating migration's text never changes, so
588            // without the second pass the guard demands an enum bound to a name
589            // that no longer exists — and that reads as a missing enrolment,
590            // which sends the reader to the wrong file entirely.
591            let created =
592                "ALTER TABLE scchat.t ADD CONSTRAINT old_check CHECK (kind IN ('a', 'b'));";
593            let renamed = "ALTER TABLE scchat.t RENAME CONSTRAINT old_check TO new_check;";
594
595            let one_file = value_set_constraints_across([created]);
596            assert!(
597                one_file.contains("old_check"),
598                "control: the scan finds it before any rename"
599            );
600
601            let corpus = value_set_constraints_across([created, renamed]);
602            assert!(
603                corpus.contains("new_check"),
604                "the final name is what the schema has"
605            );
606            assert!(
607                !corpus.contains("old_check"),
608                "the retired name must not be demanded"
609            );
610        }
611
612        // ─── drops ────────────────────────────────────────────────────────
613
614        #[test]
615        fn a_dropped_check_is_no_longer_demanded() {
616            let corpus = value_set_constraints_across([
617                "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a')); \
618                 ALTER TABLE t ADD CONSTRAINT kept_check CHECK (mode IN ('m'));",
619                "ALTER TABLE t\n    DROP CONSTRAINT a_check;\nALTER TABLE t DROP COLUMN kind;",
620            ]);
621            assert_eq!(
622                corpus.into_iter().collect::<Vec<_>>(),
623                vec!["kept_check".to_string()]
624            );
625        }
626
627        #[test]
628        fn a_drop_then_readd_in_one_statement_keeps_the_check() {
629            // The value-set widening shape: `DROP CONSTRAINT x, ADD CONSTRAINT
630            // x CHECK (...)`. Text order decides, so the name survives.
631            let corpus = value_set_constraints_across([
632                "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a'));",
633                "ALTER TABLE t DROP CONSTRAINT IF EXISTS a_check, \
634                 ADD CONSTRAINT a_check CHECK (kind IN ('a', 'b'));",
635            ]);
636            assert!(corpus.contains("a_check"), "{corpus:?}");
637        }
638
639        #[test]
640        fn a_commented_out_drop_is_not_applied() {
641            let corpus = value_set_constraints_across([
642                "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a'));",
643                "-- ALTER TABLE t DROP CONSTRAINT a_check;",
644            ]);
645            assert!(corpus.contains("a_check"), "{corpus:?}");
646        }
647
648        #[test]
649        fn a_check_recreated_under_a_renamed_away_name_is_demanded_again() {
650            // RFC_202609080357 T-19. Three migrations: the first creates
651            // `a_check`, the second renames it to `b_check`, the third creates
652            // a NEW `a_check`. The schema ends with both. A scan that collects
653            // every rename first and only then resolves every name sends the
654            // third file's `a_check` through the second file's rename — so the
655            // new constraint vanishes from the demanded set, and an enum that
656            // forgot to enrol it is never asked to.
657            let first = "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a'));";
658            let second = "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check;";
659            let third = "ALTER TABLE t ADD CONSTRAINT a_check CHECK (mode IN ('x'));";
660
661            let corpus = value_set_constraints_across([first, second, third]);
662            assert!(
663                corpus.contains("b_check"),
664                "the renamed original: {corpus:?}"
665            );
666            assert!(
667                corpus.contains("a_check"),
668                "the re-created constraint must be demanded — a rename that \
669                 happened before it existed does not apply to it: {corpus:?}"
670            );
671        }
672
673        #[test]
674        fn a_rename_applies_only_to_what_exists_when_it_runs_within_one_file() {
675            // Same rule inside a single file: statements apply in text order.
676            let sql = "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
677                       ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a'));";
678            let corpus = value_set_constraints_across([sql]);
679            assert!(corpus.contains("a_check"), "{corpus:?}");
680            assert!(
681                !corpus.contains("b_check"),
682                "nothing named b_check was ever a value-set CHECK: {corpus:?}"
683            );
684        }
685
686        #[test]
687        fn a_rename_inside_a_comment_is_not_a_rename() {
688            // A well-formed statement that happens to be commented out — the
689            // shape a migration author leaves behind when they reconsider —
690            // must mint nothing. The comment-stripping happens before parsing,
691            // per line, so the statement after the comment is still seen.
692            let sql = "-- ALTER TABLE t RENAME CONSTRAINT real_check TO phantom_check;\n\
693                       ALTER TABLE t ADD CONSTRAINT real_check CHECK (kind IN ('a'));";
694            assert!(
695                constraint_renames_in(sql).is_empty(),
696                "a commented-out rename was parsed: {:?}",
697                constraint_renames_in(sql)
698            );
699            let corpus = value_set_constraints_across([sql]);
700            assert_eq!(
701                corpus.into_iter().collect::<Vec<_>>(),
702                vec!["real_check".to_string()],
703                "the live statement after the comment is still parsed"
704            );
705        }
706
707        #[test]
708        fn a_commented_out_check_is_not_demanded() {
709            let sql = "-- ADD CONSTRAINT ck_dead CHECK (kind IN ('a'));\n\
710                       ADD CONSTRAINT ck_live CHECK (kind IN ('a'));";
711            let found = super::value_set_constraints_in(sql);
712            assert!(!found.contains("ck_dead"), "{found:?}");
713            assert!(found.contains("ck_live"), "{found:?}");
714        }
715
716        #[test]
717        fn a_non_rename_use_of_the_word_is_not_read_as_one() {
718            // `RENAME TO` on a table is not `RENAME CONSTRAINT`, and prose that
719            // merely says the word is not a statement.
720            let renames = constraint_renames_in(
721                "-- we RENAME CONSTRAINT names when a table moves\n\
722                 ALTER TABLE scchat.old_t RENAME TO new_t;",
723            );
724            assert!(
725                renames.iter().all(|(from, _)| from != "names"),
726                "a comment must not be parsed as a rename: {renames:?}"
727            );
728        }
729
730        /// The control for every guard built on this parser.
731        ///
732        /// Each input below is a shape a migration in this repo is or could be written
733        /// in. The old matcher found only the last one; the four before it are the
734        /// ones a PR would actually add, and every one of them was invisible.
735        #[test]
736        fn the_parser_sees_the_shapes_a_migration_is_actually_written_in() {
737            let must_find = [
738                (
739                    "hand-written ALTER, name and CHECK on separate lines, IN spelling",
740                    "ALTER TABLE scchat.t\n    ADD CONSTRAINT ck_target\n    CHECK (kind IN ('a', 'b'));",
741                ),
742                (
743                    "hand-written ALTER, separate lines, pg_dump spelling",
744                    "ALTER TABLE scchat.t\n    ADD CONSTRAINT ck_target\n    CHECK ((kind = ANY (ARRAY['a'::text])));",
745                ),
746                (
747                    "inline in CREATE TABLE, IN spelling",
748                    "CREATE TABLE scchat.t (\n  kind text,\n  CONSTRAINT ck_target CHECK (kind IN ('a', 'b'))\n);",
749                ),
750                (
751                    "cast between column and operator",
752                    "ADD CONSTRAINT ck_target CHECK (((kind)::text = ANY (ARRAY['a'::text])));",
753                ),
754                (
755                    "single-line pg_dump form (the only one the first matcher caught)",
756                    "ADD CONSTRAINT ck_target CHECK ((kind = ANY (ARRAY['a'::text, 'b'::text])));",
757                ),
758            ];
759            for (label, sql) in must_find {
760                assert!(
761                    super::value_set_constraints_in(sql).contains("ck_target"),
762                    "parser missed a real value-set CHECK — {label}\n  input: {sql}"
763                );
764            }
765
766            // The exclusions must stay exclusions, or the guard starts demanding an
767            // enum for constraints that can never have one.
768            let must_ignore = [
769                (
770                    "multi-clause pairing check (this repo has one)",
771                    "ADD CONSTRAINT ck_pairing CHECK ((plan = 'enterprise') = (monthly_message_limit IS NULL));",
772                ),
773                (
774                    "range check",
775                    "ADD CONSTRAINT ck_range CHECK ((retention_days > 0));",
776                ),
777                (
778                    "regex check",
779                    "ADD CONSTRAINT ck_regex CHECK ((ppnum ~ '^[0-9]{4}$'));",
780                ),
781                (
782                    "membership nested inside a compound predicate is not a value-set",
783                    "ADD CONSTRAINT ck_compound CHECK ((a IS NULL) OR (kind IN ('a', 'b')));",
784                ),
785                (
786                    "membership as the FIRST clause of a compound predicate — PAS has\
787                     exactly this shape in ck_signup_sessions_reservation_pair, and a\
788                     parser that only checks the start of the expression accepts it",
789                    "ADD CONSTRAINT ck_pair CHECK ((((state = ANY (ARRAY['a'::text])) AND\
790                     (r IS NOT NULL)) OR ((state = 'b'::text) AND (r IS NULL))));",
791                ),
792            ];
793            for (label, sql) in must_ignore {
794                assert!(
795                    super::value_set_constraints_in(sql).is_empty(),
796                    "parser claimed a non-value-set CHECK — {label}\n  input: {sql}"
797                );
798            }
799        }
800    }
801}