use std::path::Path;
use crate::generate::emit::Plan;
pub const COMMENTS_TABLE: &str = "comments";
const MIGRATION_SUFFIX: &str = "_create_comments";
struct CommentsDdl {
pk: &'static str,
big_int: &'static str,
ts: &'static str,
ts_not_null_default_now: &'static str,
}
impl CommentsDdl {
const fn for_backend(backend: autumn_web::config::DatabaseBackend) -> Self {
match backend {
autumn_web::config::DatabaseBackend::Postgres => Self {
pk: "BIGSERIAL PRIMARY KEY",
big_int: "BIGINT",
ts: "TIMESTAMP",
ts_not_null_default_now: "TIMESTAMP NOT NULL DEFAULT NOW()",
},
autumn_web::config::DatabaseBackend::Sqlite => Self {
pk: "INTEGER PRIMARY KEY AUTOINCREMENT",
big_int: "INTEGER",
ts: "TEXT",
ts_not_null_default_now: "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
},
}
}
}
#[must_use]
pub fn up_sql(backend: autumn_web::config::DatabaseBackend) -> String {
let CommentsDdl {
pk,
big_int,
ts,
ts_not_null_default_now,
} = CommentsDdl::for_backend(backend);
format!(
"-- Threaded, polymorphic comments (issue #1367).\n\
--\n\
-- ONE table serves every `#[commentable]` model: `commentable_type` holds the\n\
-- model's name and `commentable_id` its primary key. `commentable_id` carries no\n\
-- REFERENCES because a single column cannot reference two tables -- the framework\n\
-- probes and row-locks the parent before every insert instead.\n\
--\n\
-- `author_id` has no REFERENCES either, for a different reason: the author model\n\
-- is named by `#[commentable(by = ...)]` and this migration does not know which\n\
-- table that is. Add `REFERENCES users(id)` (or your own author table) once it\n\
-- exists -- it is worth having.\n\
CREATE TABLE {COMMENTS_TABLE} (\n\
\x20 id {pk},\n\
\x20 commentable_type TEXT NOT NULL,\n\
\x20 commentable_id {big_int} NOT NULL,\n\
\x20 parent_id {big_int} REFERENCES {COMMENTS_TABLE}(id) ON DELETE CASCADE,\n\
\x20 author_id {big_int} NOT NULL,\n\
\x20 body TEXT NOT NULL,\n\
\x20 created_at {ts_not_null_default_now},\n\
\x20 deleted_at {ts}\n\
);\n\
\n\
-- Covers the thread read whole: its WHERE is the discriminator pair and its\n\
-- ORDER BY is (created_at, id), so one index serves both halves.\n\
CREATE INDEX IF NOT EXISTS idx_{COMMENTS_TABLE}_thread\n\
\x20 ON {COMMENTS_TABLE} (commentable_type, commentable_id, created_at, id);\n\
\n\
-- The delete cascade walks children by parent_id.\n\
CREATE INDEX IF NOT EXISTS idx_{COMMENTS_TABLE}_parent_id\n\
\x20 ON {COMMENTS_TABLE} (parent_id);\n"
)
}
#[must_use]
pub fn down_sql() -> String {
format!("DROP TABLE IF EXISTS {COMMENTS_TABLE};\n")
}
#[must_use]
pub fn migration_dir_name(timestamp: &str) -> String {
format!("{timestamp}2{MIGRATION_SUFFIX}")
}
#[must_use]
pub fn already_migrated(project_root: &Path) -> bool {
let (exists, polymorphic) = comments_table_state(project_root);
exists && polymorphic
}
pub fn conflicting_comments_table(project_root: &Path) -> bool {
let (exists, polymorphic) = comments_table_state(project_root);
exists && !polymorphic
}
fn comments_table_state(project_root: &Path) -> (bool, bool) {
let mut creates = false;
let mut present: Vec<&'static str> = Vec::new();
for sql in migration_up_sql(project_root) {
for event in comments_table_events(&sql) {
match event {
CommentsEvent::Create(columns) => {
creates = true;
present = columns;
}
CommentsEvent::Add(column) => {
if !present.contains(&column) {
present.push(column);
}
}
CommentsEvent::Remove(column) => present.retain(|held| *held != column),
CommentsEvent::Drop => {
creates = false;
present.clear();
}
}
}
}
let complete = REQUIRED_COLUMNS
.iter()
.all(|column| present.contains(column));
(creates, complete)
}
const CREATE_VERBS: &[&str] = &["create table", "create unlogged table"];
fn comments_drops(lowered: &str) -> Vec<usize> {
let names = comments_table_names();
let mut found = Vec::new();
for (at, _) in lowered.match_indices("drop table") {
let rest = lowered[at + "drop table".len()..].trim_start();
let rest = rest.strip_prefix("if exists").unwrap_or(rest).trim_start();
let statement = rest.split(';').next().unwrap_or(rest);
let drops_comments = statement.split(',').any(|entry| {
let entry = entry.trim();
let entry = entry.strip_prefix("only ").unwrap_or(entry).trim_start();
entry
.split_whitespace()
.next()
.is_some_and(|name| names.iter().any(|accepted| accepted == name))
});
if drops_comments {
found.push(at);
}
}
found
}
fn comments_table_names() -> Vec<String> {
vec![
COMMENTS_TABLE.to_owned(),
format!("\"{COMMENTS_TABLE}\""),
format!("public.{COMMENTS_TABLE}"),
format!("public.\"{COMMENTS_TABLE}\""),
format!("\"public\".{COMMENTS_TABLE}"),
format!("\"public\".\"{COMMENTS_TABLE}\""),
]
}
fn comments_table_spellings(verb: &str) -> Vec<String> {
let names = comments_table_names();
let existence = [
"",
"if exists ",
"if not exists ",
"only ",
"if exists only ",
"if not exists only ",
];
let mut spellings = Vec::with_capacity(names.len() * existence.len());
for exists in existence {
for name in &names {
spellings.push(format!("{verb} {exists}{name}"));
}
}
spellings
}
fn mentions_column(haystack: &str, column: &str) -> bool {
let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut base = 0usize;
while let Some(at) = haystack[base..].find(column) {
let start = base + at;
let end = start + column.len();
let before_ok = start == 0 || !haystack[..start].ends_with(is_ident);
let after_ok = !haystack[end..].starts_with(is_ident);
if before_ok && after_ok {
return true;
}
base = end;
}
false
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CommentsEvent {
Create(Vec<&'static str>),
Add(&'static str),
Remove(&'static str),
Drop,
}
const REQUIRED_COLUMNS: &[&str] = &[
"id",
"commentable_type",
"commentable_id",
"parent_id",
"author_id",
"body",
"created_at",
"deleted_at",
];
fn alter_removes_column(statement: &str, column: &str) -> bool {
for (at, _) in statement.match_indices(" drop ") {
let rest = statement[at + " drop ".len()..].trim_start();
if [
"constraint ",
"default",
"not null",
"expression",
"identity",
]
.iter()
.any(|kw| rest.starts_with(kw))
{
continue;
}
let name = rest.strip_prefix("column ").unwrap_or(rest).trim_start();
let name = name.strip_prefix("if exists ").unwrap_or(name).trim_start();
let name = name.split([' ', ',', ';']).next().unwrap_or("");
if mentions_column(name, column) {
return true;
}
}
if let Some(at) = statement.find(" rename ") {
let rest = statement[at + " rename ".len()..].trim_start();
if !rest.starts_with("to ") && !rest.starts_with("constraint ") {
let (from, to) = rest.split_once(" to ").unwrap_or((rest, ""));
return mentions_column(from, column) && !mentions_column(to, column);
}
}
false
}
fn comments_table_events(sql: &str) -> Vec<CommentsEvent> {
let mut events: Vec<(usize, CommentsEvent)> = Vec::new();
for (start, body) in comments_creations(sql) {
events.push((
start,
CommentsEvent::Create(
REQUIRED_COLUMNS
.iter()
.copied()
.filter(|column| mentions_column(body, column))
.collect(),
),
));
}
for (at, statement) in comments_statements(sql, "alter table") {
for column in REQUIRED_COLUMNS.iter().copied() {
if !mentions_column(statement, column) {
continue;
}
if alter_removes_column(statement, column) {
events.push((at, CommentsEvent::Remove(column)));
} else {
events.push((at, CommentsEvent::Add(column)));
}
}
}
for at in comments_drops(sql) {
events.push((at, CommentsEvent::Drop));
}
for (at, statement) in comments_statements(sql, "alter table") {
let Some(rest) = statement.split(" rename to ").nth(1) else {
continue;
};
if statement.contains("rename column") {
continue;
}
let new_name = rest
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(';');
if !is_comments_table_name(new_name) {
events.push((at, CommentsEvent::Drop));
}
}
for (at, new_name_at) in rename_targets(sql) {
if is_comments_table_name(new_name_at) {
events.push((at, CommentsEvent::Create(Vec::new())));
}
}
events.sort_by_key(|(at, _)| *at);
events.into_iter().map(|(_, event)| event).collect()
}
fn comments_creations(sql: &str) -> Vec<(usize, &str)> {
let mut found = Vec::new();
let mut base = 0usize;
while let Some(start) = comments_table_statement_start(&sql[base..]) {
let absolute = base + start;
let Some(body) = comments_table_body(&sql[absolute..]) else {
break;
};
found.push((absolute, body));
match sql[absolute..].find('(') {
Some(open) => base = absolute + open + 1,
None => break,
}
}
found
}
fn cleanup_function_sql() -> String {
format!(
"-- Deleting a parent must take its comments with it. TG_ARGV[0] is the\n\
-- parent's `commentable_type`, so ONE function serves every model.\n\
--\n\
-- CREATE OR REPLACE, and emitted by every commentable parent's own\n\
-- migration: the shared table's migration sorts AFTER a parent created\n\
-- in the same second, so a trigger relying on it there would fail.\n\
CREATE OR REPLACE FUNCTION {COMMENTS_TABLE}_delete_for_parent()\n\
RETURNS TRIGGER AS $$\n\
BEGIN\n\
\x20 DELETE FROM {COMMENTS_TABLE}\n\
\x20 WHERE commentable_type = TG_ARGV[0]\n\
\x20 AND commentable_id = OLD.id;\n\
\x20 RETURN OLD;\n\
END;\n\
$$ LANGUAGE plpgsql;\n"
)
}
#[must_use]
pub fn parent_cleanup_sql(
backend: autumn_web::config::DatabaseBackend,
parent_table: &str,
commentable_type: &str,
) -> String {
match backend {
autumn_web::config::DatabaseBackend::Postgres => format!(
"\n\
{function}\n\
-- `{commentable_type}`'s comments go when the row does. The polymorphic key\n\
-- cannot be a foreign key, so this trigger is the cascade.\n\
--\n\
-- KEEP IN SYNC WITH `#[commentable]` ON THIS MODEL.\n\
-- The discriminator below is this trigger's ONLY link to the comments;\n\
-- nothing checks it at run time. Overriding `type_name`, `table` or the\n\
-- discriminator columns on the model without editing this trigger leaves it\n\
-- deleting rows that no longer exist, so a deleted parent keeps its thread —\n\
-- silently, and visibly again if the id is ever reused.\n\
CREATE TRIGGER {parent_table}_delete_{COMMENTS_TABLE}\n\
\x20 AFTER DELETE ON {parent_table}\n\
\x20 FOR EACH ROW\n\
\x20 EXECUTE FUNCTION {COMMENTS_TABLE}_delete_for_parent('{commentable_type}');\n",
function = cleanup_function_sql(),
),
autumn_web::config::DatabaseBackend::Sqlite => format!(
"\n\
-- `{commentable_type}`'s comments go when the row does. SQLite triggers take\n\
-- no arguments, so the discriminator is inlined here rather than shared.\n\
--\n\
-- KEEP IN SYNC WITH `#[commentable]` ON THIS MODEL.\n\
-- The discriminator below is this trigger's ONLY link to the comments;\n\
-- nothing checks it at run time. Overriding `type_name`, `table` or the\n\
-- discriminator columns on the model without editing this trigger leaves it\n\
-- deleting rows that no longer exist, so a deleted parent keeps its thread —\n\
-- silently, and visibly again if the id is ever reused.\n\
CREATE TRIGGER IF NOT EXISTS {parent_table}_delete_{COMMENTS_TABLE}\n\
\x20 AFTER DELETE ON {parent_table}\n\
\x20 FOR EACH ROW\n\
BEGIN\n\
\x20 DELETE FROM {COMMENTS_TABLE}\n\
\x20 WHERE commentable_type = '{commentable_type}'\n\
\x20 AND commentable_id = OLD.id;\n\
END;\n"
),
}
}
#[must_use]
pub fn parent_cleanup_down_sql(
backend: autumn_web::config::DatabaseBackend,
parent_table: &str,
) -> String {
match backend {
autumn_web::config::DatabaseBackend::Postgres => format!(
"DROP TRIGGER IF EXISTS {parent_table}_delete_{COMMENTS_TABLE} ON {parent_table};\n"
),
autumn_web::config::DatabaseBackend::Sqlite => {
format!("DROP TRIGGER IF EXISTS {parent_table}_delete_{COMMENTS_TABLE};\n")
}
}
}
fn is_comments_table_name(name: &str) -> bool {
let trimmed = name.trim();
[
COMMENTS_TABLE.to_owned(),
format!("\"{COMMENTS_TABLE}\""),
format!("public.{COMMENTS_TABLE}"),
format!("public.\"{COMMENTS_TABLE}\""),
format!("\"public\".{COMMENTS_TABLE}"),
format!("\"public\".\"{COMMENTS_TABLE}\""),
]
.iter()
.any(|accepted| accepted == trimmed)
}
fn rename_targets(lowered: &str) -> Vec<(usize, &str)> {
let mut found = Vec::new();
let mut base = 0usize;
while let Some(at) = lowered[base..].find(" rename to ") {
let start = base + at;
let rest = &lowered[start + " rename to ".len()..];
let statement_start = lowered[..start].rfind(';').map_or(0, |semi| semi + 1);
if !lowered[statement_start..start].contains("rename column") {
let name = rest
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(';');
found.push((start, name));
}
base = start + " rename to ".len();
}
found
}
fn comments_statements<'a>(lowered: &'a str, verb: &str) -> Vec<(usize, &'a str)> {
let mut found = Vec::new();
for prefix in comments_table_spellings(verb) {
let mut base = 0usize;
while let Some(at) = lowered[base..].find(&prefix) {
let start = base + at;
let after = &lowered[start + prefix.len()..];
if prefix.ends_with('"')
|| after.is_empty()
|| after.starts_with(|c: char| c.is_whitespace() || c == ';')
{
found.push((start, after.split(';').next().unwrap_or(after)));
}
base = start + prefix.len();
}
}
found
}
fn migration_up_sql(project_root: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(project_root.join("migrations")) else {
return Vec::new();
};
let mut dirs: Vec<std::path::PathBuf> = entries
.filter_map(Result::ok)
.map(|entry| entry.path())
.collect();
dirs.sort();
dirs.into_iter()
.filter_map(|dir| std::fs::read_to_string(dir.join("up.sql")).ok())
.map(|sql| strip_sql_comments(&sql))
.collect()
}
fn is_escape_string_prefix(sql: &str, quote: usize) -> bool {
let bytes = sql.as_bytes();
let Some(prev) = quote.checked_sub(1) else {
return false;
};
if !matches!(bytes[prev], b'E' | b'e') {
return false;
}
prev.checked_sub(1)
.map(|before| bytes[before])
.is_none_or(|b| !(b.is_ascii_alphanumeric() || b == b'_'))
}
fn dollar_tag_len(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
if bytes.first() != Some(&b'$') {
return None;
}
let mut i = 1usize;
while i < bytes.len() {
match bytes[i] {
b'$' => return Some(i + 1),
b'_' | b'a'..=b'z' | b'A'..=b'Z' => i += 1,
b'0'..=b'9' if i > 1 => i += 1,
_ => return None,
}
}
None
}
fn strip_sql_comments(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let bytes = sql.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\'' => {
let escapes = is_escape_string_prefix(sql, i);
out.push('\'');
i += 1;
while i < bytes.len() {
if escapes && bytes[i] == b'\\' && i + 1 < bytes.len() {
let ch = sql[i + 1..].chars().next().unwrap_or(' ');
for _ in 0..=ch.len_utf8() {
out.push(' ');
}
i += 1 + ch.len_utf8();
continue;
}
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
out.push_str(" ");
i += 2;
continue;
}
out.push('\'');
i += 1;
break;
}
let ch = sql[i..].chars().next().unwrap_or(' ');
for _ in 0..ch.len_utf8() {
out.push(' ');
}
i += ch.len_utf8();
}
}
b'$' => {
if let Some(tag_len) = dollar_tag_len(&sql[i..]) {
let tag = &sql[i..i + tag_len];
out.push_str(tag);
i += tag_len;
if let Some(rel) = sql[i..].find(tag) {
for _ in 0..rel {
out.push(' ');
}
out.push_str(tag);
i += rel + tag_len;
} else {
for _ in i..bytes.len() {
out.push(' ');
}
i = bytes.len();
}
} else {
out.push('$');
i += 1;
}
}
b'"' => {
out.push('"');
i += 1;
while i < bytes.len() {
if bytes[i] == b'"' {
if bytes.get(i + 1) == Some(&b'"') {
out.push_str(&sql[i..=i + 1]);
i += 2;
continue;
}
out.push('"');
i += 1;
break;
}
let ch = sql[i..].chars().next().unwrap_or('"');
out.push(ch);
i += ch.len_utf8();
}
}
b'-' if bytes.get(i + 1) == Some(&b'-') => {
let end = sql[i..].find('\n').map_or(bytes.len(), |nl| i + nl);
for _ in i..end {
out.push(' ');
}
i = end;
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
let end = sql[i + 2..]
.find("*/")
.map_or(bytes.len(), |e| i + 2 + e + 2);
for _ in i..end {
out.push(' ');
}
i = end;
}
_ => {
let ch = sql[i..].chars().next().unwrap_or(' ');
out.push(ch.to_ascii_lowercase());
i += ch.len_utf8();
}
}
}
out
}
fn comments_table_body(lowered: &str) -> Option<&str> {
let start = comments_table_statement_start(lowered)?;
let open = lowered[start..].find('(')? + start;
let mut depth = 0usize;
for (offset, ch) in lowered[open..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(&lowered[open + 1..open + offset]);
}
}
_ => {}
}
}
Some(&lowered[open + 1..])
}
fn comments_table_statement_start(lowered: &str) -> Option<usize> {
let mut best: Option<usize> = None;
for prefix in CREATE_VERBS
.iter()
.flat_map(|verb| comments_table_spellings(verb))
{
let mut base = 0usize;
while let Some(at) = lowered[base..].find(&prefix) {
let start = base + at;
let after = &lowered[start + prefix.len()..];
if prefix.ends_with('"')
|| after.is_empty()
|| after.starts_with(|c: char| c.is_whitespace() || c == '(' || c == ';')
{
best = Some(best.map_or(start, |b: usize| b.min(start)));
break;
}
base = start + prefix.len();
}
}
best
}
pub fn push_commentable_migration(
plan: &mut Plan,
project_root: &Path,
timestamp: &str,
backend: autumn_web::config::DatabaseBackend,
for_revert: bool,
) -> bool {
if !for_revert && already_migrated(project_root) {
return false;
}
if for_revert && !generator_owned_comments_migration(project_root, backend) {
return false;
}
let dir = project_root
.join("migrations")
.join(migration_dir_name(timestamp));
plan.create(dir.join("up.sql"), up_sql(backend));
plan.create(dir.join("down.sql"), down_sql());
true
}
fn commentable_declared_below(dir: &Path, destroying_path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if path.is_dir() {
if commentable_declared_below(&path, destroying_path) {
return true;
}
continue;
}
if path == destroying_path {
continue;
}
if path.extension().and_then(|ext| ext.to_str()) != Some("rs") {
continue;
}
if std::fs::read_to_string(&path).is_ok_and(|src| src.contains("#[commentable")) {
return true;
}
}
false
}
#[must_use]
fn generator_owned_comments_migration(
project_root: &Path,
backend: autumn_web::config::DatabaseBackend,
) -> bool {
let ours = up_sql(backend);
let Ok(entries) = std::fs::read_dir(project_root.join("migrations")) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
std::fs::read_to_string(entry.path().join("up.sql")).is_ok_and(|sql| sql == ours)
})
}
#[must_use]
pub fn another_model_is_still_commentable(project_root: &Path, destroying_model: &str) -> bool {
let models_dir = project_root.join("src").join("models");
let destroying_file = format!("{destroying_model}.rs");
if commentable_declared_below(&models_dir, &models_dir.join(&destroying_file)) {
return true;
}
std::fs::read_to_string(project_root.join("src").join("models.rs"))
.is_ok_and(|src| src.matches("#[commentable").count() > 1)
}
#[must_use]
pub fn model_declares_commentable(project_root: &Path, snake_name: &str) -> bool {
let src = project_root.join("src");
if let Ok(per_file) =
std::fs::read_to_string(src.join("models").join(format!("{snake_name}.rs")))
{
return per_file.contains("#[commentable");
}
false
}
#[must_use]
pub fn detect_author_model(project_root: &Path) -> Option<&'static str> {
let src = project_root.join("src");
if src.join("models").join("user.rs").is_file() {
return Some("User");
}
let single_file = std::fs::read_to_string(src.join("models.rs")).ok()?;
single_file.contains("pub struct User ").then_some("User")
}
#[cfg(test)]
mod tests {
use super::*;
use autumn_web::config::DatabaseBackend;
#[test]
fn up_sql_declares_the_polymorphic_key_and_the_threading_column() {
let sql = up_sql(DatabaseBackend::Postgres);
assert!(sql.contains("CREATE TABLE comments"));
assert!(
!sql.contains("CREATE TABLE IF NOT EXISTS"),
"a colliding table is a conflict to resolve, not a silent no-op"
);
assert!(sql.contains("commentable_type TEXT NOT NULL"));
assert!(sql.contains("commentable_id BIGINT NOT NULL"));
assert!(sql.contains("parent_id BIGINT REFERENCES comments(id) ON DELETE CASCADE"));
assert!(sql.contains("deleted_at TIMESTAMP"));
assert!(sql.contains("(commentable_type, commentable_id, created_at, id)"));
assert!(!sql.contains("commentable_id BIGINT NOT NULL REFERENCES"));
}
#[test]
fn up_sql_is_spelled_for_sqlite_too() {
let sql = up_sql(DatabaseBackend::Sqlite);
assert!(!sql.contains("BIGSERIAL"), "{sql}");
assert!(!sql.contains("NOW()"), "{sql}");
assert!(sql.contains("INTEGER PRIMARY KEY AUTOINCREMENT"), "{sql}");
assert!(sql.contains("DEFAULT CURRENT_TIMESTAMP"), "{sql}");
assert!(sql.contains("commentable_type TEXT NOT NULL"), "{sql}");
assert!(sql.contains("commentable_id INTEGER NOT NULL"), "{sql}");
assert!(
sql.contains("parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE"),
"{sql}"
);
}
#[test]
fn down_sql_is_idempotent() {
assert!(down_sql().contains("DROP TABLE IF EXISTS comments"));
}
#[test]
fn migration_version_sorts_between_this_second_and_the_next() {
let this = "20260621000000";
let next = "20260621000001";
let ours = migration_dir_name(this);
assert!(ours.starts_with(this));
assert!(this < ours.as_str());
assert!(format!("{this}1_add_comment_count_to_posts").as_str() < ours.as_str());
assert!(ours.as_str() < next);
}
#[test]
fn detect_author_model_finds_both_model_layouts() {
let tmp = tempfile::tempdir().expect("tempdir");
assert_eq!(detect_author_model(tmp.path()), None);
let per_file = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(per_file.path().join("src/models")).expect("mkdir");
std::fs::write(per_file.path().join("src/models/user.rs"), "").expect("write");
assert_eq!(detect_author_model(per_file.path()), Some("User"));
let single = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(single.path().join("src")).expect("mkdir");
std::fs::write(
single.path().join("src/models.rs"),
"#[autumn_web::model]\npub struct User {}\n",
)
.expect("write");
assert_eq!(detect_author_model(single.path()), Some("User"));
}
#[test]
fn already_migrated_finds_a_renamed_directory() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_comments_v2");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), up_sql(DatabaseBackend::Postgres)).expect("write");
assert!(already_migrated(tmp.path()));
let empty = tempfile::tempdir().expect("tempdir");
assert!(!already_migrated(empty.path()));
}
#[test]
fn a_scaffolded_comment_model_does_not_look_like_the_shared_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp
.path()
.join("migrations")
.join("20260820000000_create_comments");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (\n id BIGSERIAL PRIMARY KEY,\n \
body TEXT NOT NULL,\n post_id BIGINT NOT NULL,\n \
parent_id BIGINT,\n author_id BIGINT,\n \
created_at TIMESTAMP,\n deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"a post_id-keyed comments table is not the polymorphic one"
);
}
#[test]
fn an_unrelated_idempotent_comments_table_is_not_the_shared_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_create_comments");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE IF NOT EXISTS comments (\n id BIGSERIAL PRIMARY KEY,\n \
body TEXT NOT NULL,\n post_id BIGINT NOT NULL\n);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"IF NOT EXISTS does not make an unrelated table polymorphic"
);
}
#[test]
fn a_comments_prefixed_table_is_not_the_shared_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_archive");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments_archive (\n id BIGSERIAL PRIMARY KEY,\n \
commentable_type TEXT NOT NULL,\n commentable_id BIGINT NOT NULL,\n id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"`comments_archive` is not `comments`"
);
for sql in [
"CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
"CREATE TABLE \"comments\" (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
"CREATE TABLE IF NOT EXISTS comments(commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
] {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_real");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), sql).expect("write");
assert!(already_migrated(tmp.path()), "{sql}");
}
}
#[test]
fn dropping_the_comments_table_is_not_creating_it() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_retire");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"DROP TABLE comments;\nCREATE TABLE comments_archive (\n \
commentable_type TEXT NOT NULL,\n commentable_id BIGINT NOT NULL,\n id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"a dropped table is not a reusable one"
);
}
#[test]
fn a_commented_out_create_table_is_not_the_shared_one() {
for sql in [
"-- CREATE TABLE comments (\n-- commentable_type TEXT NOT NULL,\n\
-- commentable_id BIGINT NOT NULL\n-- );\nCREATE TABLE notes (id BIGSERIAL);\n",
"/* CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP); */\n\
CREATE TABLE notes (id BIGSERIAL);\n",
] {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_notes");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), sql).expect("write");
assert!(
!already_migrated(tmp.path()),
"a commented-out CREATE is not the shared table:\n{sql}"
);
}
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_real");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), up_sql(DatabaseBackend::Postgres)).expect("write");
assert!(already_migrated(tmp.path()));
}
#[test]
fn discriminator_columns_on_another_table_do_not_count() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_mixed");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (\n id BIGSERIAL PRIMARY KEY,\n \
body TEXT NOT NULL,\n post_id BIGINT NOT NULL\n);\n\
CREATE TABLE audit_log (\n id BIGSERIAL PRIMARY KEY,\n \
commentable_type TEXT NOT NULL,\n commentable_id BIGINT NOT NULL,\n id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the discriminator columns belong to audit_log, not to comments"
);
}
#[test]
fn a_nested_paren_in_the_column_list_does_not_truncate_the_body() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_nested");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (\n id BIGSERIAL PRIMARY KEY,\n \
score NUMERIC(10, 2) NOT NULL DEFAULT 0,\n \
commentable_type TEXT NOT NULL,\n commentable_id BIGINT NOT NULL,\n id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"NUMERIC(10, 2) must not end the column list early"
);
}
#[test]
fn a_table_made_polymorphic_by_a_later_migration_counts() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("20260419000000_create_app");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(
created.join("up.sql"),
"CREATE TABLE comments (\n id BIGSERIAL PRIMARY KEY,\n \
body TEXT NOT NULL,\n post_id BIGINT NOT NULL,\n \
parent_id BIGINT,\n author_id BIGINT,\n \
created_at TIMESTAMP,\n deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()));
let converted = migrations.join("20260820000000_polymorphic_comments");
std::fs::create_dir_all(&converted).expect("mkdir");
std::fs::write(
converted.join("up.sql"),
"ALTER TABLE comments ADD COLUMN commentable_type TEXT;\n\
ALTER TABLE comments ADD COLUMN commentable_id BIGINT;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"a comments table converted by a later ALTER is still the shared table"
);
}
#[test]
fn altering_another_table_does_not_make_comments_polymorphic() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let dir = migrations.join("0001_app");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, parent_id BIGINT, author_id BIGINT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE comments_archive ADD COLUMN commentable_type TEXT;\n\
ALTER TABLE comments_archive ADD COLUMN commentable_id BIGINT;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"comments_archive is not comments"
);
}
#[test]
fn discriminator_columns_altered_in_separate_migrations_still_count() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
for (dir, sql) in [
(
"0001_create",
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, parent_id BIGINT, author_id BIGINT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
),
(
"0002_add_type",
"ALTER TABLE comments ADD COLUMN commentable_type TEXT;\n",
),
] {
let path = migrations.join(dir);
std::fs::create_dir_all(&path).expect("mkdir");
std::fs::write(path.join("up.sql"), sql).expect("write");
}
assert!(!already_migrated(tmp.path()));
let path = migrations.join("0003_add_id");
std::fs::create_dir_all(&path).expect("mkdir");
std::fs::write(
path.join("up.sql"),
"ALTER TABLE comments ADD COLUMN commentable_id BIGINT;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"the columns may arrive in separate migrations"
);
}
#[test]
fn a_later_drop_undoes_an_earlier_polymorphic_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("20260101000000_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(created.join("up.sql"), up_sql(DatabaseBackend::Postgres)).expect("write");
assert!(
already_migrated(tmp.path()),
"the table exists at this point"
);
let dropped = migrations.join("20260202000000_retire");
std::fs::create_dir_all(&dropped).expect("mkdir");
std::fs::write(dropped.join("up.sql"), "DROP TABLE comments;\n").expect("write");
assert!(
!already_migrated(tmp.path()),
"a dropped table is not available to reuse"
);
let again = migrations.join("20260303000000_recreate");
std::fs::create_dir_all(&again).expect("mkdir");
std::fs::write(again.join("up.sql"), up_sql(DatabaseBackend::Postgres)).expect("write");
assert!(already_migrated(tmp.path()));
}
#[test]
fn a_drop_after_a_create_in_one_file_leaves_no_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_churn");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
DROP TABLE comments;\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()));
std::fs::write(
dir.join("up.sql"),
"DROP TABLE IF EXISTS comments;\n\
CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(already_migrated(tmp.path()));
}
#[test]
fn similarly_named_columns_do_not_look_like_the_discriminator() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_legacy");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (\n id BIGSERIAL PRIMARY KEY,\n \
legacy_commentable_type TEXT NOT NULL,\n \
legacy_commentable_id BIGINT NOT NULL,\n parent_id BIGINT,\n author_id BIGINT,\n body TEXT,\n created_at TIMESTAMP,\n deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"legacy_* columns are not the discriminator pair"
);
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (\n commentable_type_old TEXT,\n \
commentable_id_old BIGINT\n);\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()));
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (\n \"commentable_type\" TEXT NOT NULL,\n \
id BIGINT,\n commentable_id BIGINT NOT NULL,\n parent_id BIGINT,\n author_id BIGINT,\n body TEXT,\n created_at TIMESTAMP,\n deleted_at TIMESTAMP\n);\n",
)
.expect("write");
assert!(already_migrated(tmp.path()));
}
#[test]
fn dropping_or_renaming_a_discriminator_column_undoes_it() {
let base = "CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for removal in [
"ALTER TABLE comments DROP COLUMN commentable_type;\n",
"ALTER TABLE comments RENAME COLUMN commentable_type TO legacy_kind;\n",
] {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("0001_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(created.join("up.sql"), base).expect("write");
assert!(already_migrated(tmp.path()));
let changed = migrations.join("0002_change");
std::fs::create_dir_all(&changed).expect("mkdir");
std::fs::write(changed.join("up.sql"), removal).expect("write");
assert!(
!already_migrated(tmp.path()),
"the table is no longer polymorphic after:\n{removal}"
);
}
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("0001_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(
created.join("up.sql"),
"CREATE TABLE comments (kind TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()));
let renamed = migrations.join("0002_rename");
std::fs::create_dir_all(&renamed).expect("mkdir");
std::fs::write(
renamed.join("up.sql"),
"ALTER TABLE comments RENAME COLUMN kind TO commentable_type;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"a rename INTO the discriminator name adds it"
);
}
#[test]
fn a_schema_qualified_comments_table_is_recognised() {
for create in [
"CREATE TABLE public.comments",
"CREATE TABLE \"public\".\"comments\"",
"CREATE TABLE public.\"comments\"",
"CREATE TABLE IF NOT EXISTS public.comments",
] {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_qualified");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
format!(
"{create} (\n id BIGSERIAL PRIMARY KEY,\n \
commentable_type TEXT NOT NULL,\n \
commentable_id BIGINT NOT NULL,\n parent_id BIGINT,\n author_id BIGINT,\n body TEXT,\n created_at TIMESTAMP,\n deleted_at TIMESTAMP\n);\n"
),
)
.expect("write");
assert!(already_migrated(tmp.path()), "{create}");
}
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("0001_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(
created.join("up.sql"),
"CREATE TABLE public.comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
let dropped = migrations.join("0002_drop");
std::fs::create_dir_all(&dropped).expect("mkdir");
std::fs::write(dropped.join("up.sql"), "DROP TABLE public.comments;\n").expect("write");
assert!(!already_migrated(tmp.path()));
let other = tempfile::tempdir().expect("tempdir");
let dir = other.path().join("migrations").join("0001_other");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE archive.comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
!already_migrated(other.path()),
"archive.comments is not public.comments"
);
}
#[test]
fn a_comment_marker_inside_a_literal_is_not_a_comment() {
for sql in [
"CREATE TABLE comments (note TEXT DEFAULT '--', \
commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
"CREATE TABLE comments (note TEXT DEFAULT '/* x */', \
commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
"CREATE TABLE comments (note TEXT DEFAULT 'it''s -- fine', \
commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
"CREATE TABLE comments (\"od--d\" TEXT, commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);",
] {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_literal");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), sql).expect("write");
assert!(already_migrated(tmp.path()), "{sql}");
}
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_commented");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
-- commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()));
}
#[test]
fn a_commentable_model_in_a_nested_module_still_counts() {
let tmp = tempfile::tempdir().expect("tempdir");
let nested = tmp.path().join("src").join("models").join("admin");
std::fs::create_dir_all(&nested).expect("mkdir");
std::fs::write(
tmp.path().join("src/models/post.rs"),
"#[autumn_web::model]\n#[commentable(by = User)]\npub struct Post {}\n",
)
.expect("write");
std::fs::write(
nested.join("announcement.rs"),
"#[autumn_web::model]\n#[commentable(by = User)]\npub struct Announcement {}\n",
)
.expect("write");
assert!(
another_model_is_still_commentable(tmp.path(), "post"),
"the nested Announcement still needs the shared table"
);
std::fs::remove_file(nested.join("announcement.rs")).expect("rm");
assert!(!another_model_is_still_commentable(tmp.path(), "post"));
std::fs::write(
nested.join("post.rs"),
"#[autumn_web::model]\n#[commentable(by = User)]\npub struct Post {}\n",
)
.expect("write");
assert!(
another_model_is_still_commentable(tmp.path(), "post"),
"`admin/post.rs` is a different file from `models/post.rs`, so destroying \
the flat one must not take the shared migration with it"
);
}
#[test]
fn sql_string_literals_are_not_read_as_ddl() {
let real = "CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_paren");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (note TEXT DEFAULT ')', \
commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"a quoted ')' must not end the column list"
);
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("0001_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(created.join("up.sql"), real).expect("write");
let logged = migrations.join("0002_log");
std::fs::create_dir_all(&logged).expect("mkdir");
std::fs::write(
logged.join("up.sql"),
"INSERT INTO audit_log(message) VALUES ('DROP TABLE comments;');\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"a DROP quoted inside a value is not a DROP"
);
std::fs::write(logged.join("up.sql"), "DROP TABLE comments;\n").expect("write");
assert!(!already_migrated(tmp.path()));
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_quoted");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE \"comments\" (\"commentable_type\" TEXT, \"commentable_id\" BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(already_migrated(tmp.path()), "quoted identifiers are names");
}
#[test]
fn renaming_the_comments_table_moves_the_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("0001_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(
created.join("up.sql"),
"CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(already_migrated(tmp.path()));
let renamed = migrations.join("0002_rename");
std::fs::create_dir_all(&renamed).expect("mkdir");
std::fs::write(
renamed.join("up.sql"),
"ALTER TABLE comments RENAME TO archived_comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"only archived_comments remains; `comments` has to be created again"
);
let back = migrations.join("0003_rename_back");
std::fs::create_dir_all(&back).expect("mkdir");
std::fs::write(
back.join("up.sql"),
"ALTER TABLE archived_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the table is back but the scan cannot know its columns"
);
let columns = migrations.join("0004_columns");
std::fs::create_dir_all(&columns).expect("mkdir");
std::fs::write(
columns.join("up.sql"),
"ALTER TABLE comments ADD COLUMN commentable_type TEXT;\n\
ALTER TABLE comments ADD COLUMN commentable_id BIGINT;\n\
ALTER TABLE comments ADD COLUMN id BIGINT;\n\
ALTER TABLE comments ADD COLUMN parent_id BIGINT;\n\
ALTER TABLE comments ADD COLUMN author_id BIGINT;\n\
ALTER TABLE comments ADD COLUMN body TEXT;\n\
ALTER TABLE comments ADD COLUMN created_at TIMESTAMP;\n\
ALTER TABLE comments ADD COLUMN deleted_at TIMESTAMP;\n",
)
.expect("write");
assert!(already_migrated(tmp.path()));
}
#[test]
fn every_create_in_a_file_is_replayed_not_just_the_first() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_recreate");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
DROP TABLE comments;\n\
CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"the LAST create is the one that stands"
);
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
DROP TABLE comments;\n\
CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the last create declares no discriminator columns"
);
}
#[test]
fn alter_table_only_is_recognised() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let created = migrations.join("0001_create");
std::fs::create_dir_all(&created).expect("mkdir");
std::fs::write(
created.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()));
let altered = migrations.join("0002_convert");
std::fs::create_dir_all(&altered).expect("mkdir");
std::fs::write(
altered.join("up.sql"),
"ALTER TABLE ONLY comments ADD COLUMN commentable_type TEXT;\n\
ALTER TABLE ONLY comments ADD COLUMN commentable_id BIGINT;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"ALTER TABLE ONLY converts the table just as ALTER TABLE does"
);
let other = tempfile::tempdir().expect("tempdir");
let dir = other.path().join("migrations").join("0001_other");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE ONLY comments_archive ADD COLUMN commentable_type TEXT;\n\
ALTER TABLE ONLY comments_archive ADD COLUMN commentable_id BIGINT;\n",
)
.expect("write");
assert!(!already_migrated(other.path()));
}
#[test]
fn each_commentable_parent_gets_a_cleanup_trigger() {
let shared = up_sql(DatabaseBackend::Postgres);
assert!(
!shared.contains("comments_delete_for_parent"),
"the shared migration must NOT be the trigger's dependency:\n{shared}"
);
let trigger = parent_cleanup_sql(DatabaseBackend::Postgres, "posts", "Post");
assert!(
trigger.contains("CREATE OR REPLACE FUNCTION comments_delete_for_parent()"),
"the parent carries its own function, so ordering cannot break it:\n{trigger}"
);
assert!(
trigger.find("CREATE OR REPLACE FUNCTION").unwrap()
< trigger.find("CREATE TRIGGER").unwrap(),
"and defines it BEFORE the trigger that calls it:\n{trigger}"
);
assert!(trigger.contains("AFTER DELETE ON posts"));
for backend in [DatabaseBackend::Postgres, DatabaseBackend::Sqlite] {
let sql = parent_cleanup_sql(backend, "posts", "Post");
assert!(
sql.contains("KEEP IN SYNC WITH `#[commentable]` ON THIS MODEL."),
"{backend:?} migration must say the coupling out loud:\n{sql}"
);
assert!(sql.contains("type_name"), "{sql}");
}
assert!(
trigger.contains("EXECUTE FUNCTION comments_delete_for_parent('Post')"),
"the discriminator is passed, so one function serves every model:\n{trigger}"
);
let sqlite_shared = up_sql(DatabaseBackend::Sqlite);
assert!(
!sqlite_shared.contains("plpgsql"),
"no plpgsql on SQLite:\n{sqlite_shared}"
);
let sqlite = parent_cleanup_sql(DatabaseBackend::Sqlite, "posts", "Post");
assert!(sqlite.contains("AFTER DELETE ON posts"));
assert!(sqlite.contains("commentable_type = 'Post'"));
assert!(
!sqlite.contains("EXECUTE FUNCTION"),
"SQLite has no EXECUTE FUNCTION:\n{sqlite}"
);
assert!(
parent_cleanup_down_sql(DatabaseBackend::Postgres, "posts").contains(" ON posts;"),
"Postgres names the table"
);
assert!(
!parent_cleanup_down_sql(DatabaseBackend::Sqlite, "posts").contains(" ON "),
"SQLite must not"
);
}
#[test]
fn a_column_drop_is_recognised_with_or_without_the_column_keyword() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_d");
std::fs::create_dir_all(&dir).expect("mkdir");
let create = "CREATE TABLE comments (commentable_type TEXT NOT NULL, \
commentable_id BIGINT NOT NULL, id BIGINT, parent_id BIGINT, \
author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for drop in [
"ALTER TABLE comments DROP COLUMN commentable_type;",
"ALTER TABLE comments DROP commentable_type;",
"ALTER TABLE comments DROP COLUMN IF EXISTS commentable_type;",
"ALTER TABLE comments DROP CONSTRAINT ck, DROP COLUMN commentable_type;",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{drop}\n")).expect("write");
assert!(
!already_migrated(tmp.path()),
"the discriminator was dropped by: {drop}"
);
}
for keep in [
"ALTER TABLE comments ALTER COLUMN commentable_type DROP NOT NULL;",
"ALTER TABLE comments ALTER COLUMN commentable_type DROP DEFAULT;",
"ALTER TABLE comments DROP CONSTRAINT comments_pkey;",
"ALTER TABLE comments DROP COLUMN kind;",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{keep}\n")).expect("write");
assert!(
already_migrated(tmp.path()),
"the discriminator survives: {keep}"
);
}
}
#[test]
fn a_column_rename_is_recognised_with_or_without_the_column_keyword() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_c");
std::fs::create_dir_all(&dir).expect("mkdir");
let create = "CREATE TABLE comments (commentable_type TEXT NOT NULL, \
commentable_id BIGINT NOT NULL, id BIGINT, parent_id BIGINT, \
author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for rename in [
"ALTER TABLE comments RENAME COLUMN commentable_type TO kind;",
"ALTER TABLE comments RENAME commentable_type TO kind;",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{rename}\n")).expect("write");
assert!(
!already_migrated(tmp.path()),
"the discriminator was renamed away by: {rename}"
);
}
let bare = "CREATE TABLE comments (kind TEXT NOT NULL, commentable_id BIGINT NOT NULL, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for rename in [
"ALTER TABLE comments RENAME COLUMN kind TO commentable_type;",
"ALTER TABLE comments RENAME kind TO commentable_type;",
] {
std::fs::write(dir.join("up.sql"), format!("{bare}{rename}\n")).expect("write");
assert!(
already_migrated(tmp.path()),
"the discriminator was renamed INTO place by: {rename}"
);
}
std::fs::write(
dir.join("up.sql"),
format!("{create}ALTER TABLE comments RENAME CONSTRAINT ck TO ck2;\n"),
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"renaming a constraint leaves the columns alone"
);
}
#[test]
fn backslash_escapes_are_honoured_only_in_escape_strings() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_estring");
std::fs::create_dir_all(&dir).expect("mkdir");
let create = "CREATE TABLE comments (id BIGINT, commentable_type TEXT, \
commentable_id BIGINT, parent_id BIGINT, author_id BIGINT, \
body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for literal in [
r"INSERT INTO audit_log(msg) VALUES (E'can\'t; DROP TABLE comments;');",
r"INSERT INTO audit_log(msg) VALUES (e'lower\'s; DROP TABLE comments;');",
r"INSERT INTO audit_log(msg) VALUES (E'tab\there; DROP TABLE comments;');",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{literal}\n")).expect("write");
assert!(
already_migrated(tmp.path()),
"`{literal}` is one literal, not a drop"
);
}
std::fs::write(
dir.join("up.sql"),
format!(
"{create}INSERT INTO audit_log(msg) VALUES ('ends\\');\nDROP TABLE comments;\n"
),
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"a backslash does not escape the closing quote of an ordinary literal"
);
std::fs::write(
dir.join("up.sql"),
format!("{create}INSERT INTO audit_log(role)VALUES('x\\');\nDROP TABLE comments;\n"),
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"`role` ending in `e` is not an E-string prefix"
);
std::fs::write(
dir.join("up.sql"),
format!(
"{create}INSERT INTO audit_log(msg) VALUES (E'hi\\'there');\nDROP TABLE comments;\n"
),
)
.expect("write");
assert!(!already_migrated(tmp.path()), "the real drop still counts");
}
#[test]
fn dollar_quoted_literals_are_not_read_as_ddl() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_dollar");
std::fs::create_dir_all(&dir).expect("mkdir");
let create = "CREATE TABLE comments (id BIGINT, commentable_type TEXT, \
commentable_id BIGINT, parent_id BIGINT, author_id BIGINT, \
body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for literal in [
"INSERT INTO audit_log(msg) VALUES ($msg$DROP TABLE comments;$msg$);",
"INSERT INTO audit_log(msg) VALUES ($$DROP TABLE comments;$$);",
"INSERT INTO audit_log(msg) VALUES ($tag_1$DROP TABLE comments;$tag_1$);",
"CREATE FUNCTION f() RETURNS void AS $do$ BEGIN DROP TABLE comments; END $do$ \
LANGUAGE plpgsql;",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{literal}\n")).expect("write");
assert!(
already_migrated(tmp.path()),
"`{literal}` is data, not a drop"
);
}
std::fs::write(
dir.join("up.sql"),
format!(
"{create}INSERT INTO audit_log(msg) VALUES ($msg$hello$msg$);\n\
DROP TABLE comments;\n"
),
)
.expect("write");
assert!(!already_migrated(tmp.path()), "the real drop still counts");
std::fs::write(
dir.join("up.sql"),
format!("{create}PREPARE p AS SELECT $1;\nDROP TABLE comments;\n"),
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"`$1` must not open a literal that swallows the drop"
);
}
#[test]
fn a_drop_finds_comments_anywhere_in_the_name_list() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_drop");
std::fs::create_dir_all(&dir).expect("mkdir");
let create = "CREATE TABLE comments (id BIGINT, commentable_type TEXT, \
commentable_id BIGINT, parent_id BIGINT, author_id BIGINT, \
body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n";
for drop in [
"DROP TABLE comments;",
"DROP TABLE audit_log, comments;",
"DROP TABLE comments, audit_log;",
"DROP TABLE IF EXISTS a, public.comments CASCADE;",
"DROP TABLE a, \"comments\", b RESTRICT;",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{drop}\n")).expect("write");
assert!(
!already_migrated(tmp.path()),
"`{drop}` takes the shared table away"
);
}
for drop in [
"DROP TABLE audit_log;",
"DROP TABLE comments_archive, audit_log;",
"DROP TABLE a, archive.comments;",
"DROP TABLE old_comments;",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{drop}\n")).expect("write");
assert!(
already_migrated(tmp.path()),
"`{drop}` leaves the shared table alone"
);
}
}
#[test]
fn an_unlogged_comments_table_counts_and_a_temporary_one_does_not() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_unlogged");
std::fs::create_dir_all(&dir).expect("mkdir");
let columns = "id BIGINT, commentable_type TEXT, commentable_id BIGINT, \
parent_id BIGINT, author_id BIGINT, body TEXT, \
created_at TIMESTAMP, deleted_at TIMESTAMP";
for create in [
"CREATE UNLOGGED TABLE comments",
"CREATE UNLOGGED TABLE IF NOT EXISTS comments",
"CREATE UNLOGGED TABLE public.comments",
] {
std::fs::write(dir.join("up.sql"), format!("{create} ({columns});\n")).expect("write");
assert!(already_migrated(tmp.path()), "`{create}` occupies the name");
}
for create in [
"CREATE TEMP TABLE comments",
"CREATE TEMPORARY TABLE comments",
"CREATE LOCAL TEMP TABLE comments",
] {
std::fs::write(dir.join("up.sql"), format!("{create} ({columns});\n")).expect("write");
assert!(
!already_migrated(tmp.path()),
"`{create}` is session-local and collides with nothing"
);
}
}
#[test]
fn every_alter_table_modifier_combination_is_recognised() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_alter");
std::fs::create_dir_all(&dir).expect("mkdir");
let base = "CREATE TABLE comments (id BIGINT, parent_id BIGINT, \
author_id BIGINT, body TEXT, created_at TIMESTAMP, \
deleted_at TIMESTAMP);\n";
for alter in [
"ALTER TABLE comments",
"ALTER TABLE ONLY comments",
"ALTER TABLE IF EXISTS comments",
"ALTER TABLE IF EXISTS ONLY comments",
"ALTER TABLE IF EXISTS ONLY public.comments",
"ALTER TABLE comments *",
] {
std::fs::write(
dir.join("up.sql"),
format!(
"{base}{alter} ADD COLUMN commentable_type TEXT;\n\
{alter} ADD COLUMN commentable_id BIGINT;\n"
),
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"`{alter}` names the shared table"
);
}
for alter in [
"ALTER TABLE IF EXISTS ONLY comments_archive",
"ALTER TABLE ONLY archive.comments",
] {
std::fs::write(
dir.join("up.sql"),
format!(
"{base}{alter} ADD COLUMN commentable_type TEXT;\n\
{alter} ADD COLUMN commentable_id BIGINT;\n"
),
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"`{alter}` is a different table"
);
}
}
#[test]
fn a_polymorphic_table_missing_a_required_column_is_not_the_shared_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rails");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (id BIGINT, commentable_type TEXT, \
commentable_id BIGINT, parent_id BIGINT, user_id BIGINT, body TEXT, \
created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"`user_id` is not `author_id`, so the helpers would 42703"
);
assert!(conflicting_comments_table(tmp.path()));
for missing in REQUIRED_COLUMNS.iter().copied() {
let columns: Vec<String> = REQUIRED_COLUMNS
.iter()
.filter(|column| **column != missing)
.map(|column| format!("{column} BIGINT"))
.collect();
std::fs::write(
dir.join("up.sql"),
format!("CREATE TABLE comments ({});\n", columns.join(", ")),
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"a table with no `{missing}` is not the shared table"
);
}
let columns: Vec<String> = REQUIRED_COLUMNS
.iter()
.map(|column| format!("{column} BIGINT"))
.collect();
std::fs::write(
dir.join("up.sql"),
format!("CREATE TABLE comments ({});\n", columns.join(", ")),
)
.expect("write");
assert!(already_migrated(tmp.path()));
assert!(!conflicting_comments_table(tmp.path()));
}
#[test]
fn a_plain_comments_table_is_reported_as_conflicting() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_create_comments");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, parent_id BIGINT, author_id BIGINT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()), "no discriminator columns");
assert!(
conflicting_comments_table(tmp.path()),
"a same-named non-polymorphic table must be reported"
);
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments (commentable_type TEXT NOT NULL, commentable_id BIGINT NOT NULL, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(already_migrated(tmp.path()));
assert!(
!conflicting_comments_table(tmp.path()),
"the shared table is reused, not a collision"
);
let empty = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(empty.path().join("migrations")).expect("mkdir");
assert!(!already_migrated(empty.path()));
assert!(!conflicting_comments_table(empty.path()));
}
#[test]
fn a_quoted_case_sensitive_table_is_not_the_shared_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_cased");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE \"Comments\" (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"\"Comments\" is a different relation from comments"
);
std::fs::write(
dir.join("up.sql"),
"CrEaTe TaBlE Comments (CommentAble_Type TEXT, COMMENTABLE_ID BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"unquoted identifiers and keywords fold"
);
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE \"comments\" (\"commentable_type\" TEXT, \"commentable_id\" BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n",
)
.expect("write");
assert!(already_migrated(tmp.path()));
}
#[test]
fn another_commentable_model_keeps_the_shared_migration_alive() {
let tmp = tempfile::tempdir().expect("tempdir");
let models = tmp.path().join("src").join("models");
std::fs::create_dir_all(&models).expect("mkdir");
std::fs::write(models.join("post.rs"), "#[commentable(by = User)]\n").expect("write");
assert!(
!another_model_is_still_commentable(tmp.path(), "post"),
"the model being destroyed does not count as another one"
);
std::fs::write(models.join("photo.rs"), "#[commentable(by = User)]\n").expect("write");
assert!(another_model_is_still_commentable(tmp.path(), "post"));
let single = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(single.path().join("src")).expect("mkdir");
std::fs::write(
single.path().join("src").join("models.rs"),
"#[commentable(by = User)]\npub struct Post {}\n",
)
.expect("write");
assert!(!another_model_is_still_commentable(single.path(), "post"));
std::fs::write(
single.path().join("src").join("models.rs"),
"#[commentable(by = User)]\npub struct Post {}\n\
#[commentable(by = User)]\npub struct Photo {}\n",
)
.expect("write");
assert!(another_model_is_still_commentable(single.path(), "post"));
}
}