Skip to main content

vespertide_query/sql/
replace_constraint.rs

1use sea_query::{Alias, ForeignKey};
2
3use vespertide_core::{TableConstraint, TableDef};
4
5use super::helpers::{build_sqlite_table_rebuild, require_table_in_schema, to_sea_fk_action};
6use super::types::{BuiltQuery, DatabaseBackend};
7use crate::error::QueryError;
8
9/// Build SQL queries to replace a constraint in-place.
10///
11/// For PostgreSQL/MySQL: DROP old FK + ADD new FK (two ALTER TABLE statements).
12/// For `SQLite`: single temp table recreation with the new constraint swapped in.
13///
14/// This avoids the double table recreation that would occur with separate
15/// `RemoveConstraint` + `AddConstraint` on `SQLite`.
16pub fn build_replace_constraint(
17    backend: DatabaseBackend,
18    table: &str,
19    from: &TableConstraint,
20    to: &TableConstraint,
21    current_schema: &[TableDef],
22    pending_constraints: &[TableConstraint],
23) -> Result<Vec<BuiltQuery>, QueryError> {
24    match (from, to) {
25        (
26            TableConstraint::ForeignKey {
27                name: old_name,
28                columns: old_columns,
29                ..
30            },
31            TableConstraint::ForeignKey {
32                name: new_name,
33                columns: new_columns,
34                ref_table,
35                ref_columns,
36                on_delete,
37                on_update,
38                ..
39            },
40        ) => {
41            if backend == DatabaseBackend::Sqlite {
42                build_sqlite_constraint_replace(
43                    backend,
44                    table,
45                    from,
46                    to,
47                    current_schema,
48                    pending_constraints,
49                )
50            } else {
51                Ok(build_direct_foreign_key_replace(
52                    table,
53                    old_name.as_deref(),
54                    old_columns,
55                    new_name.as_deref(),
56                    new_columns,
57                    ref_table,
58                    ref_columns,
59                    on_delete.as_ref(),
60                    on_update.as_ref(),
61                ))
62            }
63        }
64        // For non-FK constraints: SQLite uses single temp table, PG/MySQL uses remove + add
65        _ => {
66            if backend == DatabaseBackend::Sqlite {
67                build_sqlite_constraint_replace(
68                    backend,
69                    table,
70                    from,
71                    to,
72                    current_schema,
73                    pending_constraints,
74                )
75            } else {
76                let mut queries = super::remove_constraint::build_remove_constraint(
77                    backend,
78                    table,
79                    from,
80                    current_schema,
81                    pending_constraints,
82                )?;
83
84                // Build a modified schema with the old constraint removed and new one added
85                let modified_schema: Vec<TableDef> = current_schema
86                    .iter()
87                    .map(|t| {
88                        if t.name == table {
89                            let mut modified = t.clone();
90                            modified.constraints.retain(|c| c != from);
91                            modified.constraints.push(to.clone());
92                            modified
93                        } else {
94                            t.clone()
95                        }
96                    })
97                    .collect();
98
99                queries.extend(super::add_constraint::build_add_constraint(
100                    backend,
101                    table,
102                    to,
103                    &modified_schema,
104                    pending_constraints,
105                )?);
106                Ok(queries)
107            }
108        }
109    }
110}
111
112#[expect(
113    clippy::too_many_arguments,
114    reason = "mirrors foreign key action fields"
115)]
116fn build_direct_foreign_key_replace<T: AsRef<str>, U: AsRef<str>, V: AsRef<str>>(
117    table: &str,
118    old_name: Option<&str>,
119    old_columns: &[T],
120    new_name: Option<&str>,
121    new_columns: &[U],
122    ref_table: &str,
123    ref_columns: &[V],
124    on_delete: Option<&vespertide_core::ReferenceAction>,
125    on_update: Option<&vespertide_core::ReferenceAction>,
126) -> Vec<BuiltQuery> {
127    let old_fk_name = vespertide_naming::build_foreign_key_name(table, old_columns, old_name);
128    let fk_drop = ForeignKey::drop()
129        .name(&old_fk_name)
130        .table(Alias::new(table))
131        .to_owned();
132    let fk_create = build_replacement_foreign_key(
133        table,
134        new_name,
135        new_columns,
136        ref_table,
137        ref_columns,
138        on_delete,
139        on_update,
140    );
141
142    vec![
143        BuiltQuery::DropForeignKey(Box::new(fk_drop)),
144        BuiltQuery::CreateForeignKey(Box::new(fk_create)),
145    ]
146}
147
148fn build_replacement_foreign_key<T: AsRef<str>, U: AsRef<str>>(
149    table: &str,
150    new_name: Option<&str>,
151    new_columns: &[T],
152    ref_table: &str,
153    ref_columns: &[U],
154    on_delete: Option<&vespertide_core::ReferenceAction>,
155    on_update: Option<&vespertide_core::ReferenceAction>,
156) -> sea_query::ForeignKeyCreateStatement {
157    let new_fk_name = vespertide_naming::build_foreign_key_name(table, new_columns, new_name);
158    let mut fk_create = ForeignKey::create();
159    fk_create.name(&new_fk_name);
160    fk_create.from_tbl(Alias::new(table));
161    for col in new_columns {
162        fk_create.from_col(Alias::new(col.as_ref()));
163    }
164    fk_create.to_tbl(Alias::new(ref_table));
165    for col in ref_columns {
166        fk_create.to_col(Alias::new(col.as_ref()));
167    }
168    if let Some(action) = on_delete {
169        fk_create.on_delete(to_sea_fk_action(action));
170    }
171    if let Some(action) = on_update {
172        fk_create.on_update(to_sea_fk_action(action));
173    }
174    fk_create
175}
176
177/// `SQLite`: single temp table recreation with the constraint replaced.
178/// Works for all constraint types (FK, Check, Unique, Index, PK).
179fn build_sqlite_constraint_replace(
180    backend: DatabaseBackend,
181    table: &str,
182    from: &TableConstraint,
183    to: &TableConstraint,
184    current_schema: &[TableDef],
185    pending_constraints: &[TableConstraint],
186) -> Result<Vec<BuiltQuery>, QueryError> {
187    let table_def = require_table_in_schema(
188        current_schema,
189        table,
190        "SQLite requires current schema information to replace constraints",
191    )?;
192
193    // Build new constraints: replace old constraint with new one.
194    let new_constraints: Vec<TableConstraint> = table_def
195        .constraints
196        .iter()
197        .map(|c| if c == from { to.clone() } else { c.clone() })
198        .collect();
199
200    Ok(build_sqlite_table_rebuild(
201        backend,
202        table,
203        &table_def.columns,
204        &new_constraints,
205        &table_def.columns,
206        &table_def.constraints,
207        pending_constraints,
208    ))
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use insta::{assert_snapshot, with_settings};
215    use rstest::rstest;
216    use vespertide_core::{
217        ColumnDef, ColumnType, ReferenceAction, SimpleColumnType, TableConstraint, TableDef,
218    };
219
220    fn test_schema() -> Vec<TableDef> {
221        vec![
222            TableDef {
223                name: "users".into(),
224                columns: vec![ColumnDef {
225                    name: "id".into(),
226                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
227                    nullable: false,
228                    default: None,
229                    comment: None,
230                    primary_key: None,
231                    unique: None,
232                    index: None,
233                    foreign_key: None,
234                }],
235                constraints: vec![TableConstraint::PrimaryKey {
236                    auto_increment: false,
237                    columns: vec!["id".into()],
238                    strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
239                }],
240                description: None,
241            },
242            TableDef {
243                name: "posts".into(),
244                columns: vec![
245                    ColumnDef {
246                        name: "id".into(),
247                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
248                        nullable: false,
249                        default: None,
250                        comment: None,
251                        primary_key: None,
252                        unique: None,
253                        index: None,
254                        foreign_key: None,
255                    },
256                    ColumnDef {
257                        name: "user_id".into(),
258                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
259                        nullable: false,
260                        default: None,
261                        comment: None,
262                        primary_key: None,
263                        unique: None,
264                        index: None,
265                        foreign_key: None,
266                    },
267                ],
268                constraints: vec![
269                    TableConstraint::PrimaryKey {
270                        auto_increment: false,
271                        columns: vec!["id".into()],
272                        strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
273                    },
274                    TableConstraint::ForeignKey {
275                        name: Some("fk_user".into()),
276                        columns: vec!["user_id".into()],
277                        ref_table: "users".into(),
278                        ref_columns: vec!["id".into()],
279                        on_delete: None,
280                        on_update: None,
281                        orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
282                    },
283                ],
284                description: None,
285            },
286        ]
287    }
288
289    #[rstest]
290    #[case::postgres(DatabaseBackend::Postgres)]
291    #[case::mysql(DatabaseBackend::MySql)]
292    #[case::sqlite(DatabaseBackend::Sqlite)]
293    fn replace_fk_on_delete(#[case] backend: DatabaseBackend) {
294        let schema = test_schema();
295        let from = TableConstraint::ForeignKey {
296            name: Some("fk_user".into()),
297            columns: vec!["user_id".into()],
298            ref_table: "users".into(),
299            ref_columns: vec!["id".into()],
300            on_delete: None,
301            on_update: None,
302            orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
303        };
304        let to = TableConstraint::ForeignKey {
305            name: Some("fk_user".into()),
306            columns: vec!["user_id".into()],
307            ref_table: "users".into(),
308            ref_columns: vec!["id".into()],
309            on_delete: Some(ReferenceAction::Cascade),
310            on_update: None,
311            orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
312        };
313
314        let queries = build_replace_constraint(backend, "posts", &from, &to, &schema, &[])
315            .expect("should succeed");
316
317        let sql: Vec<String> = queries.iter().map(|q| q.build(backend)).collect();
318        let combined = sql.join(";\n");
319
320        with_settings!({
321            description => format!("replace FK on_delete for {:?}", backend),
322            omit_expression => true,
323            snapshot_suffix => format!("replace_fk_on_delete_{:?}", backend),
324        }, {
325            assert_snapshot!(combined);
326        });
327    }
328
329    #[rstest]
330    #[case::postgres(DatabaseBackend::Postgres)]
331    #[case::mysql(DatabaseBackend::MySql)]
332    #[case::sqlite(DatabaseBackend::Sqlite)]
333    fn replace_fk_on_update(#[case] backend: DatabaseBackend) {
334        let schema = test_schema();
335        let from = TableConstraint::ForeignKey {
336            name: Some("fk_user".into()),
337            columns: vec!["user_id".into()],
338            ref_table: "users".into(),
339            ref_columns: vec!["id".into()],
340            on_delete: None,
341            on_update: None,
342            orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
343        };
344        let to = TableConstraint::ForeignKey {
345            name: Some("fk_user".into()),
346            columns: vec!["user_id".into()],
347            ref_table: "users".into(),
348            ref_columns: vec!["id".into()],
349            on_delete: None,
350            on_update: Some(ReferenceAction::Cascade),
351            orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(),
352        };
353
354        let queries = build_replace_constraint(backend, "posts", &from, &to, &schema, &[])
355            .expect("should succeed");
356        let sql: Vec<String> = queries.iter().map(|q| q.build(backend)).collect();
357        let combined = sql.join(";\n");
358
359        with_settings!({
360            description => format!("replace FK on_update for {:?}", backend),
361            omit_expression => true,
362            snapshot_suffix => format!("replace_fk_on_update_{:?}", backend),
363        }, {
364            assert_snapshot!(combined);
365        });
366    }
367
368    #[rstest]
369    #[case::postgres(DatabaseBackend::Postgres)]
370    #[case::mysql(DatabaseBackend::MySql)]
371    #[case::sqlite(DatabaseBackend::Sqlite)]
372    fn replace_unique_constraint(#[case] backend: DatabaseBackend) {
373        // Non-FK constraint: PG/MySQL uses remove+add, SQLite uses temp table
374        // Multi-table schema so the non-target table hits the else branch (t.clone())
375        let schema = vec![
376            TableDef {
377                name: "other".into(),
378                description: None,
379                columns: vec![ColumnDef {
380                    name: "id".into(),
381                    r#type: ColumnType::Simple(SimpleColumnType::Integer),
382                    nullable: false,
383                    default: None,
384                    comment: None,
385                    primary_key: None,
386                    unique: None,
387                    index: None,
388                    foreign_key: None,
389                }],
390                constraints: vec![],
391            },
392            TableDef {
393                name: "users".into(),
394                description: None,
395                columns: vec![
396                    ColumnDef {
397                        name: "id".into(),
398                        r#type: ColumnType::Simple(SimpleColumnType::Integer),
399                        nullable: false,
400                        default: None,
401                        comment: None,
402                        primary_key: None,
403                        unique: None,
404                        index: None,
405                        foreign_key: None,
406                    },
407                    ColumnDef {
408                        name: "email".into(),
409                        r#type: ColumnType::Simple(SimpleColumnType::Text),
410                        nullable: false,
411                        default: None,
412                        comment: None,
413                        primary_key: None,
414                        unique: None,
415                        index: None,
416                        foreign_key: None,
417                    },
418                ],
419                constraints: vec![
420                    TableConstraint::PrimaryKey {
421                        auto_increment: false,
422                        columns: vec!["id".into()],
423                        strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
424                    },
425                    TableConstraint::Unique {
426                        name: Some("uq_email".into()),
427                        columns: vec!["email".into()],
428                        strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
429                            keep: vespertide_core::KeepPolicy::First,
430                        },
431                    },
432                ],
433            },
434        ];
435        let from = TableConstraint::Unique {
436            name: Some("uq_email".into()),
437            columns: vec!["email".into()],
438            strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
439                keep: vespertide_core::KeepPolicy::First,
440            },
441        };
442        let to = TableConstraint::Unique {
443            name: Some("uq_email_new".into()),
444            columns: vec!["email".into()],
445            strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
446                keep: vespertide_core::KeepPolicy::First,
447            },
448        };
449
450        let queries = build_replace_constraint(backend, "users", &from, &to, &schema, &[])
451            .expect("should succeed");
452        let sql: Vec<String> = queries.iter().map(|q| q.build(backend)).collect();
453        let combined = sql.join(";\n");
454
455        with_settings!({
456            description => format!("replace unique constraint for {:?}", backend),
457            omit_expression => true,
458            snapshot_suffix => format!("replace_unique_{:?}", backend),
459        }, {
460            assert_snapshot!(combined);
461        });
462    }
463
464    #[test]
465    fn replace_constraint_table_not_found_sqlite() {
466        let from = TableConstraint::Unique {
467            name: Some("uq_old".into()),
468            columns: vec!["col".into()],
469            strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
470                keep: vespertide_core::KeepPolicy::First,
471            },
472        };
473        let to = TableConstraint::Unique {
474            name: Some("uq_new".into()),
475            columns: vec!["col".into()],
476            strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
477                keep: vespertide_core::KeepPolicy::First,
478            },
479        };
480        let err =
481            build_replace_constraint(DatabaseBackend::Sqlite, "missing", &from, &to, &[], &[])
482                .unwrap_err();
483        assert!(format!("{err}").contains("missing"));
484    }
485}