use std::collections::HashMap;
use std::path::Path;
use crate::generate::GenerateError;
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 {
matches!(comments_table(project_root), CommentsTable::Shared)
}
#[derive(Debug, PartialEq, Eq)]
enum CommentsTable {
Absent,
Shared,
Conflicting {
missing: Vec<&'static str>,
columns_unknown: bool,
},
}
fn comments_table(project_root: &Path) -> CommentsTable {
classify(&migration_up_sql(project_root))
}
#[must_use]
pub fn comments_migration_still_needed(
project_root: &Path,
migration_dir: &Path,
excluding: &[std::path::PathBuf],
) -> bool {
let name = migration_dir.file_name();
if !name
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(MIGRATION_SUFFIX))
{
return false;
}
let src = project_root.join("src");
let commentable_elsewhere = commentable_declared_below(&src.join("models"), excluding)
|| std::fs::read_to_string(src.join("models.rs"))
.is_ok_and(|models| declares_commentable_on_comments(&models));
if !commentable_elsewhere {
return false;
}
let all = migration_up_sql(project_root);
let without = migration_up_sql_where(project_root, |dir, _| dir.file_name() != name);
let orphans_an_alter = |files: &[String]| {
replay(files)
.touched_while_absent
.contains(&TableRef::comments())
};
classify(&all) == CommentsTable::Shared
&& (classify(&without) != CommentsTable::Shared
|| (orphans_an_alter(&without) && !orphans_an_alter(&all)))
}
fn classify(files: &[String]) -> CommentsTable {
let tables = replay_migration_history(files);
match tables.get(&TableRef::comments()) {
Some(table) if table.exists => {
let missing = table.missing_columns();
if missing.is_empty() {
CommentsTable::Shared
} else {
CommentsTable::Conflicting {
missing,
columns_unknown: table.columns_unknown,
}
}
}
_ => CommentsTable::Absent,
}
}
fn conflicting_table_error(missing: &[&str], columns_unknown: bool) -> GenerateError {
let rename_or_drop = format!(
" - Rename or drop the existing `{COMMENTS_TABLE}` table in a new migration. \
Then update or remove the model that uses it."
);
let message = if columns_unknown {
format!(
"cannot add the shared `{COMMENTS_TABLE}` table: a migration makes a \
`{COMMENTS_TABLE}` table without a column list the generator can read (a \
rename or schema move of a table no migration creates, or a \
`CREATE TABLE … AS`). The generator cannot read its columns. The generator \
wrote no files. Do one of these steps:\n\
{rename_or_drop} Then run the command again.\n\
\x20 - If the table is already the shared one, add `#[commentable]` to the model \
by hand. Do not use `comments:commentable`."
)
} else {
let missing = missing
.iter()
.map(|column| format!("`{column}`"))
.collect::<Vec<_>>()
.join(", ");
format!(
"cannot add the shared `{COMMENTS_TABLE}` table: the project has a \
`{COMMENTS_TABLE}` table that is not the shared one. These columns are missing: \
{missing}. The generator wrote no files. Do this step, then run the command \
again:\n\
{rename_or_drop}"
)
};
GenerateError::Config(message)
}
fn model_using_comments_table(project_root: &Path) -> Option<std::path::PathBuf> {
let src = project_root.join("src");
let mut files = vec![src.join("models.rs")];
let mut dirs = vec![src.join("models")];
while let Some(dir) = dirs.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for path in entries.filter_map(Result::ok).map(|entry| entry.path()) {
if path.is_dir() {
dirs.push(path);
} else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") {
files.push(path);
}
}
}
files.sort();
files
.into_iter()
.find(|file| std::fs::read_to_string(file).is_ok_and(|source| maps_comments_table(&source)))
}
fn declares_commentable_on_comments(source: &str) -> bool {
fn on_comments(items: &[syn::Item]) -> bool {
items.iter().any(|item| match item {
syn::Item::Struct(item) => item
.attrs
.iter()
.filter(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "commentable")
})
.any(|attr| commentable_table(attr) == COMMENTS_TABLE),
syn::Item::Mod(item) => item
.content
.as_ref()
.is_some_and(|(_, items)| on_comments(items)),
_ => false,
})
}
syn::parse_file(source).map_or_else(
|_| source.contains("#[commentable"),
|file| on_comments(&file.items),
)
}
fn commentable_table(attr: &syn::Attribute) -> String {
let mut table = None;
if matches!(attr.meta, syn::Meta::List(_)) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("table") {
let value: syn::Expr = meta.value()?.parse()?;
table = match value {
syn::Expr::Path(path) => path.path.get_ident().map(ToString::to_string),
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(name),
..
}) => Some(name.value()),
_ => None,
};
} else if meta.input.peek(syn::Token![=]) {
let _: syn::Expr = meta.value()?.parse()?;
}
Ok(())
});
}
table.unwrap_or_else(|| COMMENTS_TABLE.to_owned())
}
fn maps_comments_table(source: &str) -> bool {
syn::parse_file(source).map_or_else(
|_| binds_comments_table(&strip_rust_comments_and_literals(source)),
|file| items_map_comments_table(&file.items),
)
}
fn items_map_comments_table(items: &[syn::Item]) -> bool {
items.iter().any(|item| match item {
syn::Item::Struct(item) => {
model_table(item).is_some_and(|table| table == COMMENTS_TABLE)
&& !declares_every_shared_column(item)
}
syn::Item::Mod(item) => item
.content
.as_ref()
.is_some_and(|(_, items)| items_map_comments_table(items)),
_ => false,
})
}
const INSERT_COLUMNS: &[&str] = &["commentable_type", "commentable_id", "author_id", "body"];
fn declares_every_shared_column(item: &syn::ItemStruct) -> bool {
let fields: Vec<String> = item.fields.iter().filter_map(field_column).collect();
INSERT_COLUMNS
.iter()
.all(|column| fields.iter().any(|field| field == column))
}
fn field_column(field: &syn::Field) -> Option<String> {
let mut renamed = None;
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("diesel"))
{
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("column_name") {
let value: syn::Ident = meta.value()?.parse()?;
renamed = Some(value.to_string());
} else if meta.input.peek(syn::Token![=]) {
let _: syn::Expr = meta.value()?.parse()?;
}
Ok(())
});
}
renamed.or_else(|| {
field.ident.as_ref().map(|ident| {
let name = ident.to_string();
name.strip_prefix("r#")
.map(ToOwned::to_owned)
.unwrap_or(name)
})
})
}
fn model_table(item: &syn::ItemStruct) -> Option<String> {
let attr = item.attrs.iter().find(|attr| {
attr.path()
.segments
.last()
.is_some_and(|segment| segment.ident == "model")
})?;
let mut table = None;
if matches!(attr.meta, syn::Meta::List(_)) {
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("table") {
table = Some(meta.value()?.parse::<syn::LitStr>()?.value());
}
Ok(())
});
}
Some(table.unwrap_or_else(|| {
super::naming::pluralize(&super::naming::snake(&item.ident.to_string()))
}))
}
fn binds_comments_table(code: &str) -> bool {
code.match_indices("schema::").any(|(at, prefix)| {
let after = &code[at + prefix.len()..];
if let Some(group) = after.strip_prefix('{') {
let group = group.split('}').next().unwrap_or_default();
return group
.split(',')
.any(|entry| entry.split_whitespace().next() == Some(COMMENTS_TABLE));
}
after.strip_prefix(COMMENTS_TABLE).is_some_and(|rest| {
!rest.starts_with(is_ident_char) && !rest.trim_start().starts_with("::")
})
})
}
fn strip_rust_comments_and_literals(source: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut chars = source.chars().peekable();
while let Some(c) = chars.next() {
match c {
'/' if chars.peek() == Some(&'/') => {
for next in chars.by_ref() {
if next == '\n' {
out.push('\n');
break;
}
}
}
'/' if chars.peek() == Some(&'*') => {
chars.next();
let mut depth = 1;
while depth > 0 {
match chars.next() {
Some('/') if chars.peek() == Some(&'*') => {
chars.next();
depth += 1;
}
Some('*') if chars.peek() == Some(&'/') => {
chars.next();
depth -= 1;
}
Some(_) => {}
None => break,
}
}
out.push(' ');
}
'"' => {
while let Some(next) = chars.next() {
match next {
'\\' => {
chars.next();
}
'"' => break,
_ => {}
}
}
out.push_str("\"\"");
}
'\'' => {
let mut ahead = chars.clone();
let literal = match ahead.next() {
Some('\\') => {
ahead.next();
ahead.any(|next| next == '\'')
}
Some(_) => ahead.next() == Some('\''),
None => false,
};
if literal {
chars = ahead;
out.push_str("' '");
} else {
out.push(c);
}
}
_ => out.push(c),
}
}
out
}
fn stale_model_error(project_root: &Path, model: &Path) -> GenerateError {
let model = model.strip_prefix(project_root).unwrap_or(model);
GenerateError::Config(format!(
"cannot add the shared `{COMMENTS_TABLE}` table: {} still uses the \
`{COMMENTS_TABLE}` table. Its inserts do not set the shared columns, so each \
insert would fail. The generator wrote no files. Remove that model, or point it \
at another table. Then run the command again.",
model.display().to_string().replace('\\', "/")
))
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
struct TableRef {
schema: Option<String>,
name: String,
}
impl TableRef {
fn comments() -> Self {
Self {
schema: None,
name: COMMENTS_TABLE.to_owned(),
}
}
}
#[derive(Default, Debug)]
struct TableState {
exists: bool,
columns: Vec<&'static str>,
columns_unknown: bool,
}
impl TableState {
fn missing_columns(&self) -> Vec<&'static str> {
REQUIRED_COLUMNS
.iter()
.copied()
.filter(|column| !self.columns.contains(column))
.collect()
}
}
#[derive(Debug)]
enum TableEvent {
Create(TableRef, Option<Vec<&'static str>>, bool),
Add(TableRef, &'static str),
Remove(TableRef, &'static str),
Drop(TableRef, bool),
Touch(TableRef),
Rename { from: TableRef, to: TableRef },
Move { from: TableRef, to: TableRef },
}
impl TableEvent {
const fn altered_table(&self) -> Option<&TableRef> {
match self {
Self::Add(table, _)
| Self::Remove(table, _)
| Self::Touch(table)
| Self::Rename { from: table, .. }
| Self::Move { from: table, .. } => Some(table),
Self::Create(..) | Self::Drop(..) => None,
}
}
}
fn replay_migration_history(files: &[String]) -> HashMap<TableRef, TableState> {
replay(files).tables
}
struct Replay {
tables: HashMap<TableRef, TableState>,
touched_while_absent: std::collections::HashSet<TableRef>,
}
fn file_events(sql: &str) -> Vec<(usize, TableEvent, bool)> {
let mut events: Vec<(usize, TableEvent, bool)> = Vec::new();
for (at, table, body, if_not_exists) in create_tables(sql) {
let columns = body.map(|body| {
REQUIRED_COLUMNS
.iter()
.copied()
.filter(|column| declares_column(body, column))
.collect()
});
events.push((at, TableEvent::Create(table, columns, if_not_exists), false));
}
for (at, dropped, if_exists) in drop_tables(sql) {
for table in dropped {
events.push((at, TableEvent::Drop(table, if_exists), false));
}
}
for (at, table, statement, if_exists) in alter_tables(sql) {
if let Some(to) = table_rename_target(statement) {
events.push((at, TableEvent::Rename { from: table, to }, if_exists));
continue;
}
if let Some(to) = table_set_schema_target(statement, &table) {
events.push((at, TableEvent::Move { from: table, to }, if_exists));
continue;
}
events.push((at, TableEvent::Touch(table.clone()), if_exists));
for action in split_top_level(statement) {
for (column, added) in alter_action_columns(action) {
let event = if added {
TableEvent::Add(table.clone(), column)
} else {
TableEvent::Remove(table.clone(), column)
};
events.push((at, event, if_exists));
}
}
}
events
}
fn replay(files: &[String]) -> Replay {
let mut tables: HashMap<TableRef, TableState> = HashMap::new();
let mut touched_while_absent = std::collections::HashSet::new();
for sql in files {
let mut events = file_events(sql);
events.sort_by_key(|(at, _, _)| *at);
for (_, event, if_exists) in events {
let skip = match &event {
TableEvent::Rename { from, .. } | TableEvent::Move { from, .. } => {
tables.get(from).is_some_and(|state| !state.exists)
}
_ => !event
.altered_table()
.is_some_and(|table| tables.get(table).is_some_and(|state| state.exists)),
};
if if_exists && skip {
continue;
}
match event {
TableEvent::Create(table, columns, if_not_exists) => {
if if_not_exists && tables.get(&table).is_some_and(|state| state.exists) {
continue;
}
tables.insert(
table,
TableState {
exists: true,
columns_unknown: columns.is_none(),
columns: columns.unwrap_or_default(),
},
);
}
TableEvent::Add(table, column) => {
if !tables.get(&table).is_some_and(|state| state.exists) {
touched_while_absent.insert(table.clone());
}
let state = tables.entry(table).or_default();
if !state.columns.contains(&column) {
state.columns.push(column);
}
}
TableEvent::Remove(table, column) => {
if !tables.get(&table).is_some_and(|state| state.exists) {
touched_while_absent.insert(table.clone());
}
if let Some(state) = tables.get_mut(&table) {
state.columns.retain(|held| *held != column);
}
}
TableEvent::Touch(table) => {
if !tables.get(&table).is_some_and(|state| state.exists) {
touched_while_absent.insert(table);
}
}
TableEvent::Drop(table, if_exists) => {
if !if_exists && !tables.get(&table).is_some_and(|state| state.exists) {
touched_while_absent.insert(table.clone());
}
let state = tables.entry(table).or_default();
state.exists = false;
state.columns.clear();
state.columns_unknown = false;
}
TableEvent::Rename { from, to } => {
let to = TableRef {
schema: to.schema.or_else(|| from.schema.clone()),
name: to.name,
};
let source_known = tables.get(&from).is_some_and(|state| state.exists);
if !source_known && !if_exists {
touched_while_absent.insert(from.clone());
}
let mut state = tables.remove(&from).unwrap_or_default();
state.exists = true;
state.columns_unknown |= !source_known;
tables.insert(to, state);
}
TableEvent::Move { from, to } => {
let source_known = tables.get(&from).is_some_and(|state| state.exists);
if !source_known && !if_exists {
touched_while_absent.insert(from.clone());
}
let mut state = tables.remove(&from).unwrap_or_default();
state.exists = true;
state.columns_unknown |= !source_known;
tables.insert(to, state);
}
}
}
}
Replay {
tables,
touched_while_absent,
}
}
const fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '$'
}
fn strip_keyword<'a>(text: &'a str, keyword: &str) -> &'a str {
if let Some(after) = text.strip_prefix(keyword)
&& (after.is_empty() || after.starts_with(|c: char| c.is_whitespace()))
{
after.trim_start()
} else {
text
}
}
fn parse_ident_segment(text: &str) -> Option<(String, usize)> {
let trimmed = text.trim_start();
let skip = text.len() - trimmed.len();
if trimmed.starts_with('"') {
let mut name = String::new();
let mut i = 1;
loop {
let close = trimmed[i..].find('"')?;
name.push_str(&trimmed[i..i + close]);
i += close + 1;
if trimmed[i..].starts_with('"') {
name.push('"');
i += 1;
} else {
break;
}
}
Some((name, skip + i))
} else {
let len = trimmed
.find(|c: char| !is_ident_char(c))
.unwrap_or(trimmed.len());
if len == 0 {
return None;
}
Some((trimmed[..len].to_owned(), skip + len))
}
}
fn parse_table_ref(text: &str) -> Option<(TableRef, usize)> {
let mut rest = text.trim_start();
rest = strip_keyword(rest, "if not exists");
rest = strip_keyword(rest, "if exists");
rest = strip_keyword(rest, "only");
let (first, used) = parse_ident_segment(rest)?;
rest = &rest[used..];
let (schema, name) = if let Some(dot) = rest.trim_start().strip_prefix('.') {
let (second, used) = parse_ident_segment(dot)?;
rest = &dot[used..];
(Some(first), second)
} else {
(None, first)
};
let consumed = text.len() - rest.len();
let schema = schema.filter(|schema| schema != "public");
Some((TableRef { schema, name }, consumed))
}
const CREATE_VERBS: &[&str] = &["table", "unlogged table"];
fn create_tables(sql: &str) -> Vec<(usize, TableRef, Option<&str>, bool)> {
let mut found = Vec::new();
let mut base = 0usize;
while let Some(at) = sql[base..].find("create ") {
let start = base + at;
base = start + "create ".len();
if start > 0 && sql[..start].ends_with(is_ident_char) {
continue;
}
let rest = &sql[start + "create ".len()..];
let verb = CREATE_VERBS.iter().find(|verb| {
rest.strip_prefix(*verb)
.is_some_and(|after| after.starts_with(|c: char| c.is_whitespace()))
});
let Some(verb) = verb else {
continue;
};
let after_verb = &rest[verb.len()..];
let trimmed = after_verb.trim_start();
let if_not_exists = strip_keyword(trimmed, "if not exists").len() != trimmed.len();
let Some((table, used)) = parse_table_ref(after_verb) else {
continue;
};
let body_start = start + "create ".len() + verb.len() + used;
let body = create_table_body(sql, body_start);
found.push((start, table, body, if_not_exists));
}
found
}
fn create_table_body(sql: &str, from: usize) -> Option<&str> {
let open = sql[from..].find(['(', ';'])? + from;
if sql[open..].starts_with(';') {
return None;
}
if sql[from..open]
.split(|c: char| !is_ident_char(c))
.any(|word| word == "as")
{
return None;
}
let mut depth = 0usize;
for (offset, ch) in sql[open..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(&sql[open + 1..open + offset]);
}
}
_ => {}
}
}
Some(&sql[open + 1..])
}
fn drop_tables(sql: &str) -> Vec<(usize, Vec<TableRef>, bool)> {
let mut found = Vec::new();
let mut base = 0usize;
while let Some(at) = sql[base..].find("drop table") {
let start = base + at;
base = start + "drop table".len();
if start > 0 && sql[..start].ends_with(is_ident_char) {
continue;
}
let rest = &sql[start + "drop table".len()..];
if rest.starts_with(is_ident_char) {
continue;
}
let statement = rest.split(';').next().unwrap_or(rest);
let tables: Vec<TableRef> = statement
.split(',')
.filter_map(|entry| {
parse_table_ref(entry).map(|(table, _)| table)
})
.collect();
if !tables.is_empty() {
let if_exists = statement.trim_start().starts_with("if exists");
found.push((start, tables, if_exists));
}
}
found
}
fn alter_tables(sql: &str) -> Vec<(usize, TableRef, &str, bool)> {
let mut found = Vec::new();
let mut base = 0usize;
while let Some(at) = sql[base..].find("alter table") {
let start = base + at;
base = start + "alter table".len();
if start > 0 && sql[..start].ends_with(is_ident_char) {
continue;
}
let rest = &sql[start + "alter table".len()..];
if rest.starts_with(is_ident_char) {
continue;
}
let Some((table, used)) = parse_table_ref(rest) else {
continue;
};
let after = &rest[used..];
let statement = after.split(';').next().unwrap_or(after);
let if_exists = rest.trim_start().starts_with("if exists");
found.push((start, table, statement, if_exists));
}
found
}
fn table_rename_target(statement: &str) -> Option<TableRef> {
let at = statement.find(" rename to ")?;
if statement[..at].contains("rename column") {
return None;
}
parse_table_ref(&statement[at + " rename to ".len()..]).map(|(table, _)| table)
}
fn table_set_schema_target(statement: &str, table: &TableRef) -> Option<TableRef> {
let at = statement.find(" set schema ")?;
let (schema, _) = parse_ident_segment(&statement[at + " set schema ".len()..])?;
Some(TableRef {
schema: Some(schema).filter(|schema| schema != "public"),
name: table.name.clone(),
})
}
fn split_top_level(text: &str) -> Vec<&str> {
let mut parts = Vec::new();
let (mut depth, mut in_literal, mut in_ident, mut start) = (0usize, false, false, 0usize);
for (at, ch) in text.char_indices() {
match ch {
'"' if !in_literal => in_ident = !in_ident,
'\'' if !in_ident => in_literal = !in_literal,
'(' if !in_literal && !in_ident => depth += 1,
')' if !in_literal && !in_ident => depth = depth.saturating_sub(1),
',' if !in_literal && !in_ident && depth == 0 => {
parts.push(&text[start..at]);
start = at + 1;
}
_ => {}
}
}
parts.push(&text[start..]);
parts
}
const TABLE_CONSTRAINTS: &[&str] = &[
"constraint",
"primary",
"foreign",
"unique",
"check",
"exclude",
"like",
];
fn alter_action_columns(action: &str) -> Vec<(&'static str, bool)> {
let action = action.trim().trim_start_matches('*').trim_start();
let after_add = strip_keyword(action, "add");
if after_add.len() != action.len() {
let rest = strip_keyword(after_add, "column");
let rest = strip_keyword(rest, "if not exists");
let quoted = rest.starts_with('"');
return parse_ident_segment(rest)
.filter(|(name, _)| quoted || !TABLE_CONSTRAINTS.contains(&name.as_str()))
.and_then(|(name, _)| REQUIRED_COLUMNS.iter().copied().find(|c| *c == name))
.map(|column| (column, true))
.into_iter()
.collect();
}
let padded = format!(" {action} ");
let renamed_into = column_rename(&padded).map(|(_, to)| to);
REQUIRED_COLUMNS
.iter()
.copied()
.filter_map(|column| {
if alter_removes_column(&padded, column) {
Some((column, false))
} else if renamed_into.is_some_and(|to| mentions_column(to, column)) {
Some((column, true))
} else {
None
}
})
.collect()
}
fn column_rename(statement: &str) -> Option<(&str, &str)> {
let at = statement.find(" rename ")?;
let rest = statement[at + " rename ".len()..].trim_start();
if rest.starts_with("to ") || rest.starts_with("constraint ") {
return None;
}
Some(rest.split_once(" to ").unwrap_or((rest, "")))
}
fn declares_column(body: &str, column: &str) -> bool {
split_top_level(body).into_iter().any(|element| {
let element = element.trim_start();
let quoted = element.starts_with('"');
parse_ident_segment(element).is_some_and(|(name, _)| {
name == column && (quoted || !TABLE_CONSTRAINTS.contains(&name.as_str()))
})
})
}
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
}
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((from, to)) = column_rename(statement) {
return mentions_column(from, column) && !mentions_column(to, column);
}
false
}
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 migration_up_sql(project_root: &Path) -> Vec<String> {
migration_up_sql_where(project_root, |_, _| true)
}
fn migration_up_sql_where(project_root: &Path, keep: impl Fn(&Path, &str) -> bool) -> 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| {
let sql = std::fs::read_to_string(dir.join("up.sql")).ok()?;
keep(&dir, &sql).then_some(sql)
})
.map(|sql| strip_sql_comments(&sql))
.collect()
}
fn push_space(out: &mut String) {
if !out.ends_with(' ') {
out.push(' ');
}
}
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);
push_space(&mut out);
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);
push_space(&mut out);
i = end;
}
_ if bytes[i].is_ascii_whitespace() => {
push_space(&mut out);
i += 1;
}
_ => {
let ch = sql[i..].chars().next().unwrap_or(' ');
out.push(ch.to_ascii_lowercase());
i += ch.len_utf8();
}
}
}
out
}
pub fn push_commentable_migration(
plan: &mut Plan,
project_root: &Path,
timestamp: &str,
backend: autumn_web::config::DatabaseBackend,
for_revert: bool,
) -> Result<bool, GenerateError> {
if !for_revert {
let table = comments_table(project_root);
if let CommentsTable::Conflicting {
missing,
columns_unknown,
} = &table
{
return Err(conflicting_table_error(missing, *columns_unknown));
}
if let Some(model) = model_using_comments_table(project_root) {
return Err(stale_model_error(project_root, &model));
}
if table == CommentsTable::Shared {
return Ok(false);
}
}
if for_revert && !generator_owned_comments_migration(project_root, backend) {
return Ok(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());
Ok(true)
}
fn commentable_declared_below(dir: &Path, excluding: &[std::path::PathBuf]) -> 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, excluding) {
return true;
}
continue;
}
if excluding.contains(&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| declares_commentable_on_comments(&src)) {
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;
fn conflicting_comments_table(project_root: &Path) -> bool {
matches!(
comments_table(project_root),
CommentsTable::Conflicting { .. }
)
}
#[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 rename carried the discriminator columns back with the table"
);
}
#[test]
fn a_rename_into_comments_carries_the_source_columns_across() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"the renamed table already carried the full discriminator schema"
);
}
#[test]
fn a_rename_into_comments_without_the_columns_is_not_the_shared_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (id BIGSERIAL PRIMARY KEY, body TEXT);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the source table carried no discriminator columns"
);
assert!(
conflicting_comments_table(tmp.path()),
"the name is taken, loudly, rather than silently reused"
);
}
#[test]
fn a_rename_within_another_schema_stays_in_that_schema() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE archive.legacy_comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, \
parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, \
deleted_at TIMESTAMP);\n\
ALTER TABLE archive.legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the renamed table is archive.comments, not the shared comments table"
);
}
fn migrated_after(sql: &str) -> bool {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_history");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), sql).expect("write");
already_migrated(tmp.path())
}
#[test]
fn a_spaced_schema_dot_keeps_the_schema() {
let cols = "commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP";
assert!(!migrated_after(&format!(
"CREATE TABLE archive . legacy_comments ({cols});\n\
ALTER TABLE archive . legacy_comments RENAME TO comments;\n"
)));
assert!(!migrated_after(&format!(
"CREATE TABLE archive .comments ({cols});\n"
)));
assert!(migrated_after(&format!(
"CREATE TABLE public . comments ({cols});\n"
)));
}
#[test]
fn an_added_column_does_not_add_the_columns_it_mentions() {
assert!(!migrated_after(
"CREATE TABLE legacy_comments (commentable_type TEXT, commentable_id BIGINT, \
parent_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE legacy_comments ADD COLUMN author_id BIGINT REFERENCES users(id);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n"
));
}
#[test]
fn alter_actions_are_read_one_by_one() {
let base = "CREATE TABLE comments (commentable_type TEXT, commentable_id BIGINT, \
parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, \
deleted_at TIMESTAMP);\n";
assert!(!migrated_after(&format!(
"{base}ALTER TABLE comments ADD CONSTRAINT id_ck CHECK (id > 0), \
ADD FOREIGN KEY (author_id) REFERENCES users(id);\n"
)));
assert!(!migrated_after(&format!(
"{base}ALTER TABLE comments ADD PRIMARY KEY (id);\n"
)));
assert!(migrated_after(&format!(
"{base}ALTER TABLE comments ADD COLUMN IF NOT EXISTS note NUMERIC(10, 2), \
ADD COLUMN id BIGSERIAL PRIMARY KEY;\n"
)));
assert!(migrated_after(&format!(
"{base}ALTER TABLE comments ADD COLUMN id BIGINT DEFAULT 1;\n"
)));
assert!(migrated_after(&format!(
"{base}ALTER TABLE comments RENAME COLUMN uid TO id;\n"
)));
}
const LACKS_TYPE: &str = "CREATE TABLE comments (id BIGINT, commentable_id BIGINT, \
parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, \
deleted_at TIMESTAMP);\n";
#[test]
fn a_spaced_schema_dot_works_on_alter_add() {
assert!(migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE public . comments ADD COLUMN commentable_type TEXT;\n"
)));
}
#[test]
fn quoted_identifiers_do_not_merge_alter_actions() {
for name in ["o'b", "a(b", "a)b", "a,b"] {
assert!(
!migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE comments ADD COLUMN commentable_type TEXT, \
ADD COLUMN \"{name}\" TEXT, DROP COLUMN body;\n"
)),
"`{name}` hid the DROP"
);
}
assert_eq!(
split_top_level("a \"x\"\"(,\" b, c"),
["a \"x\"\"(,\" b", " c"]
);
}
#[test]
fn other_alter_actions_add_nothing() {
assert!(!migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE comments ALTER COLUMN commentable_type SET NOT NULL;\n"
)));
assert!(!migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE comments RENAME CONSTRAINT ck TO commentable_type;\n"
)));
}
#[test]
fn a_mixed_alter_applies_each_action() {
assert!(migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE comments DROP COLUMN kind, ADD COLUMN commentable_type TEXT;\n"
)));
assert!(!migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE comments ADD COLUMN commentable_type TEXT, DROP COLUMN body;\n"
)));
assert!(migrated_after(&format!(
"{LACKS_TYPE}ALTER TABLE comments RENAME COLUMN kind TO commentable_type;\n"
)));
}
#[test]
fn a_create_table_as_does_not_borrow_a_later_column_list() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments AS SELECT 1 AS id;\n\
CREATE TABLE other_comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, \
parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, \
deleted_at TIMESTAMP);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"legacy_comments never had the discriminator columns"
);
}
#[test]
fn a_create_table_as_query_is_not_read_as_a_column_list() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments AS SELECT jsonb_build_object(\
commentable_type, commentable_id, id, parent_id, author_id, body, \
created_at, deleted_at) AS payload FROM source;\n\
ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the table only has `payload`, whatever its query mentions"
);
}
#[test]
fn set_schema_moves_the_table_out_of_and_back_into_the_default_schema() {
let tmp = tempfile::tempdir().expect("tempdir");
let migrations = tmp.path().join("migrations");
let first = migrations.join("0001_create");
std::fs::create_dir_all(&first).expect("mkdir");
std::fs::write(
first.join("up.sql"),
"CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT);\n\
ALTER TABLE comments SET SCHEMA archive;\n",
)
.expect("write");
assert_eq!(
comments_table(tmp.path()),
CommentsTable::Absent,
"the table now lives in `archive`, so the default name is free"
);
let back = migrations.join("0002_back");
std::fs::create_dir_all(&back).expect("mkdir");
std::fs::write(
back.join("up.sql"),
"ALTER TABLE archive.comments SET SCHEMA public;\n",
)
.expect("write");
assert!(
conflicting_comments_table(tmp.path()),
"the plain table is back in the default schema"
);
}
#[test]
fn schema_moves_and_renames_accept_any_whitespace() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_create");
std::fs::create_dir_all(&dir).expect("mkdir");
for undo in [
"ALTER TABLE comments\nSET\tSCHEMA archive;\n",
"ALTER TABLE comments\nRENAME\tTO legacy;\n",
] {
std::fs::write(
dir.join("up.sql"),
format!("CREATE TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT);\n{undo}"),
)
.expect("write");
assert_eq!(comments_table(tmp.path()), CommentsTable::Absent, "{undo}");
}
}
#[test]
fn a_conditional_schema_move_of_a_dropped_table_changes_nothing() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_move");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE archive.comments (id BIGINT);\n\
DROP TABLE archive.comments;\n\
ALTER TABLE IF EXISTS archive.comments SET SCHEMA public;\n",
)
.expect("write");
assert_eq!(
comments_table(tmp.path()),
CommentsTable::Absent,
"nothing moved, so `comments` is still free"
);
}
#[test]
fn a_conditional_rename_only_skips_a_known_absent_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (id BIGINT);\n\
DROP TABLE legacy_comments;\n\
ALTER TABLE IF EXISTS legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert_eq!(
comments_table(tmp.path()),
CommentsTable::Absent,
"the source was dropped, so nothing was renamed"
);
std::fs::write(
dir.join("up.sql"),
"ALTER TABLE IF EXISTS legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert_eq!(
comments_table(tmp.path()),
CommentsTable::Conflicting {
missing: REQUIRED_COLUMNS.to_vec(),
columns_unknown: true,
},
"an untracked source may exist, so `comments` may be taken"
);
}
#[test]
fn create_table_as_occupies_the_name() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_ctas");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE comments AS SELECT 1 AS id;\n",
)
.expect("write");
assert!(!already_migrated(tmp.path()), "its columns are unknown");
assert!(
matches!(
comments_table(tmp.path()),
CommentsTable::Conflicting {
columns_unknown: true,
..
}
),
"the name is taken, and its columns cannot be read"
);
}
#[test]
fn ddl_verbs_accept_any_whitespace() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_create");
std::fs::create_dir_all(&dir).expect("mkdir");
let create = "CREATE\n TABLE comments (id BIGSERIAL PRIMARY KEY, body TEXT);\n";
for undo in [
"DROP\nTABLE comments;\n",
"ALTER\nTABLE comments RENAME TO archived_comments;\n",
"DROP /* old */ TABLE\tcomments;\n",
] {
std::fs::write(dir.join("up.sql"), format!("{create}{undo}")).expect("write");
assert_eq!(
comments_table(tmp.path()),
CommentsTable::Absent,
"the name is free after: {undo}"
);
}
std::fs::write(dir.join("up.sql"), create).expect("write");
assert!(conflicting_comments_table(tmp.path()));
}
#[test]
fn a_referenced_column_is_not_a_declared_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (commentable_type TEXT, commentable_id BIGINT, \
parent_id BIGINT, author_id BIGINT REFERENCES users(id), body TEXT, \
created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"legacy_comments has no `id` column of its own"
);
}
#[test]
fn declares_column_reads_element_heads_only() {
let body = "id BIGSERIAL PRIMARY KEY, \"check\" TEXT, note TEXT DEFAULT 'a, id', \
CONSTRAINT body_fk FOREIGN KEY (body) REFERENCES t(body), \
PRIMARY KEY (parent_id)";
assert!(declares_column(body, "id"));
assert!(declares_column(body, "check"));
assert!(declares_column(body, "note"));
assert!(
!declares_column(body, "body"),
"only named inside a constraint"
);
assert!(
!declares_column(body, "parent_id"),
"only named in PRIMARY KEY (…)"
);
}
#[test]
fn columns_added_before_the_rename_carry_across() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (id BIGSERIAL PRIMARY KEY);\n\
ALTER TABLE legacy_comments ADD COLUMN commentable_type TEXT;\n\
ALTER TABLE legacy_comments ADD COLUMN commentable_id BIGINT;\n\
ALTER TABLE legacy_comments ADD COLUMN parent_id BIGINT;\n\
ALTER TABLE legacy_comments ADD COLUMN author_id BIGINT;\n\
ALTER TABLE legacy_comments ADD COLUMN body TEXT;\n\
ALTER TABLE legacy_comments ADD COLUMN created_at TIMESTAMP;\n\
ALTER TABLE legacy_comments ADD COLUMN deleted_at TIMESTAMP;\n\
ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"ALTERs on the old name count once the table is renamed in"
);
}
#[test]
fn dropping_a_column_after_a_rename_in_unmakes_the_shared_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n\
ALTER TABLE comments DROP COLUMN commentable_id;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the discriminator column is gone even though the rename brought it"
);
}
#[test]
fn renaming_out_again_after_a_rename_in_leaves_no_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE legacy_comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE legacy_comments RENAME TO comments;\n\
ALTER TABLE comments RENAME TO archived_comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the table moved away again; `comments` has to be created again"
);
}
#[test]
fn two_renames_into_comments_in_one_file_last_wins() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_two_renames");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE good_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 bare_comments (id BIGSERIAL PRIMARY KEY);\n\
ALTER TABLE good_comments RENAME TO comments;\n\
ALTER TABLE bare_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the second rename replaced the polymorphic table with a bare one"
);
std::fs::write(
dir.join("up.sql"),
"CREATE TABLE bare_comments (id BIGSERIAL PRIMARY KEY);\n\
CREATE TABLE good_comments (commentable_type TEXT, commentable_id BIGINT, id BIGINT, parent_id BIGINT, author_id BIGINT, body TEXT, created_at TIMESTAMP, deleted_at TIMESTAMP);\n\
ALTER TABLE bare_comments RENAME TO comments;\n\
ALTER TABLE good_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"the last rename brought the full discriminator schema"
);
}
#[test]
fn a_rename_from_an_unknown_table_is_present_but_not_polymorphic() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
assert!(
!already_migrated(tmp.path()),
"the scan cannot verify columns it never saw"
);
assert!(
conflicting_comments_table(tmp.path()),
"the name is taken: generation refuses rather than claiming a reuse"
);
}
#[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"));
}
fn project_with_a_plain_comments_table() -> tempfile::TempDir {
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, \
created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL);\n",
)
.expect("write");
tmp
}
#[test]
fn a_plain_comments_table_blocks_the_shared_migration() {
let tmp = project_with_a_plain_comments_table();
let mut plan = Plan::new(tmp.path());
let err = push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Postgres,
false,
)
.expect_err("a plain `comments` table must block generation");
let message = err.to_string();
for missing in [
"commentable_type",
"commentable_id",
"parent_id",
"author_id",
"deleted_at",
] {
assert!(message.contains(&format!("`{missing}`")), "{message}");
}
for present in ["`id`", "`body`", "`created_at`"] {
assert!(!message.contains(present), "{present} exists:\n{message}");
}
assert!(message.contains("Rename or drop"), "{message}");
assert!(!message.contains("Add the missing columns"), "{message}");
assert!(message.contains("update or remove the model"), "{message}");
assert!(plan.actions.is_empty(), "a refusal plans no file");
}
#[test]
fn a_revert_ignores_a_plain_comments_table() {
let tmp = project_with_a_plain_comments_table();
let mut plan = Plan::new(tmp.path());
let emitted = push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Postgres,
true,
)
.expect("a revert never refuses");
assert!(!emitted, "the generator did not write this table");
assert!(plan.actions.is_empty());
}
#[test]
fn a_table_completed_by_an_alter_is_reused() {
let tmp = project_with_a_plain_comments_table();
let dir = tmp.path().join("migrations").join("0002_adopt_comments");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"ALTER TABLE comments\n\
\x20 ADD COLUMN commentable_type TEXT NOT NULL,\n\
\x20 ADD COLUMN commentable_id BIGINT NOT NULL,\n\
\x20 ADD COLUMN parent_id BIGINT REFERENCES comments(id) ON DELETE CASCADE,\n\
\x20 ADD COLUMN author_id BIGINT NOT NULL,\n\
\x20 ADD COLUMN deleted_at TIMESTAMP;\n",
)
.expect("write");
let mut plan = Plan::new(tmp.path());
let emitted = push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Postgres,
false,
)
.expect("a complete table is reused, not refused");
assert!(!emitted);
assert!(plan.actions.is_empty());
}
#[test]
fn a_plain_sqlite_comments_table_blocks_the_shared_migration() {
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 INTEGER PRIMARY KEY AUTOINCREMENT, \
body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);\n",
)
.expect("write");
let mut plan = Plan::new(tmp.path());
let err = push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Sqlite,
false,
)
.expect_err("a plain `comments` table must block generation");
assert!(err.to_string().contains("`commentable_type`"), "{err}");
assert!(plan.actions.is_empty());
}
#[test]
fn a_rename_from_an_unknown_table_says_the_columns_are_unknown() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("migrations").join("0001_rename_in");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("up.sql"),
"ALTER TABLE legacy_comments RENAME TO comments;\n",
)
.expect("write");
let mut plan = Plan::new(tmp.path());
let message = push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Postgres,
false,
)
.expect_err("the name is taken")
.to_string();
assert!(message.contains("cannot read"), "{message}");
assert!(!message.contains("Add the missing columns"), "{message}");
assert!(message.contains("Rename or drop"), "{message}");
}
#[test]
fn a_create_if_not_exists_keeps_the_existing_table() {
let tmp = tempfile::tempdir().expect("tempdir");
let first = tmp.path().join("migrations").join("0001_create_comments");
std::fs::create_dir_all(&first).expect("mkdir");
std::fs::write(first.join("up.sql"), up_sql(DatabaseBackend::Postgres)).expect("write");
let second = tmp.path().join("migrations").join("0002_noop");
std::fs::create_dir_all(&second).expect("mkdir");
std::fs::write(
second.join("up.sql"),
"CREATE TABLE IF NOT EXISTS comments (id BIGINT, body TEXT);\n",
)
.expect("write");
assert!(
already_migrated(tmp.path()),
"the IF NOT EXISTS create was a no-op"
);
std::fs::remove_dir_all(&first).expect("rm");
assert!(conflicting_comments_table(tmp.path()));
}
fn project_with(migrations: &[(&str, &str)]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
for (dir, sql) in migrations {
let dir = tmp.path().join("migrations").join(dir);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("up.sql"), sql).expect("write");
}
let models = tmp.path().join("src").join("models");
std::fs::create_dir_all(&models).expect("mkdir");
std::fs::write(
models.join("post.rs"),
"#[commentable]\npub struct Post {}\n",
)
.expect("write");
tmp
}
#[test]
fn a_full_column_comment_migration_is_still_needed() {
let full = format!(
"CREATE TABLE comments ({});\n",
REQUIRED_COLUMNS
.iter()
.map(|column| format!("{column} BIGINT"))
.collect::<Vec<_>>()
.join(", ")
);
let tmp = project_with(&[("0001_create_comments", &full)]);
let dir = tmp.path().join("migrations").join("0001_create_comments");
assert!(comments_migration_still_needed(tmp.path(), &dir, &[]));
let post = tmp.path().join("src").join("models").join("post.rs");
assert!(!comments_migration_still_needed(tmp.path(), &dir, &[post]));
}
#[test]
fn an_adopted_table_after_a_renamed_shared_one_is_still_needed() {
let ours = up_sql(DatabaseBackend::Postgres);
let tmp = project_with(&[
("0001_create_comments", &ours),
(
"0002_retire",
"ALTER TABLE comments RENAME TO legacy_comments;\n",
),
(
"0003_create_comments",
"CREATE TABLE comments (id BIGINT, body TEXT, created_at TIMESTAMP);\n",
),
(
"0004_adopt",
"ALTER TABLE comments ADD COLUMN commentable_type TEXT, ADD COLUMN commentable_id BIGINT, \
ADD COLUMN parent_id BIGINT, ADD COLUMN author_id BIGINT, ADD COLUMN deleted_at TIMESTAMP;\n",
),
]);
let plain = tmp.path().join("migrations").join("0003_create_comments");
assert!(comments_migration_still_needed(tmp.path(), &plain, &[]));
}
#[test]
fn another_migration_is_never_kept() {
let tmp = project_with(&[("0001_create_posts", "CREATE TABLE posts (id BIGINT);\n")]);
let dir = tmp.path().join("migrations").join("0001_create_posts");
assert!(!comments_migration_still_needed(tmp.path(), &dir, &[]));
}
#[test]
fn a_migration_a_later_alter_needs_is_still_needed() {
let ours = up_sql(DatabaseBackend::Postgres);
let tmp = project_with(&[
(
"0001_create_comments",
"CREATE TABLE comments (id BIGINT, body TEXT, created_at TIMESTAMP);\n",
),
(
"0002_adopt",
"ALTER TABLE comments ADD COLUMN commentable_type TEXT, ADD COLUMN commentable_id BIGINT, \
ADD COLUMN parent_id BIGINT, ADD COLUMN author_id BIGINT, ADD COLUMN deleted_at TIMESTAMP;\n",
),
(
"0003_retire",
"ALTER TABLE comments RENAME TO legacy_comments;\n",
),
("0004_create_comments", &ours),
]);
let plain = tmp.path().join("migrations").join("0001_create_comments");
assert!(comments_migration_still_needed(tmp.path(), &plain, &[]));
}
#[test]
fn a_model_still_on_the_comments_table_blocks_the_shared_migration() {
let tmp = project_with_a_plain_comments_table();
let rename = tmp.path().join("migrations").join("0002_rename");
std::fs::create_dir_all(&rename).expect("mkdir");
std::fs::write(
rename.join("up.sql"),
"ALTER TABLE comments RENAME TO notes;\n",
)
.expect("write");
let models = tmp.path().join("src").join("models");
std::fs::create_dir_all(&models).expect("mkdir");
std::fs::write(
models.join("comment.rs"),
"use crate::schema::comments;\n\n#[autumn_web::model]\npub struct Comment {}\n",
)
.expect("write");
let mut plan = Plan::new(tmp.path());
let message = push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Postgres,
false,
)
.expect_err("the `Comment` model still writes to `comments`")
.to_string();
assert!(message.contains("comment.rs"), "{message}");
assert!(plan.actions.is_empty());
std::fs::write(
models.join("comment.rs"),
"use crate::schema::notes;\n\n#[autumn_web::model(table = \"notes\")]\npub struct Comment {}\n",
)
.expect("write");
let mut plan = Plan::new(tmp.path());
assert!(
push_commentable_migration(
&mut plan,
tmp.path(),
"20260101000000",
DatabaseBackend::Postgres,
false,
)
.expect("no model uses `comments` now")
);
}
#[test]
fn a_migration_a_later_drop_needs_is_still_needed() {
let ours = up_sql(DatabaseBackend::Postgres);
let migrations = |drop: &str| {
project_with(&[
(
"0001_create_comments",
"CREATE TABLE comments (id BIGINT, body TEXT);\n",
),
("0002_drop", drop),
("0003_create_comments", &ours),
])
};
let tmp = migrations("DROP TABLE comments;\n");
let plain = tmp.path().join("migrations").join("0001_create_comments");
assert!(comments_migration_still_needed(tmp.path(), &plain, &[]));
let tmp = migrations("DROP TABLE IF EXISTS comments;\n");
let plain = tmp.path().join("migrations").join("0001_create_comments");
assert!(!comments_migration_still_needed(tmp.path(), &plain, &[]));
}
#[test]
fn only_a_model_struct_on_the_comments_table_counts() {
let tmp = tempfile::tempdir().expect("tempdir");
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).expect("mkdir");
let models = src.join("models.rs");
let check = |source: &str| {
std::fs::write(&models, source).expect("write");
model_using_comments_table(tmp.path())
};
assert_eq!(
check(
"// `crate::schema::comments` is kept, but no `#[model]` maps it.\n\
use crate::schema::{comments, posts};\n\
const NOTE: &str = \"crate::schema::comments;\";\n\
fn count() { crate::schema::comments::table; }\n\
#[autumn_web::model]\npub struct Post { pub id: i64 }\n",
),
None
);
assert_eq!(
check(
"use crate::schema::*;\n#[autumn_web::model]\npub struct Comment { pub id: i64 }\n"
),
Some(models.clone())
);
assert_eq!(
check("#[model(table = \"comments\")]\npub struct Remark { pub id: i64 }\n"),
Some(models.clone())
);
assert_eq!(
check("#[autumn_web::model(table = \"notes\")]\npub struct Comment { pub id: i64 }\n"),
None
);
assert_eq!(
check("mod inner {\n#[autumn_web::model]\npub struct Comment { pub id: i64 }\n}\n"),
Some(models.clone())
);
let fields = REQUIRED_COLUMNS
.iter()
.map(|column| format!("pub {column}: i64"))
.collect::<Vec<_>>()
.join(", ");
assert_eq!(
check(&format!(
"#[autumn_web::model]\npub struct Comment {{ {fields} }}\n"
)),
None
);
assert_eq!(
check(
"#[autumn_web::model]\npub struct Comment { pub id: i64, \
pub commentable_type: String, pub commentable_id: i64, \
pub author_id: i64, pub body: String }\n",
),
None
);
let renamed = fields.replacen(
"pub id: i64",
"#[diesel(column_name = id)] pub comment_id: i64",
1,
);
assert_eq!(
check(&format!(
"#[autumn_web::model]\npub struct Comment {{ {renamed} }}\n"
)),
None
);
}
#[test]
fn an_alter_if_exists_does_not_need_the_candidate() {
let ours = up_sql(DatabaseBackend::Postgres);
let tmp = project_with(&[
(
"0001_create_comments",
"CREATE TABLE comments (id BIGINT, body TEXT);\n",
),
(
"0002_tweak",
"ALTER TABLE IF EXISTS comments ADD COLUMN commentable_type TEXT;\n",
),
("0003_drop", "DROP TABLE IF EXISTS comments;\n"),
("0004_create_comments", &ours),
]);
let plain = tmp.path().join("migrations").join("0001_create_comments");
assert!(!comments_migration_still_needed(tmp.path(), &plain, &[]));
}
#[test]
fn only_a_live_commentable_on_comments_needs_the_table() {
let tmp = project_with(&[(
"0001_create_comments",
"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",
)]);
let dir = tmp.path().join("migrations").join("0001_create_comments");
let post = tmp.path().join("src").join("models").join("post.rs");
assert!(comments_migration_still_needed(tmp.path(), &dir, &[]));
std::fs::write(&post, "// #[commentable]\npub struct Post {}\n").expect("write");
assert!(!comments_migration_still_needed(tmp.path(), &dir, &[]));
std::fs::write(
&post,
"#[commentable(table = remarks)]\npub struct Post {}\n",
)
.expect("write");
assert!(!comments_migration_still_needed(tmp.path(), &dir, &[]));
}
#[test]
fn an_alter_on_an_untracked_column_needs_the_candidate() {
let ours = up_sql(DatabaseBackend::Postgres);
let tmp = project_with(&[
(
"0001_create_comments",
"CREATE TABLE comments (id BIGINT, body TEXT);\n",
),
(
"0002_moderate",
"ALTER TABLE comments ADD COLUMN moderation_state TEXT;\n",
),
("0003_drop", "DROP TABLE IF EXISTS comments;\n"),
("0004_create_comments", &ours),
]);
let plain = tmp.path().join("migrations").join("0001_create_comments");
assert!(comments_migration_still_needed(tmp.path(), &plain, &[]));
}
}