use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use crate::migrate::safety::{normalize_statement, split_statements, strip_block_comments};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PriorIndex {
pub name: String,
pub create_sql: String,
tokens: Vec<String>,
}
impl PriorIndex {
#[must_use]
pub fn covers(&self, column: &str) -> bool {
let needle = column.to_lowercase();
self.tokens.contains(&needle)
}
}
#[must_use]
pub fn scan_prior_indexes(migrations_dir: &Path, table: &str) -> Vec<PriorIndex> {
let statements = migration_statements(migrations_dir);
let (names_at, from) = table_identity(&statements, table);
let mut live: BTreeMap<String, (usize, PriorIndex)> = BTreeMap::new();
let mut taken: BTreeSet<String> = BTreeSet::new();
let mut seq = 0usize;
for (i, (raw, normalized)) in statements.iter().enumerate().skip(from) {
let name = &names_at[i];
if let Some(header) = parse_index_header(normalized) {
let key = normalize_identifier(header.name);
if header.if_not_exists && taken.contains(&key) {
continue;
}
taken.insert(key.clone());
if let Some(index) = parse_create_index(raw, &header, name) {
live.insert(key, (seq, index));
seq += 1;
} else {
live.remove(&key);
}
} else if let Some(dropped) = parse_dropped_index_name(normalized) {
taken.remove(&dropped);
live.remove(&dropped);
} else if let Some((old, new)) = parse_column_rename(normalized, name) {
for (_, index) in live.values_mut() {
for token in &mut index.tokens {
if *token == old {
token.clone_from(&new);
}
}
index.create_sql = rename_identifier(&index.create_sql, &old, &new);
}
} else if let Some((old, new)) = parse_table_rename(normalized)
&& old == *name
{
for (_, index) in live.values_mut() {
index.create_sql = rename_identifier(&index.create_sql, &old, &new);
}
} else if drops_table(normalized, name) {
live.clear();
}
}
let mut out: Vec<(usize, PriorIndex)> = live.into_values().collect();
out.sort_by_key(|(seq, _)| *seq);
out.into_iter().map(|(_, index)| index).collect()
}
fn migration_statements(migrations_dir: &Path) -> Vec<(String, String)> {
let Ok(entries) = std::fs::read_dir(migrations_dir) else {
return Vec::new();
};
let mut dirs: Vec<_> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
let mut out = Vec::new();
for dir in dirs {
let Ok(sql) = std::fs::read_to_string(dir.join("up.sql")) else {
continue;
};
for raw in split_statements(&strip_block_comments(&sql)) {
let normalized = normalize_statement(&blank_string_literals(&raw));
out.push((raw, normalized));
}
}
out
}
fn table_identity(statements: &[(String, String)], table: &str) -> (Vec<String>, usize) {
let mut names_at = vec![String::new(); statements.len()];
let mut current = table.to_lowercase();
let mut boundary: Option<usize> = None;
for i in (0..statements.len()).rev() {
let normalized = &statements[i].1;
if let Some((old, new)) = parse_table_rename(normalized)
&& new == current
{
current = old;
}
names_at[i].clone_from(¤t);
if boundary.is_none() {
if creates_table(normalized, ¤t) {
boundary = Some(i);
} else if drops_table(normalized, ¤t) {
boundary = Some(i + 1);
}
}
}
(names_at, boundary.unwrap_or(0))
}
fn creates_table(normalized: &str, table: &str) -> bool {
let Some(rest) = normalized.strip_prefix("create table ") else {
return false;
};
let rest = rest.strip_prefix("if not exists ").unwrap_or(rest);
rest.split([' ', '(', ';'])
.next()
.is_some_and(|named| normalize_identifier(named) == table)
}
fn parse_column_rename(normalized: &str, table: &str) -> Option<(String, String)> {
let rest = normalized.strip_prefix("alter table ")?;
let (named, rest) = rest.split_once(" rename ")?;
if normalize_identifier(named) != table {
return None;
}
let rest = rest.strip_prefix("column ").unwrap_or(rest);
let (old, rest) = rest.split_once(" to ")?;
if old.trim().is_empty() {
return None;
}
let new = rest.split([' ', ';']).next()?;
Some((normalize_identifier(old), normalize_identifier(new)))
}
fn parse_table_rename(normalized: &str) -> Option<(String, String)> {
let rest = normalized.strip_prefix("alter table ")?;
let (old, rest) = rest.split_once(" rename to ")?;
let new = rest.split([' ', ';']).next()?;
Some((normalize_identifier(old), normalize_identifier(new)))
}
fn parse_create_index(raw: &str, header: &IndexHeader<'_>, table: &str) -> Option<PriorIndex> {
let after_table = strip_on_table(header.after_name, table)?;
Some(PriorIndex {
name: raw_token_matching(raw, header.name),
create_sql: recreate_sql(raw),
tokens: identifier_tokens(after_table),
})
}
struct IndexHeader<'a> {
name: &'a str,
if_not_exists: bool,
after_name: &'a str,
}
fn parse_index_header(normalized: &str) -> Option<IndexHeader<'_>> {
let rest = normalized
.strip_prefix("create unique index ")
.or_else(|| normalized.strip_prefix("create index "))?;
let rest = rest.strip_prefix("concurrently ").unwrap_or(rest);
let (rest, if_not_exists) = rest
.strip_prefix("if not exists ")
.map_or((rest, false), |r| (r, true));
let (name, after_name) = split_index_name(rest)?;
Some(IndexHeader {
name,
if_not_exists,
after_name,
})
}
fn split_index_name(rest: &str) -> Option<(&str, &str)> {
if let Some(body) = rest.strip_prefix('"') {
let close = body.find('"')?;
return Some((&rest[..close + 2], rest[close + 2..].trim_start()));
}
let end = rest.find([' ', '('])?;
Some((&rest[..end], rest[end..].trim_start()))
}
fn strip_on_table<'a>(rest: &'a str, table: &str) -> Option<&'a str> {
let after_on = rest.strip_prefix("on ")?;
let end = after_on.find(['(', ' ']).unwrap_or(after_on.len());
let (named, tail) = after_on.split_at(end);
(normalize_identifier(named) == table).then_some(tail)
}
fn parse_dropped_index_name(normalized: &str) -> Option<String> {
let rest = normalized.strip_prefix("drop index ")?;
let rest = rest.strip_prefix("concurrently ").unwrap_or(rest);
let rest = rest.strip_prefix("if exists ").unwrap_or(rest);
let name = split_index_name(rest).map_or_else(|| rest.trim(), |(name, _)| name);
(!name.is_empty()).then(|| normalize_identifier(name))
}
fn drops_table(normalized: &str, table: &str) -> bool {
let Some(rest) = normalized.strip_prefix("drop table ") else {
return false;
};
let rest = rest.strip_prefix("if exists ").unwrap_or(rest);
rest.split([' ', ';', ','])
.next()
.is_some_and(|named| normalize_identifier(named) == table)
}
fn normalize_identifier(raw: &str) -> String {
let trimmed = raw.trim().trim_end_matches(';').trim_matches('"');
trimmed
.rsplit_once('.')
.map_or(trimmed, |(_, name)| name)
.trim_matches('"')
.to_lowercase()
}
fn raw_token_matching(raw: &str, lowercased: &str) -> String {
raw.split_whitespace()
.find(|t| t.to_lowercase() == lowercased)
.map_or_else(|| lowercased.to_owned(), str::to_owned)
}
fn recreate_sql(raw: &str) -> String {
let body: Vec<&str> = raw
.lines()
.skip_while(|l| l.trim().is_empty() || l.trim().starts_with("--"))
.collect();
let joined = body.join("\n");
let trimmed = joined.trim().trim_end_matches(';').trim_end();
let last_line_is_commented = trimmed
.lines()
.next_back()
.is_some_and(|l| blank_string_literals(l).contains("--"));
if last_line_is_commented {
format!("{trimmed}\n;")
} else {
format!("{trimmed};")
}
}
fn rename_identifier(sql: &str, old: &str, new: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut pending = String::new();
let mut in_literal = false;
let flush = |out: &mut String, pending: &mut String, next: Option<char>| {
if !pending.is_empty() {
if pending.to_lowercase() == old && next != Some('(') {
out.push_str(new);
} else {
out.push_str(pending);
}
pending.clear();
}
};
for c in sql.chars() {
if in_literal {
if c == '\'' {
in_literal = false;
}
out.push(c);
continue;
}
if c == '\'' {
flush(&mut out, &mut pending, Some(c));
in_literal = true;
out.push(c);
continue;
}
if c.is_alphanumeric() || c == '_' {
pending.push(c);
continue;
}
flush(&mut out, &mut pending, Some(c));
out.push(c);
}
flush(&mut out, &mut pending, None);
out
}
fn blank_string_literals(sql: &str) -> String {
let mut out = String::with_capacity(sql.len());
let mut in_literal = false;
for c in sql.chars() {
match c {
'\'' => {
in_literal = !in_literal;
out.push(c);
}
_ if in_literal => out.push(' '),
_ => out.push(c),
}
}
out
}
fn identifier_tokens(sql: &str) -> Vec<String> {
let sql = blank_string_literals(sql);
let mut out = Vec::new();
let mut start = None;
for (i, c) in sql.char_indices() {
if c.is_alphanumeric() || c == '_' {
start.get_or_insert(i);
continue;
}
if let Some(from) = start.take() {
push_identifier(&mut out, &sql[from..i], &sql[i..]);
}
}
if let Some(from) = start {
push_identifier(&mut out, &sql[from..], "");
}
out
}
const INDEX_GRAMMAR_KEYWORDS: &[&str] = &[
"asc", "collate", "desc", "where", "and", "or", "not", "is", "null", "in", "like", "glob",
"between", "on", "using", "true", "false",
];
fn push_identifier(out: &mut Vec<String>, token: &str, rest: &str) {
if rest.trim_start().starts_with('(') {
return;
}
let lowered = token.to_lowercase();
if INDEX_GRAMMAR_KEYWORDS.contains(&lowered.as_str()) {
return;
}
out.push(lowered);
}
#[cfg(test)]
mod tests {
use super::*;
fn tree(migrations: &[(&str, &str)]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
for (name, up) in migrations {
let dir = tmp.path().join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("up.sql"), up).unwrap();
}
tmp
}
#[test]
fn finds_a_composite_index_covering_the_column() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_author_title ON posts (author_id, title);",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert_eq!(found[0].name, "idx_posts_author_title");
assert!(found[0].covers("title"));
assert!(found[0].covers("author_id"));
assert!(!found[0].covers("body"));
}
#[test]
fn recreate_sql_is_the_original_statement() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE UNIQUE INDEX idx_posts_slug ON posts (slug);",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(
found[0].create_sql,
"CREATE UNIQUE INDEX idx_posts_slug ON posts (slug);"
);
}
#[test]
fn ignores_an_index_on_another_table() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_comments_title ON comments (title);",
)]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn a_later_drop_index_removes_it() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title);",
),
("2026_01_02_000000_b", "DROP INDEX idx_posts_title;"),
]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn a_later_drop_table_removes_every_index_on_it() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title);",
),
("2026_01_02_000000_b", "DROP TABLE posts;"),
]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn covers_a_partial_index_where_column() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_live ON posts (title) WHERE deleted_at IS NULL;",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(found[0].covers("deleted_at"), "got {found:?}");
}
#[test]
fn covers_an_expression_index_operand() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_lower_title ON posts (lower(title));",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(found[0].covers("title"), "got {found:?}");
}
#[test]
fn quoted_and_if_not_exists_forms_are_parsed() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX IF NOT EXISTS \"idx_posts_title\" ON \"posts\" (\"title\");",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert_eq!(found[0].name, "\"idx_posts_title\"");
assert!(found[0].covers("title"));
}
#[test]
fn a_commented_out_create_index_is_ignored() {
let t = tree(&[(
"2026_01_01_000000_a",
"-- CREATE INDEX idx_posts_title ON posts (title);\n",
)]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn an_absent_directory_yields_nothing() {
let tmp = tempfile::tempdir().unwrap();
assert!(scan_prior_indexes(&tmp.path().join("nope"), "posts").is_empty());
}
#[test]
fn migrations_replay_in_timestamp_order_not_readdir_order() {
let t = tree(&[
("2026_02_01_000000_z_create", "CREATE INDEX i ON posts (a);"),
("2026_03_01_000000_a_drop", "DROP INDEX i;"),
]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn a_quoted_create_matches_an_unquoted_later_drop() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX \"idx_posts_title\" ON posts (title);",
),
("2026_01_02_000000_b", "DROP INDEX idx_posts_title;"),
]);
assert!(
scan_prior_indexes(t.path(), "posts").is_empty(),
"quoting must not defeat the drop"
);
}
#[test]
fn a_block_comment_containing_a_semicolon_does_not_hide_the_index() {
let t = tree(&[(
"2026_01_01_000000_a",
"/* fast; titles */\nCREATE INDEX idx_posts_title ON posts (title);",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
}
#[test]
fn a_string_literal_in_a_where_clause_is_not_a_column() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_featured ON posts (status) WHERE status = 'title';",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(!found[0].covers("title"), "got {found:?}");
assert!(found[0].covers("status"));
}
#[test]
fn a_function_name_in_an_expression_index_is_not_a_column() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_day ON posts (date(created_at));",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(!found[0].covers("date"), "got {found:?}");
assert!(found[0].covers("created_at"));
}
#[test]
fn a_non_ascii_column_name_survives_tokenizing() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_titulo ON posts (\"título\");",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(found[0].covers("título"), "got {found:?}");
}
#[test]
fn a_double_dash_inside_a_literal_does_not_truncate_the_statement() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_live ON posts (slug) \
WHERE tag = 'a--b' AND title IS NOT NULL;",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(found[0].covers("title"), "got {found:?}");
}
#[test]
fn a_trailing_comment_does_not_swallow_the_terminator() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title)\n-- TODO: make partial\n;\n",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(
found[0].create_sql.ends_with("\n;"),
"the `;` must not land inside the comment: {:?}",
found[0].create_sql
);
}
#[test]
fn an_index_survives_a_later_table_rename() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_articles_title ON articles (title);",
),
(
"2026_01_02_000000_b",
"ALTER TABLE articles RENAME TO posts;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert!(found[0].covers("title"));
}
#[test]
fn an_index_follows_a_later_column_rename() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title);",
),
(
"2026_01_02_000000_b",
"ALTER TABLE posts RENAME COLUMN title TO headline;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert!(found[0].covers("headline"), "got {found:?}");
assert!(!found[0].covers("title"), "the old name is gone: {found:?}");
}
#[test]
fn a_reused_old_name_does_not_capture_the_new_tables_indexes() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_old_title ON articles (title);",
),
(
"2026_01_02_000000_b",
"ALTER TABLE articles RENAME TO posts;",
),
(
"2026_01_03_000000_c",
"CREATE TABLE articles (id INTEGER PRIMARY KEY, title TEXT);\n\
CREATE UNIQUE INDEX idx_new_title ON articles (title);",
),
]);
let names: Vec<String> = scan_prior_indexes(t.path(), "posts")
.into_iter()
.map(|i| i.name)
.collect();
assert_eq!(names, vec!["idx_old_title"], "got {names:?}");
}
#[test]
fn a_table_dropped_and_recreated_starts_from_the_new_one() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_gone ON posts (title);",
),
("2026_01_02_000000_b", "DROP TABLE posts;"),
(
"2026_01_03_000000_c",
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT);\n\
CREATE INDEX idx_fresh ON posts (title);",
),
]);
let names: Vec<String> = scan_prior_indexes(t.path(), "posts")
.into_iter()
.map(|i| i.name)
.collect();
assert_eq!(names, vec!["idx_fresh"], "got {names:?}");
}
#[test]
fn index_grammar_keywords_are_not_column_references() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE UNIQUE INDEX ux ON posts (title DESC) WHERE body IS NOT NULL;",
)]);
let found = scan_prior_indexes(t.path(), "posts");
for keyword in ["desc", "where", "is", "not", "null"] {
assert!(!found[0].covers(keyword), "{keyword}: {found:?}");
}
assert!(found[0].covers("title"));
assert!(found[0].covers("body"));
}
#[test]
fn a_reused_name_after_a_rename_away_starts_a_new_incarnation() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT);\n\
CREATE UNIQUE INDEX ux_old ON posts (title);",
),
(
"2026_01_02_000000_b",
"ALTER TABLE posts RENAME TO archived_posts;",
),
(
"2026_01_03_000000_c",
"CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT);\n\
CREATE INDEX ix_new ON posts (title);",
),
]);
let names: Vec<String> = scan_prior_indexes(t.path(), "posts")
.into_iter()
.map(|i| i.name)
.collect();
assert_eq!(names, vec!["ix_new"], "got {names:?}");
}
#[test]
fn a_table_rename_rewrites_the_replay_sql() {
let t = tree(&[
("2026_01_01_000000_a", "CREATE INDEX i ON articles (title);"),
(
"2026_01_02_000000_b",
"ALTER TABLE articles RENAME TO posts;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(
found[0].create_sql, "CREATE INDEX i ON posts (title);",
"got {:?}",
found[0].create_sql
);
}
#[test]
fn if_not_exists_keeps_the_original_definition() {
let t = tree(&[
("2026_01_01_000000_a", "CREATE INDEX i ON posts (body);"),
(
"2026_01_02_000000_b",
"CREATE INDEX IF NOT EXISTS i ON posts (title);",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert!(found[0].covers("body"), "got {found:?}");
assert!(!found[0].covers("title"), "got {found:?}");
}
#[test]
fn if_not_exists_respects_a_name_taken_by_another_table() {
let t = tree(&[
("2026_01_01_000000_a", "CREATE INDEX i ON comments (body);"),
(
"2026_01_02_000000_b",
"CREATE INDEX IF NOT EXISTS i ON posts (title);",
),
]);
assert!(
scan_prior_indexes(t.path(), "posts").is_empty(),
"the name was already taken"
);
}
#[test]
fn a_plain_create_on_another_table_reclaims_the_name() {
let t = tree(&[
("2026_01_01_000000_a", "CREATE INDEX i ON posts (title);"),
("2026_01_02_000000_b", "CREATE INDEX i ON comments (body);"),
]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn a_spaced_function_call_is_not_a_column() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE UNIQUE INDEX ux ON posts (lower (title));",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(!found[0].covers("lower"), "got {found:?}");
assert!(found[0].covers("title"), "got {found:?}");
}
#[test]
fn a_column_rename_rewrites_the_replay_sql_too() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX i ON posts (title, title_slug) WHERE title IS NOT NULL;",
),
(
"2026_01_02_000000_b",
"ALTER TABLE posts RENAME COLUMN title TO headline;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(
found[0].create_sql,
"CREATE INDEX i ON posts (headline, title_slug) WHERE headline IS NOT NULL;",
"`title_slug` must not be touched"
);
}
#[test]
fn a_column_rename_leaves_string_literals_and_calls_alone() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX i ON posts (title(x)) WHERE tag = 'title';",
),
(
"2026_01_02_000000_b",
"ALTER TABLE posts RENAME COLUMN title TO headline;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(
found[0].create_sql, "CREATE INDEX i ON posts (title(x)) WHERE tag = 'title';",
"a call name and a literal are not the column"
);
}
#[test]
fn a_column_rename_on_another_table_is_ignored() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title);",
),
(
"2026_01_02_000000_b",
"ALTER TABLE comments RENAME COLUMN title TO headline;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(found[0].covers("title"), "got {found:?}");
}
#[test]
fn a_table_rename_is_not_read_as_a_column_rename() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_articles_title ON articles (title);",
),
(
"2026_01_02_000000_b",
"ALTER TABLE articles RENAME TO posts;",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert!(found[0].covers("title"), "got {found:?}");
}
#[test]
fn a_quoted_index_name_containing_a_space_is_parsed() {
let t = tree(&[(
"2026_01_01_000000_a",
"CREATE INDEX \"my idx\" ON posts (title);",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert_eq!(found[0].name, "\"my idx\"");
}
#[test]
fn drop_index_if_exists_and_concurrently_forms_are_honored() {
for drop in [
"DROP INDEX IF EXISTS idx_posts_title;",
"DROP INDEX CONCURRENTLY idx_posts_title;",
"DROP INDEX CONCURRENTLY IF EXISTS idx_posts_title;",
] {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title);",
),
("2026_01_02_000000_b", drop),
]);
assert!(
scan_prior_indexes(t.path(), "posts").is_empty(),
"not dropped by: {drop}"
);
}
}
#[test]
fn drop_table_if_exists_clears_indexes_and_another_table_does_not() {
let create = (
"2026_01_01_000000_a",
"CREATE INDEX idx_posts_title ON posts (title);",
);
let t = tree(&[
create,
("2026_01_02_000000_b", "DROP TABLE IF EXISTS posts;"),
]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
let t = tree(&[create, ("2026_01_02_000000_b", "DROP TABLE comments;")]);
assert_eq!(scan_prior_indexes(t.path(), "posts").len(), 1);
}
#[test]
fn a_leading_comment_is_stripped_from_the_recreate_sql() {
let t = tree(&[(
"2026_01_01_000000_a",
"-- speeds up the archive page\nCREATE INDEX idx_posts_title ON posts (title);",
)]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(
found[0].create_sql,
"CREATE INDEX idx_posts_title ON posts (title);"
);
}
#[test]
fn a_schema_qualified_create_matches_a_bare_drop() {
let t = tree(&[
(
"2026_01_01_000000_a",
"CREATE INDEX main.idx_posts_title ON main.posts (title);",
),
("2026_01_02_000000_b", "DROP INDEX idx_posts_title;"),
]);
assert!(scan_prior_indexes(t.path(), "posts").is_empty());
}
#[test]
fn a_recreated_index_keeps_its_latest_definition() {
let t = tree(&[
("2026_01_01_000000_a", "CREATE INDEX i ON posts (title);"),
(
"2026_01_02_000000_b",
"DROP INDEX i;\nCREATE INDEX i ON posts (title, body);",
),
]);
let found = scan_prior_indexes(t.path(), "posts");
assert_eq!(found.len(), 1, "got {found:?}");
assert!(found[0].covers("body"));
}
}