Skip to content

Commit 5f3e6c8

Browse files
committed
fix(models): add migration for articles.slug column
The production articles table was created before the slug column was added to the schema. Since CREATE TABLE IF NOT EXISTS is a no-op for existing tables, the column was never added, causing INSERT failures with MySQL error 1364. Add idempotent migrations to add the slug column (with DEFAULT '') and its index to existing tables on startup. Signed-off-by: JmPotato <github@ipotato.me>
1 parent 28f0273 commit 5f3e6c8

File tree

1 file changed

+150
-2
lines changed

1 file changed

+150
-2
lines changed

src/models/mod.rs

Lines changed: 150 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,42 @@ pub async fn create_tables_within_transaction(db: &sqlx::MySqlPool) -> Result<()
8181
/// Apply incremental schema migrations for already-existing tables.
8282
/// Each migration is idempotent: errors from "already exists" are silently ignored.
8383
pub(crate) async fn run_migrations(db: &sqlx::MySqlPool) -> Result<(), Error> {
84+
// Migration: add slug column to articles for tables created before this column existed.
85+
match sqlx::query(
86+
"ALTER TABLE articles ADD COLUMN slug VARCHAR(255) NOT NULL DEFAULT '' AFTER id",
87+
)
88+
.execute(db)
89+
.await
90+
{
91+
Ok(_) => info!("migration: added slug column to articles"),
92+
Err(sqlx::Error::Database(e))
93+
if e.downcast_ref::<sqlx::mysql::MySqlDatabaseError>()
94+
.number()
95+
== 1060 =>
96+
{
97+
// MySQL error 1060: Duplicate column name - column already exists.
98+
info!("migration: slug column already exists in articles, skipping");
99+
}
100+
Err(e) => return Err(e.into()),
101+
}
102+
103+
// Migration: add index on articles.slug for tables created before this index existed.
104+
match sqlx::query("ALTER TABLE articles ADD INDEX idx_slug (slug)")
105+
.execute(db)
106+
.await
107+
{
108+
Ok(_) => info!("migration: added index on articles.slug"),
109+
Err(sqlx::Error::Database(e))
110+
if e.downcast_ref::<sqlx::mysql::MySqlDatabaseError>()
111+
.number()
112+
== 1061 =>
113+
{
114+
// MySQL error 1061: Duplicate key name - index already exists.
115+
info!("migration: index on articles.slug already exists, skipping");
116+
}
117+
Err(e) => return Err(e.into()),
118+
}
119+
84120
// Migration: add UNIQUE INDEX on users.username for tables created before this constraint.
85121
match sqlx::query("ALTER TABLE users ADD UNIQUE INDEX idx_username (username)")
86122
.execute(db)
@@ -116,6 +152,18 @@ mod tests {
116152
) CHARSET = utf8mb4;
117153
"#;
118154

155+
/// Old articles schema WITHOUT the slug column, simulating a pre-migration table.
156+
const OLD_ARTICLES_TABLE_SQL: &str = r#"
157+
CREATE TABLE IF NOT EXISTS articles (
158+
id INT AUTO_INCREMENT PRIMARY KEY,
159+
title TEXT NOT NULL,
160+
content TEXT NOT NULL,
161+
tags VARCHAR(255) NOT NULL,
162+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
163+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
164+
) CHARSET = utf8mb4;
165+
"#;
166+
119167
/// Returns a MySQL pool if TEST_DATABASE_URL is set, otherwise None (test skipped).
120168
async fn get_test_pool() -> Option<sqlx::MySqlPool> {
121169
let url = std::env::var("TEST_DATABASE_URL").ok()?;
@@ -131,6 +179,19 @@ mod tests {
131179
sqlx::query(create_sql).execute(db).await.unwrap();
132180
}
133181

182+
/// Drops and recreates the articles (and tags) table with the given schema SQL.
183+
async fn reset_articles_table(db: &sqlx::MySqlPool, create_sql: &str) {
184+
sqlx::query("DROP TABLE IF EXISTS tags, articles")
185+
.execute(db)
186+
.await
187+
.unwrap();
188+
sqlx::query(create_sql).execute(db).await.unwrap();
189+
sqlx::query(CREATE_TABLE_TAGS_SQL)
190+
.execute(db)
191+
.await
192+
.unwrap();
193+
}
194+
134195
/// Checks if a UNIQUE INDEX named `idx_username` exists on the users table.
135196
async fn has_unique_index(db: &sqlx::MySqlPool) -> bool {
136197
// SHOW INDEX returns one row per index; Non_unique=0 means unique.
@@ -147,7 +208,39 @@ mod tests {
147208
matches!(row, Some((count,)) if count > 0)
148209
}
149210

150-
// NOTE: These tests share the `users` table and must not run in parallel.
211+
/// Checks if a column exists on the given table.
212+
async fn has_column(db: &sqlx::MySqlPool, table: &str, column: &str) -> bool {
213+
let row: Option<(i64,)> = sqlx::query_as(
214+
"SELECT COUNT(*) FROM information_schema.COLUMNS \
215+
WHERE TABLE_SCHEMA = DATABASE() \
216+
AND TABLE_NAME = ? \
217+
AND COLUMN_NAME = ?",
218+
)
219+
.bind(table)
220+
.bind(column)
221+
.fetch_optional(db)
222+
.await
223+
.unwrap();
224+
matches!(row, Some((count,)) if count > 0)
225+
}
226+
227+
/// Checks if an index exists on the given table.
228+
async fn has_index(db: &sqlx::MySqlPool, table: &str, index_name: &str) -> bool {
229+
let row: Option<(i64,)> = sqlx::query_as(
230+
"SELECT COUNT(*) FROM information_schema.STATISTICS \
231+
WHERE TABLE_SCHEMA = DATABASE() \
232+
AND TABLE_NAME = ? \
233+
AND INDEX_NAME = ?",
234+
)
235+
.bind(table)
236+
.bind(index_name)
237+
.fetch_optional(db)
238+
.await
239+
.unwrap();
240+
matches!(row, Some((count,)) if count > 0)
241+
}
242+
243+
// NOTE: These tests share tables and must not run in parallel.
151244
// Run with: TEST_DATABASE_URL="mysql://..." cargo test -- --test-threads=1
152245

153246
#[tokio::test]
@@ -220,6 +313,59 @@ mod tests {
220313
reset_users_table(&pool, OLD_USERS_TABLE_SQL).await;
221314
}
222315

316+
// --- articles.slug migration ---
317+
318+
#[tokio::test]
319+
async fn test_migration_adds_slug_column_to_old_articles() {
320+
let Some(pool) = get_test_pool().await else {
321+
return;
322+
};
323+
// Simulate an old environment: articles table exists but has no slug column.
324+
reset_articles_table(&pool, OLD_ARTICLES_TABLE_SQL).await;
325+
assert!(!has_column(&pool, "articles", "slug").await);
326+
assert!(!has_index(&pool, "articles", "idx_slug").await);
327+
328+
// Migration should add the column and index.
329+
run_migrations(&pool).await.unwrap();
330+
assert!(has_column(&pool, "articles", "slug").await);
331+
assert!(has_index(&pool, "articles", "idx_slug").await);
332+
333+
// Verify that inserting an article with slug now works.
334+
sqlx::query(
335+
"INSERT INTO articles (slug, title, content, tags) VALUES ('test-slug', 'Title', 'Content', 'tag')",
336+
)
337+
.execute(&pool)
338+
.await
339+
.expect("insert with slug should succeed after migration");
340+
341+
// Verify that inserting without slug uses the default empty string.
342+
sqlx::query("INSERT INTO articles (title, content, tags) VALUES ('Title2', 'Content2', 'tag2')")
343+
.execute(&pool)
344+
.await
345+
.expect("insert without slug should succeed (DEFAULT '')");
346+
347+
reset_articles_table(&pool, OLD_ARTICLES_TABLE_SQL).await;
348+
}
349+
350+
#[tokio::test]
351+
async fn test_migration_slug_idempotent() {
352+
let Some(pool) = get_test_pool().await else {
353+
return;
354+
};
355+
// New schema already has the slug column.
356+
reset_articles_table(&pool, CREATE_TABLE_ARTICLES_SQL).await;
357+
assert!(has_column(&pool, "articles", "slug").await);
358+
359+
// Running migration multiple times should always succeed.
360+
run_migrations(&pool).await.unwrap();
361+
run_migrations(&pool).await.unwrap();
362+
run_migrations(&pool).await.unwrap();
363+
364+
reset_articles_table(&pool, OLD_ARTICLES_TABLE_SQL).await;
365+
}
366+
367+
// --- full initialization ---
368+
223369
#[tokio::test]
224370
async fn test_create_tables_runs_migration() {
225371
let Some(pool) = get_test_pool().await else {
@@ -234,12 +380,14 @@ mod tests {
234380
// Full initialization path.
235381
create_tables_within_transaction(&pool).await.unwrap();
236382

237-
// The unique index should exist after create_tables_within_transaction.
383+
// The unique index and slug column should exist after create_tables_within_transaction.
238384
assert!(has_unique_index(&pool).await);
385+
assert!(has_column(&pool, "articles", "slug").await);
239386

240387
// Running again (simulating restart) should also succeed.
241388
create_tables_within_transaction(&pool).await.unwrap();
242389
assert!(has_unique_index(&pool).await);
390+
assert!(has_column(&pool, "articles", "slug").await);
243391

244392
sqlx::query("DROP TABLE IF EXISTS tags, articles, pages, users")
245393
.execute(&pool)

0 commit comments

Comments
 (0)