Skip to main content

vespertide_query/sql/remove_constraint/
mod.rs

1mod mysql;
2mod postgres;
3mod sqlite;
4
5use vespertide_core::{TableConstraint, TableDef};
6
7use super::types::{BuiltQuery, DatabaseBackend};
8use crate::error::QueryError;
9
10// Re-export the canonical `DROP INDEX` builder from `sql::helpers` so
11// the SQLite fallback below and the `TableConstraint::Index` arms in
12// `mysql.rs` / `postgres.rs` keep resolving `super::build_drop_index_query`
13// unchanged. The implementation now lives in one place.
14pub(super) use super::helpers::build_drop_index_query;
15
16pub fn build_remove_constraint(
17    backend: DatabaseBackend,
18    table: &str,
19    constraint: &TableConstraint,
20    current_schema: &[TableDef],
21    pending_constraints: &[TableConstraint],
22) -> Result<Vec<BuiltQuery>, QueryError> {
23    if backend == DatabaseBackend::Sqlite && sqlite::requires_rebuild(constraint) {
24        return sqlite::build_remove_constraint(
25            table,
26            constraint,
27            current_schema,
28            pending_constraints,
29        );
30    }
31
32    if backend == DatabaseBackend::Postgres {
33        Ok(postgres::build_remove_constraint(table, constraint))
34    } else if backend == DatabaseBackend::MySql {
35        Ok(mysql::build_remove_constraint(table, constraint))
36    } else {
37        build_drop_index(table, constraint)
38    }
39}
40
41fn build_drop_index(
42    table: &str,
43    constraint: &TableConstraint,
44) -> Result<Vec<BuiltQuery>, QueryError> {
45    let TableConstraint::Index { name, columns } = constraint else {
46        return Err(QueryError::BackendError {
47            backend: DatabaseBackend::Sqlite,
48            message: format!(
49                "SQLite constraint '{}' requires a table rebuild",
50                constraint_kind(constraint)
51            ),
52        });
53    };
54
55    let index_name = vespertide_naming::build_index_name(table, columns, name.as_deref());
56    Ok(vec![build_drop_index_query(table, &index_name)])
57}
58
59fn constraint_kind(constraint: &TableConstraint) -> &'static str {
60    match constraint {
61        TableConstraint::PrimaryKey { .. } => "primary key",
62        TableConstraint::Unique { .. } => "unique",
63        TableConstraint::ForeignKey { .. } => "foreign key",
64        TableConstraint::Index { .. } => "index",
65        TableConstraint::Check { .. } => "check",
66        _ => unreachable!("TableConstraint is #[non_exhaustive]; all variants are matched above"),
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use crate::sql::types::DatabaseBackend;
74    use crate::test_support::joined_sql;
75    use insta::{assert_snapshot, with_settings};
76    use rstest::rstest;
77    use vespertide_core::{
78        ColumnDef, ColumnType, SimpleColumnType, StrOrBoolOrArray, TableConstraint,
79    };
80
81    fn int_col(name: &str) -> ColumnDef {
82        col(name, SimpleColumnType::Integer)
83    }
84
85    fn text_col(name: &str) -> ColumnDef {
86        col(name, SimpleColumnType::Text)
87    }
88
89    fn col(name: &str, ty: SimpleColumnType) -> ColumnDef {
90        ColumnDef {
91            name: name.into(),
92            r#type: ColumnType::Simple(ty),
93            nullable: name != "id",
94            default: None,
95            comment: None,
96            primary_key: None,
97            unique: None,
98            index: None,
99            foreign_key: None,
100        }
101    }
102
103    fn table(name: &str, columns: Vec<ColumnDef>, constraints: Vec<TableConstraint>) -> TableDef {
104        TableDef {
105            name: name.into(),
106            description: None,
107            columns,
108            constraints,
109        }
110    }
111
112    fn pk() -> TableConstraint {
113        TableConstraint::PrimaryKey {
114            columns: vec!["id".into()],
115            auto_increment: false,
116            strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
117        }
118    }
119
120    fn unique(name: Option<&str>, columns: &[&str]) -> TableConstraint {
121        TableConstraint::Unique {
122            name: name.map(Into::into),
123            columns: columns.iter().copied().map(Into::into).collect(),
124            strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
125                keep: vespertide_core::KeepPolicy::First,
126            },
127        }
128    }
129
130    fn fk(name: Option<&str>) -> TableConstraint {
131        TableConstraint::ForeignKey {
132            name: name.map(Into::into),
133            columns: vec!["user_id".into()],
134            ref_table: "users".into(),
135            ref_columns: vec!["id".into()],
136            on_delete: None,
137            on_update: None,
138            orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
139        }
140    }
141
142    fn check(name: &str) -> TableConstraint {
143        TableConstraint::Check {
144            name: name.into(),
145            expr: "age > 0".into(),
146            strategy: vespertide_core::CheckViolationStrategy::default(),
147        }
148    }
149
150    fn index(name: &str, columns: &[&str]) -> TableConstraint {
151        TableConstraint::Index {
152            name: Some(name.into()),
153            columns: columns.iter().copied().map(Into::into).collect(),
154        }
155    }
156
157    fn render(
158        backend: DatabaseBackend,
159        table_name: &str,
160        constraint: &TableConstraint,
161        schema: &[TableDef],
162    ) -> String {
163        joined_sql(
164            backend,
165            &build_remove_constraint(backend, table_name, constraint, schema, &[]).unwrap(),
166        )
167    }
168
169    fn assert_rendered(
170        backend: DatabaseBackend,
171        table_name: &str,
172        constraint: &TableConstraint,
173        schema: &[TableDef],
174        expected: &[&str],
175    ) -> String {
176        let sql = render(backend, table_name, constraint, schema);
177        for fragment in expected {
178            assert!(
179                sql.contains(fragment),
180                "Expected SQL to contain '{fragment}', got: {sql}"
181            );
182        }
183        sql
184    }
185
186    #[rstest]
187    #[case::remove_constraint_primary_key_postgres(
188        "remove_constraint_primary_key_postgres",
189        DatabaseBackend::Postgres,
190        pk(),
191        vec![int_col("id")],
192        &["DROP CONSTRAINT \"users_pkey\""]
193    )]
194    #[case::remove_constraint_primary_key_mysql(
195        "remove_constraint_primary_key_mysql",
196        DatabaseBackend::MySql,
197        pk(),
198        vec![int_col("id")],
199        &["DROP PRIMARY KEY"]
200    )]
201    #[case::remove_constraint_primary_key_sqlite(
202        "remove_constraint_primary_key_sqlite",
203        DatabaseBackend::Sqlite,
204        pk(),
205        vec![int_col("id")],
206        &["CREATE TABLE \"users_temp\""]
207    )]
208    #[case::remove_constraint_unique_named_postgres(
209        "remove_constraint_unique_named_postgres",
210        DatabaseBackend::Postgres,
211        unique(Some("uq_email"), &["email"]),
212        vec![int_col("id")],
213        &["DROP INDEX \"uq_users__uq_email\""]
214    )]
215    #[case::remove_constraint_unique_named_mysql(
216        "remove_constraint_unique_named_mysql",
217        DatabaseBackend::MySql,
218        unique(Some("uq_email"), &["email"]),
219        vec![int_col("id")],
220        &["DROP INDEX `uq_users__uq_email`"]
221    )]
222    #[case::remove_constraint_unique_named_sqlite(
223        "remove_constraint_unique_named_sqlite",
224        DatabaseBackend::Sqlite,
225        unique(Some("uq_email"), &["email"]),
226        vec![int_col("id")],
227        &["CREATE TABLE \"users_temp\""]
228    )]
229    #[case::remove_constraint_foreign_key_named_postgres(
230        "remove_constraint_foreign_key_named_postgres",
231        DatabaseBackend::Postgres,
232        fk(Some("fk_user")),
233        vec![int_col("id"), int_col("user_id")],
234        &["DROP CONSTRAINT \"fk_users__fk_user\""]
235    )]
236    #[case::remove_constraint_foreign_key_named_mysql(
237        "remove_constraint_foreign_key_named_mysql",
238        DatabaseBackend::MySql,
239        fk(Some("fk_user")),
240        vec![int_col("id"), int_col("user_id")],
241        &["DROP FOREIGN KEY `fk_users__fk_user`"]
242    )]
243    #[case::remove_constraint_foreign_key_named_sqlite(
244        "remove_constraint_foreign_key_named_sqlite",
245        DatabaseBackend::Sqlite,
246        fk(Some("fk_user")),
247        vec![int_col("id"), int_col("user_id")],
248        &["CREATE TABLE \"users_temp\""]
249    )]
250    #[case::remove_constraint_check_named_postgres(
251        "remove_constraint_check_named_postgres",
252        DatabaseBackend::Postgres,
253        check("chk_age"),
254        vec![int_col("id"), int_col("age")],
255        &["DROP CONSTRAINT \"chk_age\""]
256    )]
257    #[case::remove_constraint_check_named_mysql(
258        "remove_constraint_check_named_mysql",
259        DatabaseBackend::MySql,
260        check("chk_age"),
261        vec![int_col("id"), int_col("age")],
262        &["DROP CHECK `chk_age`"]
263    )]
264    #[case::remove_constraint_check_named_sqlite(
265        "remove_constraint_check_named_sqlite",
266        DatabaseBackend::Sqlite,
267        check("chk_age"),
268        vec![int_col("id"), int_col("age")],
269        &["CREATE TABLE \"users_temp\""]
270    )]
271    fn test_remove_constraint(
272        #[case] title: &str,
273        #[case] backend: DatabaseBackend,
274        #[case] constraint: TableConstraint,
275        #[case] columns: Vec<ColumnDef>,
276        #[case] expected: &[&str],
277    ) {
278        let schema = vec![table("users", columns, vec![constraint.clone()])];
279        let sql = assert_rendered(backend, "users", &constraint, &schema, expected);
280
281        with_settings!({ snapshot_path => "../snapshots", snapshot_suffix => format!("remove_constraint_{title}") }, {
282            assert_snapshot!(sql);
283        });
284    }
285
286    #[rstest]
287    #[case::primary_key(DatabaseBackend::Sqlite, pk())]
288    #[case::unique(DatabaseBackend::Sqlite, unique(Some("uq_email"), &["email"]))]
289    #[case::foreign_key(DatabaseBackend::Sqlite, fk(Some("fk_user")))]
290    #[case::check(DatabaseBackend::Sqlite, check("chk_age"))]
291    fn test_remove_constraint_sqlite_table_not_found(
292        #[case] backend: DatabaseBackend,
293        #[case] constraint: TableConstraint,
294    ) {
295        let result = build_remove_constraint(backend, "nonexistent_table", &constraint, &[], &[]);
296        assert!(result.is_err());
297        assert!(
298            result
299                .unwrap_err()
300                .to_string()
301                .contains("Table 'nonexistent_table' not found in current schema")
302        );
303    }
304
305    #[rstest]
306    #[case::remove_primary_key_with_index(
307        "remove_primary_key_with_index",
308        "users",
309        pk(),
310        vec![int_col("id")],
311        vec![pk(), index("idx_id", &["id"])],
312        Some("ix_users__idx_id")
313    )]
314    #[case::remove_unique_with_index(
315        "remove_unique_with_index",
316        "users",
317        unique(Some("uq_email"), &["email"]),
318        vec![int_col("id"), text_col("email")],
319        vec![unique(Some("uq_email"), &["email"]), index("idx_id", &["id"])],
320        Some("ix_users__idx_id")
321    )]
322    #[case::remove_foreign_key_with_index(
323        "remove_foreign_key_with_index",
324        "posts",
325        fk(Some("fk_user")),
326        vec![int_col("id"), int_col("user_id")],
327        vec![fk(Some("fk_user")), index("idx_user_id", &["user_id"])],
328        Some("idx_user_id")
329    )]
330    #[case::remove_check_with_index(
331        "remove_check_with_index",
332        "users",
333        check("chk_age"),
334        vec![int_col("id"), int_col("age")],
335        vec![check("chk_age"), index("idx_age", &["age"])],
336        Some("idx_age")
337    )]
338    #[case::remove_primary_key_with_unique_constraint(
339        "remove_primary_key_with_unique_constraint",
340        "users",
341        pk(),
342        vec![int_col("id"), text_col("email")],
343        vec![pk(), unique(Some("uq_email"), &["email"])],
344        None
345    )]
346    #[case::remove_unique_with_other_unique_constraint(
347        "remove_unique_with_other_unique_constraint",
348        "users",
349        unique(Some("uq_email"), &["email"]),
350        vec![int_col("id"), text_col("email"), text_col("name")],
351        vec![unique(Some("uq_email"), &["email"]), unique(Some("uq_name"), &["name"])],
352        None
353    )]
354    #[case::remove_foreign_key_with_unique_constraint(
355        "remove_foreign_key_with_unique_constraint",
356        "posts",
357        fk(Some("fk_user")),
358        vec![int_col("id"), int_col("user_id")],
359        vec![fk(Some("fk_user")), unique(Some("uq_user_id"), &["user_id"])],
360        None
361    )]
362    #[case::remove_check_with_unique_constraint(
363        "remove_check_with_unique_constraint",
364        "users",
365        check("chk_age"),
366        vec![int_col("id"), int_col("age")],
367        vec![check("chk_age"), unique(Some("uq_age"), &["age"])],
368        None
369    )]
370    #[case::remove_unique_with_other_constraints(
371        "remove_unique_with_other_constraints",
372        "users",
373        unique(Some("uq_email"), &["email"]),
374        vec![int_col("id"), text_col("email")],
375        vec![pk(), unique(Some("uq_email"), &["email"]), TableConstraint::Check { name: "chk_email".into(), expr: "email IS NOT NULL".into(), strategy: vespertide_core::CheckViolationStrategy::default() }],
376        None
377    )]
378    #[case::remove_foreign_key_with_other_constraints(
379        "remove_foreign_key_with_other_constraints",
380        "posts",
381        fk(Some("fk_user")),
382        vec![int_col("id"), int_col("user_id")],
383        vec![pk(), fk(Some("fk_user")), unique(Some("uq_user_id"), &["user_id"]), TableConstraint::Check { name: "chk_user_id".into(), expr: "user_id > 0".into(), strategy: vespertide_core::CheckViolationStrategy::default() }],
384        None
385    )]
386    #[case::remove_check_with_other_constraints(
387        "remove_check_with_other_constraints",
388        "users",
389        check("chk_age"),
390        vec![int_col("id"), int_col("age")],
391        vec![pk(), unique(Some("uq_age"), &["age"]), check("chk_age")],
392        None
393    )]
394    fn test_remove_constraint_with_companion_constraints(
395        #[case] title: &str,
396        #[case] table_name: &str,
397        #[case] constraint: TableConstraint,
398        #[case] columns: Vec<ColumnDef>,
399        #[case] constraints: Vec<TableConstraint>,
400        #[case] sqlite_fragment: Option<&str>,
401    ) {
402        for backend in [
403            DatabaseBackend::Postgres,
404            DatabaseBackend::MySql,
405            DatabaseBackend::Sqlite,
406        ] {
407            let schema = vec![table(table_name, columns.clone(), constraints.clone())];
408            let sql = render(backend, table_name, &constraint, &schema);
409
410            if matches!(backend, DatabaseBackend::Sqlite) {
411                assert!(sql.contains("CREATE TABLE"));
412                if let Some(fragment) = sqlite_fragment {
413                    assert!(sql.contains(fragment), "Expected {fragment} in {sql}");
414                }
415            } else {
416                assert!(sql.contains("DROP"));
417            }
418
419            let _ = title;
420        }
421    }
422
423    #[rstest]
424    #[case::remove_unique_without_name(
425        "remove_unique_without_name",
426        "users",
427        unique(None, &["email"]),
428        vec![int_col("id"), text_col("email")]
429    )]
430    #[case::remove_foreign_key_without_name(
431        "remove_foreign_key_without_name",
432        "posts",
433        fk(None),
434        vec![int_col("id"), int_col("user_id")]
435    )]
436    fn test_remove_constraint_without_name(
437        #[case] title: &str,
438        #[case] table_name: &str,
439        #[case] constraint: TableConstraint,
440        #[case] columns: Vec<ColumnDef>,
441    ) {
442        for backend in [
443            DatabaseBackend::Postgres,
444            DatabaseBackend::MySql,
445            DatabaseBackend::Sqlite,
446        ] {
447            let schema = vec![table(table_name, columns.clone(), vec![constraint.clone()])];
448            let sql = render(backend, table_name, &constraint, &schema);
449            if matches!(backend, DatabaseBackend::Sqlite) {
450                assert!(sql.contains("CREATE TABLE"));
451            } else {
452                assert!(sql.contains("email") || sql.contains("user_id"));
453            }
454
455            let _ = title;
456        }
457    }
458
459    #[test]
460    fn test_remove_constraint_primary_key_postgres_direct() {
461        let constraint = pk();
462        let schema = vec![table(
463            "orders",
464            vec![int_col("id")],
465            vec![constraint.clone()],
466        )];
467        let result = build_remove_constraint(
468            DatabaseBackend::Postgres,
469            "orders",
470            &constraint,
471            &schema,
472            &[],
473        )
474        .unwrap();
475        assert_eq!(result.len(), 1);
476        assert!(
477            result[0]
478                .build(DatabaseBackend::Postgres)
479                .contains("ALTER TABLE \"orders\" DROP CONSTRAINT \"orders_pkey\"")
480        );
481    }
482
483    #[test]
484    fn test_remove_constraint_primary_key_mysql_direct() {
485        let constraint = pk();
486        let schema = vec![table(
487            "orders",
488            vec![int_col("id")],
489            vec![constraint.clone()],
490        )];
491        let result =
492            build_remove_constraint(DatabaseBackend::MySql, "orders", &constraint, &schema, &[])
493                .unwrap();
494        assert_eq!(result.len(), 1);
495        assert!(
496            result[0]
497                .build(DatabaseBackend::MySql)
498                .contains("ALTER TABLE `orders` DROP PRIMARY KEY")
499        );
500    }
501
502    #[rstest]
503    #[case::remove_index_with_custom_inline_name_postgres(DatabaseBackend::Postgres)]
504    #[case::remove_index_with_custom_inline_name_mysql(DatabaseBackend::MySql)]
505    #[case::remove_index_with_custom_inline_name_sqlite(DatabaseBackend::Sqlite)]
506    fn test_remove_constraint_index_with_custom_inline_name(#[case] backend: DatabaseBackend) {
507        let constraint = index("custom_idx_email", &["email"]);
508        let mut email = text_col("email");
509        email.index = Some(StrOrBoolOrArray::Str("custom_idx_email".into()));
510        let schema = vec![table("users", vec![email], vec![])];
511
512        let sql = render(backend, "users", &constraint, &schema);
513        assert!(sql.contains("custom_idx_email"));
514
515        let _ = backend;
516    }
517
518    /// Drives line 18 — the `DatabaseBackend::Postgres` dispatch arm of
519    /// `build_remove_constraint` — for an `Index` constraint (which never
520    /// triggers the SQLite early-return on line 13-15 because
521    /// `sqlite::requires_rebuild` returns false for `Index`).
522    #[test]
523    fn build_remove_constraint_postgres_index_takes_top_level_dispatch() {
524        let constraint = index("idx_email", &["email"]);
525        let queries =
526            build_remove_constraint(DatabaseBackend::Postgres, "users", &constraint, &[], &[])
527                .unwrap();
528        assert_eq!(queries.len(), 1);
529        let sql = queries[0].build(DatabaseBackend::Postgres);
530        assert!(sql.contains("DROP INDEX"));
531    }
532
533    /// `build_drop_index` is the body of the `DatabaseBackend::Sqlite`
534    /// dispatch arm. The public `build_remove_constraint` reaches it only
535    /// when the constraint is an `Index` (everything else routes to the
536    /// SQLite rebuild path), so the `Err` branch at L26 is unreachable
537    /// via the public API. Calling `build_drop_index` directly from the
538    /// in-module test target covers the error path (L26) plus
539    /// `constraint_kind` (L34-40), and also documents that the guard
540    /// must remain in place even if `requires_rebuild` is later
541    /// expanded to leave new constraint kinds in the drop-index path.
542    #[rstest]
543    #[case::primary_key(pk(), "primary key")]
544    #[case::unique(unique(Some("uq_email"), &["email"]), "unique")]
545    #[case::foreign_key(fk(Some("fk_user")), "foreign key")]
546    #[case::check(check("chk_age"), "check")]
547    fn build_drop_index_rejects_non_index_constraint(
548        #[case] constraint: TableConstraint,
549        #[case] expected_kind: &str,
550    ) {
551        let result = build_drop_index("users", &constraint);
552        assert!(result.is_err());
553        let err = result.unwrap_err();
554        let message = err.to_string();
555        assert!(
556            message.contains(expected_kind),
557            "error message should mention '{expected_kind}', got: {message}"
558        );
559        assert!(
560            message.contains("requires a table rebuild"),
561            "error must explain why drop_index is not applicable, got: {message}"
562        );
563    }
564
565    /// Direct cover for `constraint_kind` (L34-40). Although the helper is
566    /// only used inside the error message in [`build_drop_index_rejects_non_index_constraint`],
567    /// asserting the returned label for every variant pins the mapping
568    /// and exercises every match arm explicitly.
569    #[rstest]
570    #[case::primary_key(pk(), "primary key")]
571    #[case::unique(unique(None, &["email"]), "unique")]
572    #[case::foreign_key(fk(None), "foreign key")]
573    #[case::index(index("idx_email", &["email"]), "index")]
574    #[case::check(check("chk_age"), "check")]
575    fn constraint_kind_maps_every_variant(
576        #[case] constraint: TableConstraint,
577        #[case] expected: &str,
578    ) {
579        assert_eq!(constraint_kind(&constraint), expected);
580    }
581
582    #[rstest]
583    #[case::named(Some("idx_user_email"))]
584    #[case::unnamed(None)]
585    fn remove_index_sqlite_uses_drop_index_path(#[case] name: Option<&str>) {
586        let constraint = TableConstraint::Index {
587            name: name.map(Into::into),
588            columns: vec!["email".into()],
589        };
590        let queries =
591            build_remove_constraint(DatabaseBackend::Sqlite, "users", &constraint, &[], &[])
592                .unwrap();
593        assert_eq!(queries.len(), 1);
594        assert!(
595            queries[0]
596                .build(DatabaseBackend::Sqlite)
597                .contains("DROP INDEX")
598        );
599    }
600
601    #[rstest]
602    #[case::postgres(DatabaseBackend::Postgres)]
603    #[case::mysql(DatabaseBackend::MySql)]
604    fn remove_index_non_sqlite_uses_backend_module(#[case] backend: DatabaseBackend) {
605        let constraint = TableConstraint::Index {
606            name: Some("idx_user_email".into()),
607            columns: vec!["email".into()],
608        };
609        let queries = build_remove_constraint(backend, "users", &constraint, &[], &[]).unwrap();
610        assert_eq!(queries.len(), 1);
611        assert!(queries[0].build(backend).contains("DROP INDEX"));
612    }
613
614    /// Mutant target: `sqlite::same_named_or_column_constraint` —
615    /// `candidate_columns.len() == removed_columns.len()` guard and the
616    /// `&&` joining it to the zipped-`all` column comparison. Without
617    /// the guard, an unnamed single-column unique `(email)` is treated
618    /// as "same as" a removed unnamed composite `(email, tenant_id)`
619    /// and is silently dropped from the SQLite temp-table rebuild.
620    #[rstest]
621    #[case::postgres(DatabaseBackend::Postgres)]
622    #[case::mysql(DatabaseBackend::MySql)]
623    #[case::sqlite(DatabaseBackend::Sqlite)]
624    fn remove_composite_unique_preserves_overlapping_single_column_unique(
625        #[case] backend: DatabaseBackend,
626    ) {
627        let single_email = unique(None, &["email"]);
628        let composite = unique(None, &["email", "tenant_id"]);
629        let schema = vec![table(
630            "users",
631            vec![int_col("id"), text_col("email"), int_col("tenant_id")],
632            vec![single_email.clone(), composite.clone()],
633        )];
634
635        let sql = render(backend, "users", &composite, &schema);
636
637        match backend {
638            DatabaseBackend::Sqlite => {
639                assert!(
640                    sql.contains(
641                        "CREATE UNIQUE INDEX \"uq_users__email\" ON \"users\" (\"email\")"
642                    ),
643                    "SQLite rebuild must recreate the (email) unique index \
644                     after removing the composite (email, tenant_id); got: {sql}"
645                );
646                assert!(
647                    !sql.contains("uq_users__email_tenant_id"),
648                    "Composite unique must not appear in the recreated \
649                     index set; got: {sql}"
650                );
651                assert_eq!(
652                    sql.matches("CREATE UNIQUE INDEX").count(),
653                    1,
654                    "Expected exactly one CREATE UNIQUE INDEX (single \
655                     email unique recreated, composite gone); got: {sql}"
656                );
657            }
658            DatabaseBackend::Postgres => {
659                assert!(
660                    sql.contains("DROP INDEX \"uq_users__email_tenant_id\""),
661                    "Postgres must DROP the composite-unique index by its \
662                     auto-derived name; got: {sql}"
663                );
664            }
665            DatabaseBackend::MySql => {
666                assert!(
667                    sql.contains("DROP INDEX `uq_users__email_tenant_id`"),
668                    "MySQL must DROP the composite-unique index by its \
669                     auto-derived name; got: {sql}"
670                );
671            }
672        }
673    }
674}