1mod mysql;
2mod postgres;
3mod sqlite;
4
5use vespertide_core::{TableConstraint, TableDef};
6
7use super::types::{BuiltQuery, DatabaseBackend};
8use crate::error::QueryError;
9
10pub(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 #[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 #[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 #[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 #[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}