@@ -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.
8383pub ( 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