#![deny(rust_2018_idioms)]
#![warn(missing_debug_implementations)]
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub struct SchemaBinding {
pub constraint: &'static str,
pub allowed: BTreeSet<&'static str>,
}
pub trait SchemaConstrained: Sized + 'static {
const ALL: &'static [Self];
const CHECK_CONSTRAINTS: &'static [&'static str];
fn db_value(&self) -> &'static str;
fn bindings() -> Vec<SchemaBinding> {
let allowed: BTreeSet<&'static str> = Self::ALL.iter().map(Self::db_value).collect();
Self::CHECK_CONSTRAINTS
.iter()
.map(|&constraint| SchemaBinding {
constraint,
allowed: allowed.clone(),
})
.collect()
}
}
#[macro_export]
macro_rules! impl_schema_constrained {
(
$ty:ident via $method:ident {
all: [ $( $variant:ident ),+ $(,)? ],
$( non_stored: [ $( $ns:ident ),+ $(,)? ], )?
constraints: [ $( $constraint:literal ),+ $(,)? ] $(,)?
}
) => {
impl $crate::SchemaConstrained for $ty {
const ALL: &'static [Self] = &[ $( $ty::$variant ),+ ];
const CHECK_CONSTRAINTS: &'static [&'static str] = &[ $( $constraint ),+ ];
fn db_value(&self) -> &'static str {
self.$method()
}
}
const _: fn($ty) = |x| match x {
$( $ty::$variant => () ),+
$( , $( $ty::$ns => () ),+ )?
};
};
}
pub mod migration_scan {
use std::collections::BTreeSet;
pub fn value_set_constraints_in(sql: &str) -> BTreeSet<String> {
let flat = flatten(sql);
value_set_events(&flat)
.into_iter()
.map(|(_, name)| name)
.collect()
}
pub fn constraint_renames_in(sql: &str) -> Vec<(String, String)> {
let flat = flatten(sql);
rename_events(&flat)
.into_iter()
.map(|(_, rename)| rename)
.collect()
}
pub fn value_set_constraints_across<'a>(
sources: impl IntoIterator<Item = &'a str>,
) -> BTreeSet<String> {
let mut live = BTreeSet::new();
for sql in sources {
let flat = flatten(sql);
for event in events_in(&flat) {
match event {
Event::Add(name) => {
live.insert(name);
}
Event::Rename(from, to) => {
if live.remove(&from) {
live.insert(to);
}
}
}
}
}
live
}
enum Event {
Add(String),
Rename(String, String),
}
fn events_in(flat: &str) -> Vec<Event> {
let mut events: Vec<(usize, Event)> = value_set_events(flat)
.into_iter()
.map(|(at, name)| (at, Event::Add(name)))
.chain(
rename_events(flat)
.into_iter()
.map(|(at, (from, to))| (at, Event::Rename(from, to))),
)
.collect();
events.sort_by_key(|(at, _)| *at);
events.into_iter().map(|(_, event)| event).collect()
}
fn value_set_events(flat: &str) -> Vec<(usize, String)> {
let mut found = Vec::new();
for (at, _) in flat.match_indices("CONSTRAINT ") {
let rest = &flat[at + "CONSTRAINT ".len()..];
let Some((name, tail)) = rest.split_once(' ') else {
continue;
};
let Some(body) = tail.strip_prefix("CHECK ") else {
continue;
};
let Some(body) = balanced(body) else {
continue;
};
if is_pure_value_set(body) {
found.push((at, name.to_string()));
}
}
found
}
fn rename_events(flat: &str) -> Vec<(usize, (String, String))> {
let mut renames = Vec::new();
for (at, _) in flat.match_indices("RENAME CONSTRAINT ") {
let rest = &flat[at + "RENAME CONSTRAINT ".len()..];
let mut parts = rest.split_whitespace();
let (Some(from), Some(to_kw), Some(to)) = (parts.next(), parts.next(), parts.next())
else {
continue;
};
if !to_kw.eq_ignore_ascii_case("TO") {
continue;
}
renames.push((
at,
(
from.trim_end_matches(';').to_string(),
to.trim_end_matches(';').to_string(),
),
));
}
renames
}
fn flatten(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut in_space = false;
for line in sql.lines() {
for ch in strip_line_comment(line)
.chars()
.chain(std::iter::once('\n'))
{
if ch.is_whitespace() {
if !in_space {
out.push(' ');
}
in_space = true;
} else {
out.push(ch);
in_space = false;
}
}
}
out
}
fn strip_line_comment(line: &str) -> &str {
let mut in_quote = false;
let mut prev_dash = false;
for (i, ch) in line.char_indices() {
match ch {
'\'' => {
in_quote = !in_quote;
prev_dash = false;
}
'-' if !in_quote => {
if prev_dash {
return &line[..i - 1];
}
prev_dash = true;
}
_ => prev_dash = false,
}
}
line
}
fn balanced(s: &str) -> Option<&str> {
let s = s.strip_prefix('(')?;
let mut depth = 1usize;
for (i, ch) in s.char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(&s[..i]);
}
}
_ => {}
}
}
None
}
fn is_pure_value_set(expr: &str) -> bool {
let mut expr = expr.trim();
while let Some(inner) = balanced(expr) {
if inner.len() + 2 == expr.len() {
expr = inner.trim();
} else {
break;
}
}
let mut after = if expr.starts_with('(') {
match balanced(expr) {
Some(inner)
if !inner.is_empty()
&& inner.chars().all(|c| c.is_alphanumeric() || c == '_') =>
{
expr[inner.len() + 2..].trim_start()
}
_ => return false,
}
} else {
let column_len = expr
.find(|c: char| !(c.is_alphanumeric() || c == '_'))
.unwrap_or(expr.len());
if column_len == 0 {
return false;
}
expr[column_len..].trim_start()
};
loop {
if let Some(r) = after.strip_prefix(')') {
after = r.trim_start();
continue;
}
if let Some(r) = after.strip_prefix("::") {
after = r
.trim_start_matches(|ch: char| ch.is_alphanumeric() || ch == '_')
.trim_start();
continue;
}
break;
}
let list = if let Some(r) = after.strip_prefix("IN ") {
r.trim_start()
} else if let Some(r) = after.strip_prefix("= ANY ") {
r.trim_start()
} else {
return false;
};
match balanced(list) {
Some(inner) => list.len() == inner.len() + 2,
None => false,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::{constraint_renames_in, value_set_constraints_across};
#[test]
fn renames_parse_across_the_line_breaks_migrations_use() {
let sql = "ALTER TABLE scchat.t\n RENAME CONSTRAINT one_check\n TO two_check;";
assert_eq!(
constraint_renames_in(sql),
vec![("one_check".to_string(), "two_check".to_string())],
);
}
#[test]
fn a_rename_chain_lands_on_its_final_name() {
let corpus = value_set_constraints_across([
"ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a')); \
ALTER TABLE t ADD CONSTRAINT untouched_check CHECK (mode IN ('m'));",
"ALTER TABLE t RENAME CONSTRAINT a_check TO b_check;",
"ALTER TABLE t RENAME CONSTRAINT b_check TO c_check;",
]);
assert!(corpus.contains("c_check"), "{corpus:?}");
assert!(
!corpus.contains("a_check") && !corpus.contains("b_check"),
"neither retired name is demanded: {corpus:?}"
);
assert!(
corpus.contains("untouched_check"),
"a constraint nobody renamed keeps its name: {corpus:?}"
);
}
#[test]
fn a_cyclic_rename_ends_where_the_database_ends() {
let corpus = value_set_constraints_across([
"ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a')); \
ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
ALTER TABLE t RENAME CONSTRAINT b_check TO a_check;",
]);
assert_eq!(
corpus.into_iter().collect::<Vec<_>>(),
vec!["a_check".to_string()]
);
}
#[test]
fn a_corpus_reports_the_check_at_its_final_name_only() {
let created =
"ALTER TABLE scchat.t ADD CONSTRAINT old_check CHECK (kind IN ('a', 'b'));";
let renamed = "ALTER TABLE scchat.t RENAME CONSTRAINT old_check TO new_check;";
let one_file = value_set_constraints_across([created]);
assert!(
one_file.contains("old_check"),
"control: the scan finds it before any rename"
);
let corpus = value_set_constraints_across([created, renamed]);
assert!(
corpus.contains("new_check"),
"the final name is what the schema has"
);
assert!(
!corpus.contains("old_check"),
"the retired name must not be demanded"
);
}
#[test]
fn a_check_recreated_under_a_renamed_away_name_is_demanded_again() {
let first = "ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a'));";
let second = "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check;";
let third = "ALTER TABLE t ADD CONSTRAINT a_check CHECK (mode IN ('x'));";
let corpus = value_set_constraints_across([first, second, third]);
assert!(
corpus.contains("b_check"),
"the renamed original: {corpus:?}"
);
assert!(
corpus.contains("a_check"),
"the re-created constraint must be demanded — a rename that \
happened before it existed does not apply to it: {corpus:?}"
);
}
#[test]
fn a_rename_applies_only_to_what_exists_when_it_runs_within_one_file() {
let sql = "ALTER TABLE t RENAME CONSTRAINT a_check TO b_check; \
ALTER TABLE t ADD CONSTRAINT a_check CHECK (kind IN ('a'));";
let corpus = value_set_constraints_across([sql]);
assert!(corpus.contains("a_check"), "{corpus:?}");
assert!(
!corpus.contains("b_check"),
"nothing named b_check was ever a value-set CHECK: {corpus:?}"
);
}
#[test]
fn a_rename_inside_a_comment_is_not_a_rename() {
let sql = "-- ALTER TABLE t RENAME CONSTRAINT real_check TO phantom_check;\n\
ALTER TABLE t ADD CONSTRAINT real_check CHECK (kind IN ('a'));";
assert!(
constraint_renames_in(sql).is_empty(),
"a commented-out rename was parsed: {:?}",
constraint_renames_in(sql)
);
let corpus = value_set_constraints_across([sql]);
assert_eq!(
corpus.into_iter().collect::<Vec<_>>(),
vec!["real_check".to_string()],
"the live statement after the comment is still parsed"
);
}
#[test]
fn a_commented_out_check_is_not_demanded() {
let sql = "-- ADD CONSTRAINT ck_dead CHECK (kind IN ('a'));\n\
ADD CONSTRAINT ck_live CHECK (kind IN ('a'));";
let found = super::value_set_constraints_in(sql);
assert!(!found.contains("ck_dead"), "{found:?}");
assert!(found.contains("ck_live"), "{found:?}");
}
#[test]
fn a_non_rename_use_of_the_word_is_not_read_as_one() {
let renames = constraint_renames_in(
"-- we RENAME CONSTRAINT names when a table moves\n\
ALTER TABLE scchat.old_t RENAME TO new_t;",
);
assert!(
renames.iter().all(|(from, _)| from != "names"),
"a comment must not be parsed as a rename: {renames:?}"
);
}
#[test]
fn the_parser_sees_the_shapes_a_migration_is_actually_written_in() {
let must_find = [
(
"hand-written ALTER, name and CHECK on separate lines, IN spelling",
"ALTER TABLE scchat.t\n ADD CONSTRAINT ck_target\n CHECK (kind IN ('a', 'b'));",
),
(
"hand-written ALTER, separate lines, pg_dump spelling",
"ALTER TABLE scchat.t\n ADD CONSTRAINT ck_target\n CHECK ((kind = ANY (ARRAY['a'::text])));",
),
(
"inline in CREATE TABLE, IN spelling",
"CREATE TABLE scchat.t (\n kind text,\n CONSTRAINT ck_target CHECK (kind IN ('a', 'b'))\n);",
),
(
"cast between column and operator",
"ADD CONSTRAINT ck_target CHECK (((kind)::text = ANY (ARRAY['a'::text])));",
),
(
"single-line pg_dump form (the only one the first matcher caught)",
"ADD CONSTRAINT ck_target CHECK ((kind = ANY (ARRAY['a'::text, 'b'::text])));",
),
];
for (label, sql) in must_find {
assert!(
super::value_set_constraints_in(sql).contains("ck_target"),
"parser missed a real value-set CHECK — {label}\n input: {sql}"
);
}
let must_ignore = [
(
"multi-clause pairing check (this repo has one)",
"ADD CONSTRAINT ck_pairing CHECK ((plan = 'enterprise') = (monthly_message_limit IS NULL));",
),
(
"range check",
"ADD CONSTRAINT ck_range CHECK ((retention_days > 0));",
),
(
"regex check",
"ADD CONSTRAINT ck_regex CHECK ((ppnum ~ '^[0-9]{4}$'));",
),
(
"membership nested inside a compound predicate is not a value-set",
"ADD CONSTRAINT ck_compound CHECK ((a IS NULL) OR (kind IN ('a', 'b')));",
),
(
"membership as the FIRST clause of a compound predicate — PAS has\
exactly this shape in ck_signup_sessions_reservation_pair, and a\
parser that only checks the start of the expression accepts it",
"ADD CONSTRAINT ck_pair CHECK ((((state = ANY (ARRAY['a'::text])) AND\
(r IS NOT NULL)) OR ((state = 'b'::text) AND (r IS NULL))));",
),
];
for (label, sql) in must_ignore {
assert!(
super::value_set_constraints_in(sql).is_empty(),
"parser claimed a non-value-set CHECK — {label}\n input: {sql}"
);
}
}
}
}