Skip to main content

vespertide_query/sql/delete_column/
mod.rs

1use vespertide_core::{ColumnType, ComplexColumnType, TableConstraint, TableDef};
2
3use self::direct::build_direct_delete_column;
4use self::sqlite_rebuild::build_delete_column_sqlite_temp_table;
5use super::helpers::build_drop_index_query;
6use super::types::{BuiltQuery, DatabaseBackend};
7
8mod direct;
9mod sqlite_rebuild;
10
11/// Build SQL to delete a column, optionally with DROP TYPE for enum columns (`PostgreSQL`).
12///
13/// For `SQLite`: Handles constraint removal before dropping the column:
14/// - Unique/Index constraints: Dropped via DROP INDEX
15/// - ForeignKey/PrimaryKey constraints: Uses temp table approach (recreate table without column)
16///
17/// `SQLite` doesn't cascade constraint drops when a column is dropped.
18pub fn build_delete_column(
19    backend: DatabaseBackend,
20    table: &str,
21    column: &str,
22    column_type: Option<&ColumnType>,
23    current_schema: &[TableDef],
24    pending_constraints: &[TableConstraint],
25) -> Vec<BuiltQuery> {
26    let mut stmts = Vec::new();
27
28    // SQLite: Check if we need special handling for constraints.
29    if backend == DatabaseBackend::Sqlite
30        && let Some(table_def) = current_schema.iter().find(|t| t.name == table)
31        && let Some(sqlite_queries) = sqlite_constraint_handling(
32            table,
33            column,
34            table_def,
35            column_type,
36            pending_constraints,
37            &mut stmts,
38        )
39    {
40        return sqlite_queries;
41    }
42
43    stmts.extend(build_direct_delete_column(table, column, column_type));
44    stmts
45}
46
47fn sqlite_constraint_handling(
48    table: &str,
49    column: &str,
50    table_def: &TableDef,
51    column_type: Option<&ColumnType>,
52    pending_constraints: &[TableConstraint],
53    stmts: &mut Vec<BuiltQuery>,
54) -> Option<Vec<BuiltQuery>> {
55    // If the column has an enum type, SQLite embeds a CHECK constraint in CREATE TABLE.
56    // ALTER TABLE DROP COLUMN fails if the column is referenced by any CHECK.
57    // Must use temp table approach.
58    if let Some(col_def) = table_def.columns.iter().find(|c| c.name == column)
59        && let ColumnType::Complex(ComplexColumnType::Enum { .. }) = &col_def.r#type
60    {
61        return Some(build_delete_column_sqlite_temp_table(
62            table,
63            column,
64            table_def,
65            column_type,
66            pending_constraints,
67        ));
68    }
69
70    // Handle constraints referencing the deleted column.
71    for constraint in &table_def.constraints {
72        match constraint {
73            // Check constraints may reference the column in their expression.
74            // SQLite can't DROP COLUMN if a CHECK references it — use temp table.
75            TableConstraint::Check { expr, .. } => {
76                // Check if the expression references the column (e.g. "status" IN (...)).
77                // Deliberately conservative bare-substring heuristic: it also matches the
78                // quoted form `"col"`, so false positives only cost an unnecessary rebuild.
79                if expr.contains(column) {
80                    return Some(build_delete_column_sqlite_temp_table(
81                        table,
82                        column,
83                        table_def,
84                        column_type,
85                        pending_constraints,
86                    ));
87                }
88            }
89            // For column-based constraints, check if they reference the deleted column.
90            _ if !constraint.columns().iter().any(|c| c == column) => {}
91            // FK/PK require temp table approach - return immediately.
92            TableConstraint::ForeignKey { .. } | TableConstraint::PrimaryKey { .. } => {
93                return Some(build_delete_column_sqlite_temp_table(
94                    table,
95                    column,
96                    table_def,
97                    column_type,
98                    pending_constraints,
99                ));
100            }
101            // Unique/Index: drop the index first, then drop column below.
102            TableConstraint::Unique { name, columns, .. } => {
103                let index_name = vespertide_naming::build_unique_constraint_name(
104                    table,
105                    columns,
106                    name.as_deref(),
107                );
108                stmts.push(build_drop_index_query(table, &index_name));
109            }
110            TableConstraint::Index { name, columns } => {
111                let index_name =
112                    vespertide_naming::build_index_name(table, columns, name.as_deref());
113                stmts.push(build_drop_index_query(table, &index_name));
114            }
115            _ => {
116                unreachable!("TableConstraint is #[non_exhaustive]; all variants are matched above")
117            }
118        }
119    }
120
121    None
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::sql::types::DatabaseBackend;
128    use crate::test_support::{col, joined_sql, joined_sql_semicolon};
129    use insta::{assert_snapshot, with_settings};
130    use rstest::rstest;
131    use vespertide_core::{ComplexColumnType, SimpleColumnType};
132
133    #[rstest]
134    #[case::delete_column_postgres(
135            "delete_column_postgres",
136            DatabaseBackend::Postgres,
137            &["ALTER TABLE \"users\" DROP COLUMN \"email\""]
138        )]
139    #[case::delete_column_mysql(
140            "delete_column_mysql",
141            DatabaseBackend::MySql,
142            &["ALTER TABLE `users` DROP COLUMN `email`"]
143        )]
144    #[case::delete_column_sqlite(
145            "delete_column_sqlite",
146            DatabaseBackend::Sqlite,
147            &["ALTER TABLE \"users\" DROP COLUMN \"email\""]
148        )]
149    fn test_delete_column(
150        #[case] title: &str,
151        #[case] backend: DatabaseBackend,
152        #[case] expected: &[&str],
153    ) {
154        let result = build_delete_column(backend, "users", "email", None, &[], &[]);
155        let sql = result[0].build(backend);
156        for exp in expected {
157            assert!(
158                sql.contains(exp),
159                "Expected SQL to contain '{exp}', got: {sql}"
160            );
161        }
162
163        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_{}", title) }, {
164            assert_snapshot!(sql);
165        });
166    }
167
168    #[test]
169    fn test_delete_enum_column_postgres() {
170        use vespertide_core::EnumValues;
171
172        let enum_type = ColumnType::Complex(ComplexColumnType::Enum {
173            name: "status".into(),
174            values: EnumValues::String(vec!["active".into(), "inactive".into()]),
175        });
176        let result = build_delete_column(
177            DatabaseBackend::Postgres,
178            "users",
179            "status",
180            Some(&enum_type),
181            &[],
182            &[],
183        );
184
185        // Should have 2 statements: ALTER TABLE and DROP TYPE
186        assert_eq!(result.len(), 2);
187
188        let alter_sql = result[0].build(DatabaseBackend::Postgres);
189        assert!(alter_sql.contains("DROP COLUMN"));
190
191        let drop_type_sql = result[1].build(DatabaseBackend::Postgres);
192        assert!(drop_type_sql.contains("DROP TYPE \"users_status\""));
193
194        // MySQL and SQLite should have empty DROP TYPE
195        let drop_type_mysql = result[1].build(DatabaseBackend::MySql);
196        assert!(drop_type_mysql.is_empty());
197    }
198
199    #[test]
200    fn test_delete_non_enum_column_no_drop_type() {
201        let text_type = ColumnType::Simple(SimpleColumnType::Text);
202        let result = build_delete_column(
203            DatabaseBackend::Postgres,
204            "users",
205            "name",
206            Some(&text_type),
207            &[],
208            &[],
209        );
210
211        // Should only have 1 statement: ALTER TABLE
212        assert_eq!(result.len(), 1);
213    }
214
215    #[test]
216    fn test_delete_column_sqlite_drops_unique_constraint_first() {
217        // SQLite should drop unique constraint index before dropping the column
218        let schema = vec![TableDef {
219            name: "gift".into(),
220            description: None,
221            columns: vec![
222                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
223                col("gift_code", ColumnType::Simple(SimpleColumnType::Text)),
224            ],
225            constraints: vec![TableConstraint::Unique {
226                name: None,
227                columns: vec!["gift_code".into()],
228                strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
229                    keep: vespertide_core::KeepPolicy::First,
230                },
231            }],
232        }];
233
234        let result = build_delete_column(
235            DatabaseBackend::Sqlite,
236            "gift",
237            "gift_code",
238            None,
239            &schema,
240            &[],
241        );
242
243        // Should have 2 statements: DROP INDEX then ALTER TABLE DROP COLUMN
244        assert_eq!(result.len(), 2);
245
246        let drop_index_sql = result[0].build(DatabaseBackend::Sqlite);
247        assert!(
248            drop_index_sql.contains("DROP INDEX"),
249            "Expected DROP INDEX, got: {drop_index_sql}"
250        );
251        assert!(
252            drop_index_sql.contains("uq_gift__gift_code"),
253            "Expected index name uq_gift__gift_code, got: {drop_index_sql}"
254        );
255
256        let drop_column_sql = result[1].build(DatabaseBackend::Sqlite);
257        assert!(
258            drop_column_sql.contains("DROP COLUMN"),
259            "Expected DROP COLUMN, got: {drop_column_sql}"
260        );
261    }
262
263    #[test]
264    fn test_delete_column_sqlite_drops_index_constraint_first() {
265        // SQLite should drop index before dropping the column
266        let schema = vec![TableDef {
267            name: "users".into(),
268            description: None,
269            columns: vec![
270                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
271                col("email", ColumnType::Simple(SimpleColumnType::Text)),
272            ],
273            constraints: vec![TableConstraint::Index {
274                name: None,
275                columns: vec!["email".into()],
276            }],
277        }];
278
279        let result = build_delete_column(
280            DatabaseBackend::Sqlite,
281            "users",
282            "email",
283            None,
284            &schema,
285            &[],
286        );
287
288        // Should have 2 statements: DROP INDEX then ALTER TABLE DROP COLUMN
289        assert_eq!(result.len(), 2);
290
291        let drop_index_sql = result[0].build(DatabaseBackend::Sqlite);
292        assert!(drop_index_sql.contains("DROP INDEX"));
293        assert!(drop_index_sql.contains("ix_users__email"));
294
295        let drop_column_sql = result[1].build(DatabaseBackend::Sqlite);
296        assert!(drop_column_sql.contains("DROP COLUMN"));
297    }
298
299    #[test]
300    fn test_delete_column_postgres_does_not_drop_constraints() {
301        // PostgreSQL cascades constraint drops, so we shouldn't emit extra DROP INDEX
302        let schema = vec![TableDef {
303            name: "gift".into(),
304            description: None,
305            columns: vec![
306                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
307                col("gift_code", ColumnType::Simple(SimpleColumnType::Text)),
308            ],
309            constraints: vec![TableConstraint::Unique {
310                name: None,
311                columns: vec!["gift_code".into()],
312                strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
313                    keep: vespertide_core::KeepPolicy::First,
314                },
315            }],
316        }];
317
318        let result = build_delete_column(
319            DatabaseBackend::Postgres,
320            "gift",
321            "gift_code",
322            None,
323            &schema,
324            &[],
325        );
326
327        // Should have only 1 statement: ALTER TABLE DROP COLUMN
328        assert_eq!(result.len(), 1);
329
330        let drop_column_sql = result[0].build(DatabaseBackend::Postgres);
331        assert!(drop_column_sql.contains("DROP COLUMN"));
332    }
333
334    #[test]
335    fn test_delete_column_sqlite_with_named_unique_constraint() {
336        // Test with a named unique constraint
337        let schema = vec![TableDef {
338            name: "gift".into(),
339            description: None,
340            columns: vec![
341                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
342                col("gift_code", ColumnType::Simple(SimpleColumnType::Text)),
343            ],
344            constraints: vec![TableConstraint::Unique {
345                name: Some("gift_code".into()),
346                columns: vec!["gift_code".into()],
347                strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
348                    keep: vespertide_core::KeepPolicy::First,
349                },
350            }],
351        }];
352
353        let result = build_delete_column(
354            DatabaseBackend::Sqlite,
355            "gift",
356            "gift_code",
357            None,
358            &schema,
359            &[],
360        );
361
362        assert_eq!(result.len(), 2);
363
364        let drop_index_sql = result[0].build(DatabaseBackend::Sqlite);
365        // Named constraint: uq_gift__gift_code (name is "gift_code")
366        assert!(
367            drop_index_sql.contains("uq_gift__gift_code"),
368            "Expected uq_gift__gift_code, got: {drop_index_sql}"
369        );
370    }
371
372    #[test]
373    fn test_delete_column_sqlite_with_fk_uses_temp_table() {
374        // SQLite should use temp table approach when deleting a column with FK constraint
375        let schema = vec![TableDef {
376            name: "gift".into(),
377            description: None,
378            columns: vec![
379                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
380                col("sender_id", ColumnType::Simple(SimpleColumnType::BigInt)),
381                col("message", ColumnType::Simple(SimpleColumnType::Text)),
382            ],
383            constraints: vec![TableConstraint::ForeignKey {
384                name: None,
385                columns: vec!["sender_id".into()],
386                ref_table: "user".into(),
387                ref_columns: vec!["id".into()],
388                on_delete: None,
389                on_update: None,
390                orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
391            }],
392        }];
393
394        let result = build_delete_column(
395            DatabaseBackend::Sqlite,
396            "gift",
397            "sender_id",
398            None,
399            &schema,
400            &[],
401        );
402
403        // Should use temp table approach:
404        // 1. CREATE TABLE gift_temp (without sender_id column)
405        // 2. INSERT INTO gift_temp SELECT ... FROM gift
406        // 3. DROP TABLE gift
407        // 4. ALTER TABLE gift_temp RENAME TO gift
408        assert!(
409            result.len() >= 4,
410            "Expected at least 4 statements for temp table approach, got: {}",
411            result.len()
412        );
413
414        let combined_sql = joined_sql(DatabaseBackend::Sqlite, &result);
415
416        // Verify temp table creation
417        assert!(
418            combined_sql.contains("CREATE TABLE") && combined_sql.contains("gift_temp"),
419            "Expected CREATE TABLE gift_temp, got: {combined_sql}"
420        );
421
422        // Verify the new table doesn't have sender_id column
423        assert!(
424            !combined_sql.contains("\"sender_id\"") || combined_sql.contains("DROP TABLE"),
425            "New table should not contain sender_id column"
426        );
427
428        // Verify data copy (INSERT ... SELECT)
429        assert!(
430            combined_sql.contains("INSERT INTO"),
431            "Expected INSERT INTO for data copy, got: {combined_sql}"
432        );
433
434        // Verify original table drop
435        assert!(
436            combined_sql.contains("DROP TABLE") && combined_sql.contains("\"gift\""),
437            "Expected DROP TABLE gift, got: {combined_sql}"
438        );
439
440        // Verify rename
441        assert!(
442            combined_sql.contains("RENAME"),
443            "Expected RENAME for temp table, got: {combined_sql}"
444        );
445    }
446
447    #[test]
448    fn test_delete_column_sqlite_with_fk_preserves_other_columns() {
449        // When using temp table approach, other columns should be preserved
450        let schema = vec![TableDef {
451            name: "gift".into(),
452            description: None,
453            columns: vec![
454                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
455                col("sender_id", ColumnType::Simple(SimpleColumnType::BigInt)),
456                col("receiver_id", ColumnType::Simple(SimpleColumnType::BigInt)),
457                col("message", ColumnType::Simple(SimpleColumnType::Text)),
458            ],
459            constraints: vec![
460                TableConstraint::ForeignKey {
461                    name: None,
462                    columns: vec!["sender_id".into()],
463                    ref_table: "user".into(),
464                    ref_columns: vec!["id".into()],
465                    on_delete: None,
466                    on_update: None,
467                    orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
468                },
469                TableConstraint::Index {
470                    name: None,
471                    columns: vec!["receiver_id".into()],
472                },
473            ],
474        }];
475
476        let result = build_delete_column(
477            DatabaseBackend::Sqlite,
478            "gift",
479            "sender_id",
480            None,
481            &schema,
482            &[],
483        );
484
485        let combined_sql = joined_sql(DatabaseBackend::Sqlite, &result);
486
487        // Should preserve other columns
488        assert!(combined_sql.contains("\"id\""), "Should preserve id column");
489        assert!(
490            combined_sql.contains("\"receiver_id\""),
491            "Should preserve receiver_id column"
492        );
493        assert!(
494            combined_sql.contains("\"message\""),
495            "Should preserve message column"
496        );
497
498        // Should recreate index on receiver_id (not on sender_id)
499        assert!(
500            combined_sql.contains("CREATE INDEX") && combined_sql.contains("ix_gift__receiver_id"),
501            "Should recreate index on receiver_id, got: {combined_sql}"
502        );
503    }
504
505    #[test]
506    fn test_delete_column_postgres_with_fk_does_not_use_temp_table() {
507        // PostgreSQL should NOT use temp table - just drop column directly
508        let schema = vec![TableDef {
509            name: "gift".into(),
510            description: None,
511            columns: vec![
512                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
513                col("sender_id", ColumnType::Simple(SimpleColumnType::BigInt)),
514            ],
515            constraints: vec![TableConstraint::ForeignKey {
516                name: None,
517                columns: vec!["sender_id".into()],
518                ref_table: "user".into(),
519                ref_columns: vec!["id".into()],
520                on_delete: None,
521                on_update: None,
522                orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
523            }],
524        }];
525
526        let result = build_delete_column(
527            DatabaseBackend::Postgres,
528            "gift",
529            "sender_id",
530            None,
531            &schema,
532            &[],
533        );
534
535        // Should have only 1 statement: ALTER TABLE DROP COLUMN
536        assert_eq!(
537            result.len(),
538            1,
539            "PostgreSQL should only have 1 statement, got: {}",
540            result.len()
541        );
542
543        let sql = result[0].build(DatabaseBackend::Postgres);
544        assert!(
545            sql.contains("DROP COLUMN"),
546            "Expected DROP COLUMN, got: {sql}"
547        );
548        assert!(
549            !sql.contains("gift_temp"),
550            "PostgreSQL should not use temp table"
551        );
552    }
553
554    #[test]
555    fn test_delete_column_sqlite_with_pk_uses_temp_table() {
556        // SQLite should use temp table approach when deleting a column that's part of PK
557        let schema = vec![TableDef {
558            name: "order_items".into(),
559            description: None,
560            columns: vec![
561                col("order_id", ColumnType::Simple(SimpleColumnType::Integer)),
562                col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
563                col("quantity", ColumnType::Simple(SimpleColumnType::Integer)),
564            ],
565            constraints: vec![TableConstraint::PrimaryKey {
566                auto_increment: false,
567                columns: vec!["order_id".into(), "product_id".into()],
568                strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
569            }],
570        }];
571
572        let result = build_delete_column(
573            DatabaseBackend::Sqlite,
574            "order_items",
575            "product_id",
576            None,
577            &schema,
578            &[],
579        );
580
581        // Should use temp table approach
582        assert!(
583            result.len() >= 4,
584            "Expected at least 4 statements for temp table approach, got: {}",
585            result.len()
586        );
587
588        let combined_sql = joined_sql(DatabaseBackend::Sqlite, &result);
589
590        assert!(
591            combined_sql.contains("order_items_temp"),
592            "Should use temp table approach for PK column deletion"
593        );
594    }
595
596    #[test]
597    fn test_delete_column_sqlite_unique_on_different_column_not_dropped() {
598        // When deleting a column in SQLite, UNIQUE constraints on OTHER columns should NOT be dropped
599        // This tests line 46's condition: only drop constraints that reference the deleted column
600        let schema = vec![TableDef {
601            name: "users".into(),
602            description: None,
603            columns: vec![
604                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
605                col("email", ColumnType::Simple(SimpleColumnType::Text)),
606                col("nickname", ColumnType::Simple(SimpleColumnType::Text)),
607            ],
608            constraints: vec![
609                // UNIQUE on email (the column we're NOT deleting)
610                TableConstraint::Unique {
611                    name: None,
612                    columns: vec!["email".into()],
613                    strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
614                        keep: vespertide_core::KeepPolicy::First,
615                    },
616                },
617            ],
618        }];
619
620        // Delete nickname, which does NOT have the unique constraint
621        let result = build_delete_column(
622            DatabaseBackend::Sqlite,
623            "users",
624            "nickname",
625            None,
626            &schema,
627            &[],
628        );
629
630        // Should only have 1 statement: ALTER TABLE DROP COLUMN (no DROP INDEX needed)
631        assert_eq!(
632            result.len(),
633            1,
634            "Should not drop UNIQUE on email when deleting nickname, got: {} statements",
635            result.len()
636        );
637
638        let sql = result[0].build(DatabaseBackend::Sqlite);
639        assert!(
640            sql.contains("DROP COLUMN"),
641            "Expected DROP COLUMN, got: {sql}"
642        );
643        assert!(
644            !sql.contains("DROP INDEX"),
645            "Should NOT drop the email UNIQUE constraint when deleting nickname"
646        );
647    }
648
649    #[test]
650    fn test_delete_column_sqlite_temp_table_filters_constraints_correctly() {
651        // When using temp table approach, constraints referencing the deleted column should be excluded,
652        // but constraints on OTHER columns should be preserved
653        // This tests lines 122-124: filter constraints by column reference
654        let schema = vec![TableDef {
655            name: "orders".into(),
656            description: None,
657            columns: vec![
658                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
659                col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
660                col("status", ColumnType::Simple(SimpleColumnType::Text)),
661                col(
662                    "created_at",
663                    ColumnType::Simple(SimpleColumnType::Timestamp),
664                ),
665            ],
666            constraints: vec![
667                // FK on user_id (column we're deleting) - should be excluded
668                TableConstraint::ForeignKey {
669                    name: None,
670                    columns: vec!["user_id".into()],
671                    ref_table: "users".into(),
672                    ref_columns: vec!["id".into()],
673                    on_delete: None,
674                    on_update: None,
675                    orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
676                },
677                // Index on created_at (different column) - should be preserved and recreated
678                TableConstraint::Index {
679                    name: None,
680                    columns: vec!["created_at".into()],
681                },
682                // Another FK on a different column - should be preserved
683                TableConstraint::ForeignKey {
684                    name: None,
685                    columns: vec!["status".into()],
686                    ref_table: "statuses".into(),
687                    ref_columns: vec!["code".into()],
688                    on_delete: None,
689                    on_update: None,
690                    orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
691                },
692            ],
693        }];
694
695        let result = build_delete_column(
696            DatabaseBackend::Sqlite,
697            "orders",
698            "user_id",
699            None,
700            &schema,
701            &[],
702        );
703
704        let combined_sql = joined_sql(DatabaseBackend::Sqlite, &result);
705
706        // Should use temp table approach (FK triggers it)
707        assert!(
708            combined_sql.contains("orders_temp"),
709            "Should use temp table approach for FK column deletion"
710        );
711
712        // Index on created_at should be recreated after rename
713        assert!(
714            combined_sql.contains("ix_orders__created_at"),
715            "Index on created_at should be recreated, got: {combined_sql}"
716        );
717
718        // The FK on user_id should NOT appear (deleted column)
719        // But the FK on status should be preserved
720        assert!(
721            combined_sql.contains("REFERENCES \"statuses\""),
722            "FK on status should be preserved, got: {combined_sql}"
723        );
724
725        // Count FK references - should only be 1 (status FK, not user_id FK)
726        let fk_patterns = combined_sql.matches("REFERENCES").count();
727        assert_eq!(
728            fk_patterns, 1,
729            "Only the FK on status should exist (not the one on user_id), got: {combined_sql}"
730        );
731    }
732
733    // ==================== Snapshot Tests ====================
734
735    #[rstest]
736    #[case::postgres("postgres", DatabaseBackend::Postgres)]
737    #[case::mysql("mysql", DatabaseBackend::MySql)]
738    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
739    fn test_delete_column_with_unique_constraint(
740        #[case] title: &str,
741        #[case] backend: DatabaseBackend,
742    ) {
743        let schema = vec![TableDef {
744            name: "users".into(),
745            description: None,
746            columns: vec![
747                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
748                col("email", ColumnType::Simple(SimpleColumnType::Text)),
749                col("name", ColumnType::Simple(SimpleColumnType::Text)),
750            ],
751            constraints: vec![TableConstraint::Unique {
752                name: None,
753                columns: vec!["email".into()],
754                strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
755                    keep: vespertide_core::KeepPolicy::First,
756                },
757            }],
758        }];
759
760        let result = build_delete_column(backend, "users", "email", None, &schema, &[]);
761        let sql = joined_sql_semicolon(backend, &result);
762
763        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_unique_{}", title) }, {
764            assert_snapshot!(sql);
765        });
766    }
767
768    #[rstest]
769    #[case::postgres("postgres", DatabaseBackend::Postgres)]
770    #[case::mysql("mysql", DatabaseBackend::MySql)]
771    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
772    fn test_delete_column_with_index_constraint(
773        #[case] title: &str,
774        #[case] backend: DatabaseBackend,
775    ) {
776        let schema = vec![TableDef {
777            name: "posts".into(),
778            description: None,
779            columns: vec![
780                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
781                col(
782                    "created_at",
783                    ColumnType::Simple(SimpleColumnType::Timestamp),
784                ),
785                col("title", ColumnType::Simple(SimpleColumnType::Text)),
786            ],
787            constraints: vec![TableConstraint::Index {
788                name: None,
789                columns: vec!["created_at".into()],
790            }],
791        }];
792
793        let result = build_delete_column(backend, "posts", "created_at", None, &schema, &[]);
794        let sql = joined_sql_semicolon(backend, &result);
795
796        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_index_{}", title) }, {
797            assert_snapshot!(sql);
798        });
799    }
800
801    #[rstest]
802    #[case::postgres("postgres", DatabaseBackend::Postgres)]
803    #[case::mysql("mysql", DatabaseBackend::MySql)]
804    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
805    fn test_delete_column_with_fk_constraint(
806        #[case] title: &str,
807        #[case] backend: DatabaseBackend,
808    ) {
809        let schema = vec![TableDef {
810            name: "orders".into(),
811            description: None,
812            columns: vec![
813                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
814                col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
815                col("total", ColumnType::Simple(SimpleColumnType::Integer)),
816            ],
817            constraints: vec![TableConstraint::ForeignKey {
818                name: None,
819                columns: vec!["user_id".into()],
820                ref_table: "users".into(),
821                ref_columns: vec!["id".into()],
822                on_delete: None,
823                on_update: None,
824                orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
825            }],
826        }];
827
828        let result = build_delete_column(backend, "orders", "user_id", None, &schema, &[]);
829        let sql = joined_sql_semicolon(backend, &result);
830
831        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_fk_{}", title) }, {
832            assert_snapshot!(sql);
833        });
834    }
835
836    #[rstest]
837    #[case::postgres("postgres", DatabaseBackend::Postgres)]
838    #[case::mysql("mysql", DatabaseBackend::MySql)]
839    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
840    fn test_delete_column_with_pk_constraint(
841        #[case] title: &str,
842        #[case] backend: DatabaseBackend,
843    ) {
844        let schema = vec![TableDef {
845            name: "order_items".into(),
846            description: None,
847            columns: vec![
848                col("order_id", ColumnType::Simple(SimpleColumnType::Integer)),
849                col("product_id", ColumnType::Simple(SimpleColumnType::Integer)),
850                col("quantity", ColumnType::Simple(SimpleColumnType::Integer)),
851            ],
852            constraints: vec![TableConstraint::PrimaryKey {
853                auto_increment: false,
854                columns: vec!["order_id".into(), "product_id".into()],
855                strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
856            }],
857        }];
858
859        let result = build_delete_column(backend, "order_items", "product_id", None, &schema, &[]);
860        let sql = joined_sql_semicolon(backend, &result);
861
862        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_pk_{}", title) }, {
863            assert_snapshot!(sql);
864        });
865    }
866
867    #[rstest]
868    #[case::postgres("postgres", DatabaseBackend::Postgres)]
869    #[case::mysql("mysql", DatabaseBackend::MySql)]
870    #[case::sqlite("sqlite", DatabaseBackend::Sqlite)]
871    fn test_delete_column_with_fk_and_index_constraints(
872        #[case] title: &str,
873        #[case] backend: DatabaseBackend,
874    ) {
875        // Complex case: FK on the deleted column + Index on another column
876        let schema = vec![TableDef {
877            name: "orders".into(),
878            description: None,
879            columns: vec![
880                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
881                col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
882                col(
883                    "created_at",
884                    ColumnType::Simple(SimpleColumnType::Timestamp),
885                ),
886                col("total", ColumnType::Simple(SimpleColumnType::Integer)),
887            ],
888            constraints: vec![
889                TableConstraint::ForeignKey {
890                    name: None,
891                    columns: vec!["user_id".into()],
892                    ref_table: "users".into(),
893                    ref_columns: vec!["id".into()],
894                    on_delete: None,
895                    on_update: None,
896                    orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
897                },
898                TableConstraint::Index {
899                    name: None,
900                    columns: vec!["created_at".into()],
901                },
902            ],
903        }];
904
905        let result = build_delete_column(backend, "orders", "user_id", None, &schema, &[]);
906        let sql = joined_sql_semicolon(backend, &result);
907
908        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("delete_column_with_fk_and_index_{}", title) }, {
909            assert_snapshot!(sql);
910        });
911    }
912
913    #[test]
914    fn test_delete_column_sqlite_temp_table_with_enum_column() {
915        // SQLite temp table approach with enum column type
916        // This tests lines 122-124: enum type drop in temp table function
917        use vespertide_core::EnumValues;
918
919        let enum_type = ColumnType::Complex(ComplexColumnType::Enum {
920            name: "order_status".into(),
921            values: EnumValues::String(vec![
922                "pending".into(),
923                "shipped".into(),
924                "delivered".into(),
925            ]),
926        });
927
928        let schema = vec![TableDef {
929            name: "orders".into(),
930            description: None,
931            columns: vec![
932                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
933                col("user_id", ColumnType::Simple(SimpleColumnType::BigInt)),
934                col("status", enum_type.clone()),
935            ],
936            constraints: vec![TableConstraint::ForeignKey {
937                name: None,
938                columns: vec!["user_id".into()],
939                ref_table: "users".into(),
940                ref_columns: vec!["id".into()],
941                on_delete: None,
942                on_update: None,
943                orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
944            }],
945        }];
946
947        // Delete the FK column (user_id) with an enum type - triggers temp table AND enum drop
948        let result = build_delete_column(
949            DatabaseBackend::Sqlite,
950            "orders",
951            "user_id",
952            Some(&enum_type),
953            &schema,
954            &[],
955        );
956
957        // Should use temp table approach (FK triggers it) + DROP TYPE at end
958        assert!(
959            result.len() >= 4,
960            "Expected at least 4 statements for temp table approach, got: {}",
961            result.len()
962        );
963
964        let combined_sql = joined_sql(DatabaseBackend::Sqlite, &result);
965
966        // Verify temp table approach
967        assert!(
968            combined_sql.contains("orders_temp"),
969            "Should use temp table approach"
970        );
971
972        // The DROP TYPE statement should be empty for SQLite (only applies to PostgreSQL)
973        // but the code path should still be executed
974        let last_stmt = result.last().unwrap();
975        let last_sql = last_stmt.build(DatabaseBackend::Sqlite);
976        // SQLite doesn't have DROP TYPE, so it should be empty string
977        assert!(
978            last_sql.is_empty() || !last_sql.contains("DROP TYPE"),
979            "SQLite should not emit DROP TYPE"
980        );
981
982        // Verify it DOES emit DROP TYPE for PostgreSQL
983        let pg_last_sql = last_stmt.build(DatabaseBackend::Postgres);
984        assert!(
985            pg_last_sql.contains("DROP TYPE"),
986            "PostgreSQL should emit DROP TYPE, got: {pg_last_sql}"
987        );
988    }
989
990    #[test]
991    fn test_delete_column_sqlite_with_check_constraint_referencing_column() {
992        // When a CHECK constraint references the column being deleted,
993        // SQLite can't use ALTER TABLE DROP COLUMN — must use temp table approach.
994        let schema = vec![TableDef {
995            name: "orders".into(),
996            description: None,
997            columns: vec![
998                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
999                col("amount", ColumnType::Simple(SimpleColumnType::Integer)),
1000            ],
1001            constraints: vec![TableConstraint::Check {
1002                name: "check_positive".into(),
1003                expr: "amount > 0".into(),
1004                strategy: vespertide_core::CheckViolationStrategy::default(),
1005            }],
1006        }];
1007
1008        // Delete amount column — CHECK references it, so temp table is needed
1009        let result = build_delete_column(
1010            DatabaseBackend::Sqlite,
1011            "orders",
1012            "amount",
1013            None,
1014            &schema,
1015            &[],
1016        );
1017
1018        // Should use temp table approach (CREATE temp, INSERT, DROP, RENAME)
1019        assert!(
1020            result.len() >= 4,
1021            "Expected temp table approach (>=4 stmts), got: {} statements",
1022            result.len()
1023        );
1024
1025        let sql = result[0].build(DatabaseBackend::Sqlite);
1026        assert!(
1027            sql.contains("orders_temp"),
1028            "Expected temp table creation, got: {sql}"
1029        );
1030        // The CHECK constraint referencing "amount" should NOT be in the temp table
1031        assert!(
1032            !sql.contains("check_positive"),
1033            "CHECK referencing deleted column should be removed, got: {sql}"
1034        );
1035    }
1036
1037    /// Mutant target: `sqlite_rebuild.rs` line 46 —
1038    /// `!expr.contains("\"col\"") && !expr.contains(col)` — the
1039    /// CHECK-constraint filter for SQLite temp-table rebuild after the
1040    /// referenced column is dropped. Pins BOTH quoted and unquoted shapes.
1041    #[rstest]
1042    #[case::sqlite_unquoted(DatabaseBackend::Sqlite, "age > 0")]
1043    #[case::sqlite_quoted(DatabaseBackend::Sqlite, r#""age" > 0"#)]
1044    #[case::postgres_unquoted(DatabaseBackend::Postgres, "age > 0")]
1045    #[case::postgres_quoted(DatabaseBackend::Postgres, r#""age" > 0"#)]
1046    #[case::mysql_unquoted(DatabaseBackend::MySql, "age > 0")]
1047    #[case::mysql_quoted(DatabaseBackend::MySql, r#""age" > 0"#)]
1048    fn delete_column_removes_check_constraint_referencing_it(
1049        #[case] backend: DatabaseBackend,
1050        #[case] check_expr: &str,
1051    ) {
1052        let schema = vec![TableDef {
1053            name: "people".into(),
1054            description: None,
1055            columns: vec![
1056                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1057                col("age", ColumnType::Simple(SimpleColumnType::Integer)),
1058            ],
1059            constraints: vec![TableConstraint::Check {
1060                name: "chk_age_positive".into(),
1061                expr: check_expr.into(),
1062                strategy: vespertide_core::CheckViolationStrategy::default(),
1063            }],
1064        }];
1065
1066        let queries = build_delete_column(backend, "people", "age", None, &schema, &[]);
1067        let sql = joined_sql(backend, &queries);
1068
1069        if backend == DatabaseBackend::Sqlite {
1070            assert!(
1071                !sql.contains("chk_age_positive"),
1072                "SQLite rebuild must omit the CHECK '{check_expr}' that \
1073                 references the deleted column; got: {sql}"
1074            );
1075            assert!(
1076                !sql.contains(check_expr),
1077                "SQLite rebuild must not echo the CHECK expression \
1078                 '{check_expr}' (it references the deleted column); got: {sql}"
1079            );
1080            assert!(
1081                sql.contains("people_temp"),
1082                "expected SQLite temp-table rebuild for 'people'; got: {sql}"
1083            );
1084        } else {
1085            assert!(
1086                !queries.is_empty(),
1087                "{backend:?}: expected DROP COLUMN output, got empty"
1088            );
1089            assert!(
1090                sql.contains("DROP COLUMN"),
1091                "{backend:?}: expected DROP COLUMN, got: {sql}"
1092            );
1093        }
1094    }
1095
1096    #[test]
1097    fn test_delete_column_sqlite_with_check_constraint_not_referencing_column() {
1098        // When a CHECK constraint does NOT reference the column being deleted,
1099        // simple DROP COLUMN should work.
1100        let schema = vec![TableDef {
1101            name: "orders".into(),
1102            description: None,
1103            columns: vec![
1104                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
1105                col("amount", ColumnType::Simple(SimpleColumnType::Integer)),
1106                col("note", ColumnType::Simple(SimpleColumnType::Text)),
1107            ],
1108            constraints: vec![TableConstraint::Check {
1109                name: "check_positive".into(),
1110                expr: "amount > 0".into(),
1111                strategy: vespertide_core::CheckViolationStrategy::default(),
1112            }],
1113        }];
1114
1115        // Delete "note" column — CHECK only references "amount", not "note"
1116        let result = build_delete_column(
1117            DatabaseBackend::Sqlite,
1118            "orders",
1119            "note",
1120            None,
1121            &schema,
1122            &[],
1123        );
1124
1125        assert_eq!(
1126            result.len(),
1127            1,
1128            "Unrelated CHECK should be skipped, got: {} statements",
1129            result.len()
1130        );
1131
1132        let sql = result[0].build(DatabaseBackend::Sqlite);
1133        assert!(
1134            sql.contains("DROP COLUMN"),
1135            "Expected DROP COLUMN, got: {sql}"
1136        );
1137    }
1138}