Skip to content

Commit 07af049

Browse files
committed
Fix idx issue
1 parent 1627394 commit 07af049

File tree

24 files changed

+367
-207
lines changed

24 files changed

+367
-207
lines changed

Cargo.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespertide-planner/src/apply.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,57 @@ pub fn apply_action(
179179
.find(|t| t.name == *table)
180180
.ok_or_else(|| PlannerError::TableNotFound(table.clone()))?;
181181
tbl.constraints.retain(|c| c != constraint);
182+
183+
// Also clear inline column fields that correspond to the removed constraint
184+
// This ensures normalize() won't re-add the constraint from inline fields
185+
match constraint {
186+
TableConstraint::Unique { name, columns } => {
187+
// For unnamed single-column unique constraints, clear the column's inline unique
188+
if name.is_none()
189+
&& columns.len() == 1
190+
&& let Some(col) = tbl.columns.iter_mut().find(|c| c.name == columns[0])
191+
{
192+
col.unique = None;
193+
}
194+
// For named constraints, clear inline unique references to this constraint name
195+
if let Some(constraint_name) = name {
196+
for col in &mut tbl.columns {
197+
if let Some(vespertide_core::StrOrBoolOrArray::Array(names)) =
198+
&mut col.unique
199+
{
200+
names.retain(|n| n != constraint_name);
201+
if names.is_empty() {
202+
col.unique = None;
203+
}
204+
} else if let Some(vespertide_core::StrOrBoolOrArray::Str(n)) =
205+
&col.unique
206+
&& n == constraint_name
207+
{
208+
col.unique = None;
209+
}
210+
}
211+
}
212+
}
213+
TableConstraint::PrimaryKey { columns, .. } => {
214+
// Clear inline primary_key for columns in this constraint
215+
for col_name in columns {
216+
if let Some(col) = tbl.columns.iter_mut().find(|c| &c.name == col_name) {
217+
col.primary_key = None;
218+
}
219+
}
220+
}
221+
TableConstraint::ForeignKey { columns, .. } => {
222+
// Clear inline foreign_key for columns in this constraint
223+
for col_name in columns {
224+
if let Some(col) = tbl.columns.iter_mut().find(|c| &c.name == col_name) {
225+
col.foreign_key = None;
226+
}
227+
}
228+
}
229+
TableConstraint::Check { .. } => {
230+
// Check constraints don't have inline representation
231+
}
232+
}
182233
Ok(())
183234
}
184235
}

crates/vespertide-planner/src/schema.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,76 @@ mod tests {
160160
let users = schema.iter().find(|t| t.name == "users").unwrap();
161161
assert_eq!(users, &expected_users);
162162
}
163+
164+
/// Test that RemoveConstraint works when table was created with both
165+
/// inline unique column AND table-level unique constraint for the same column
166+
#[test]
167+
fn remove_constraint_with_inline_and_table_level_unique() {
168+
use vespertide_core::StrOrBoolOrArray;
169+
170+
// Simulate migration 0001: CreateTable with both inline unique and table-level constraint
171+
let create_plan = MigrationPlan {
172+
comment: None,
173+
created_at: None,
174+
version: 1,
175+
actions: vec![MigrationAction::CreateTable {
176+
table: "users".into(),
177+
columns: vec![ColumnDef {
178+
name: "email".into(),
179+
r#type: ColumnType::Simple(SimpleColumnType::Text),
180+
nullable: false,
181+
default: None,
182+
comment: None,
183+
primary_key: None,
184+
unique: Some(StrOrBoolOrArray::Bool(true)), // inline unique
185+
index: None,
186+
foreign_key: None,
187+
}],
188+
constraints: vec![TableConstraint::Unique {
189+
name: None,
190+
columns: vec!["email".into()],
191+
}], // table-level unique (duplicate!)
192+
}],
193+
};
194+
195+
// Migration 0002: RemoveConstraint
196+
let remove_plan = MigrationPlan {
197+
comment: None,
198+
created_at: None,
199+
version: 2,
200+
actions: vec![MigrationAction::RemoveConstraint {
201+
table: "users".into(),
202+
constraint: TableConstraint::Unique {
203+
name: None,
204+
columns: vec!["email".into()],
205+
},
206+
}],
207+
};
208+
209+
let schema = schema_from_plans(&[create_plan, remove_plan]).unwrap();
210+
let users = schema.iter().find(|t| t.name == "users").unwrap();
211+
212+
println!("Constraints after apply: {:?}", users.constraints);
213+
println!("Column unique field: {:?}", users.columns[0].unique);
214+
215+
// After apply_action:
216+
// - constraints is empty (RemoveConstraint removed the table-level one)
217+
// - but column still has unique: Some(Bool(true))!
218+
219+
// Now simulate what diff_schemas does - it normalizes the baseline
220+
let normalized = users.clone().normalize().unwrap();
221+
println!("Constraints after normalize: {:?}", normalized.constraints);
222+
223+
// After normalize:
224+
// - inline unique (column.unique = true) is converted to table-level constraint
225+
// - So we'd still have one unique constraint!
226+
227+
// This is the bug: diff_schemas normalizes both baseline and target,
228+
// but the baseline still has inline unique that gets re-added.
229+
assert!(
230+
normalized.constraints.is_empty(),
231+
"Expected no constraints after normalize, but got: {:?}",
232+
normalized.constraints
233+
);
234+
}
163235
}

crates/vespertide-query/src/sql/add_constraint.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,14 @@ pub fn build_add_constraint(
153153
}
154154
TableConstraint::Unique { name, columns } => {
155155
// SQLite does not support ALTER TABLE ... ADD CONSTRAINT UNIQUE
156-
let mut idx = Index::create().table(Alias::new(table)).unique().to_owned();
157-
if let Some(n) = name {
158-
idx = idx.name(n).to_owned();
159-
}
156+
// Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns}
157+
let index_name =
158+
super::helpers::build_unique_constraint_name(table, columns, name.as_deref());
159+
let mut idx = Index::create()
160+
.table(Alias::new(table))
161+
.name(&index_name)
162+
.unique()
163+
.to_owned();
160164
for col in columns {
161165
idx = idx.col(Alias::new(col)).to_owned();
162166
}
@@ -394,17 +398,17 @@ mod tests {
394398
#[case::add_constraint_unique_named_postgres(
395399
"add_constraint_unique_named_postgres",
396400
DatabaseBackend::Postgres,
397-
&["CREATE UNIQUE INDEX \"uq_email\" ON \"users\" (\"email\")"]
401+
&["CREATE UNIQUE INDEX \"uq_users__uq_email\" ON \"users\" (\"email\")"]
398402
)]
399403
#[case::add_constraint_unique_named_mysql(
400404
"add_constraint_unique_named_mysql",
401405
DatabaseBackend::MySql,
402-
&["CREATE UNIQUE INDEX `uq_email` ON `users` (`email`)"]
406+
&["CREATE UNIQUE INDEX `uq_users__uq_email` ON `users` (`email`)"]
403407
)]
404408
#[case::add_constraint_unique_named_sqlite(
405409
"add_constraint_unique_named_sqlite",
406410
DatabaseBackend::Sqlite,
407-
&["CREATE UNIQUE INDEX \"uq_email\" ON \"users\" (\"email\")"]
411+
&["CREATE UNIQUE INDEX \"uq_users__uq_email\" ON \"users\" (\"email\")"]
408412
)]
409413
#[case::add_constraint_foreign_key_postgres(
410414
"add_constraint_foreign_key_postgres",

crates/vespertide-query/src/sql/create_table.rs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,13 @@ pub(crate) fn build_create_table_for_backend(
5959
// For MySQL, we can add unique index directly in CREATE TABLE
6060
// For Postgres and SQLite, we'll handle it separately in build_create_table
6161
if matches!(backend, DatabaseBackend::MySql) {
62-
let mut idx = Index::create().unique().to_owned();
63-
if let Some(n) = name {
64-
idx = idx.name(n).to_owned();
65-
}
62+
// Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns}
63+
let index_name = super::helpers::build_unique_constraint_name(
64+
table,
65+
unique_cols,
66+
name.as_deref(),
67+
);
68+
let mut idx = Index::create().name(&index_name).unique().to_owned();
6669
for col in unique_cols {
6770
idx = idx.col(Alias::new(col)).to_owned();
6871
}
@@ -79,10 +82,10 @@ pub(crate) fn build_create_table_for_backend(
7982
on_delete,
8083
on_update,
8184
} => {
82-
let mut fk = ForeignKey::create();
83-
if let Some(n) = name {
84-
fk = fk.name(n).to_owned();
85-
}
85+
// Always generate a proper name: fk_{table}_{key} or fk_{table}_{columns}
86+
let fk_name =
87+
super::helpers::build_foreign_key_name(table, fk_cols, name.as_deref());
88+
let mut fk = ForeignKey::create().name(&fk_name).to_owned();
8689
fk = fk.from_tbl(Alias::new(table)).to_owned();
8790
for col in fk_cols {
8891
fk = fk.from_col(Alias::new(col)).to_owned();
@@ -180,10 +183,17 @@ pub fn build_create_table(
180183
columns: unique_cols,
181184
} = constraint
182185
{
183-
let mut idx = Index::create().table(Alias::new(table)).unique().to_owned();
184-
if let Some(n) = name {
185-
idx = idx.name(n).to_owned();
186-
}
186+
// Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns}
187+
let index_name = super::helpers::build_unique_constraint_name(
188+
table,
189+
unique_cols,
190+
name.as_deref(),
191+
);
192+
let mut idx = Index::create()
193+
.table(Alias::new(table))
194+
.name(&index_name)
195+
.unique()
196+
.to_owned();
187197
for col in unique_cols {
188198
idx = idx.col(Alias::new(col)).to_owned();
189199
}

crates/vespertide-query/src/sql/helpers.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,44 @@ pub fn is_enum_type(column_type: &ColumnType) -> bool {
238238
)
239239
}
240240

241+
/// Generate index name from table name, columns, and optional user-provided key
242+
/// Always includes table name to avoid conflicts across tables.
243+
/// Uses double underscore to separate table name from the rest.
244+
/// Format: ix_{table}__{key} or ix_{table}__{col1}_{col2}...
245+
pub fn build_index_name(table: &str, columns: &[String], key: Option<&str>) -> String {
246+
match key {
247+
Some(k) => format!("ix_{}__{}", table, k),
248+
None => format!("ix_{}__{}", table, columns.join("_")),
249+
}
250+
}
251+
252+
/// Generate unique constraint name from table name, columns, and optional user-provided key
253+
/// Always includes table name to avoid conflicts across tables.
254+
/// Uses double underscore to separate table name from the rest.
255+
/// Format: uq_{table}__{key} or uq_{table}__{col1}_{col2}...
256+
pub fn build_unique_constraint_name(table: &str, columns: &[String], key: Option<&str>) -> String {
257+
match key {
258+
Some(k) => format!("uq_{}__{}", table, k),
259+
None => format!("uq_{}__{}", table, columns.join("_")),
260+
}
261+
}
262+
263+
/// Generate foreign key constraint name from table name, columns, and optional user-provided key
264+
/// Always includes table name to avoid conflicts across tables.
265+
/// Uses double underscore to separate table name from the rest.
266+
/// Format: fk_{table}__{key} or fk_{table}__{col1}_{col2}...
267+
pub fn build_foreign_key_name(table: &str, columns: &[String], key: Option<&str>) -> String {
268+
match key {
269+
Some(k) => format!("fk_{}__{}", table, k),
270+
None => format!("fk_{}__{}", table, columns.join("_")),
271+
}
272+
}
273+
241274
/// Generate CHECK constraint name for SQLite enum column
242-
/// Format: chk_{table}_{column}
275+
/// Uses double underscore to separate table name from the rest.
276+
/// Format: chk_{table}__{column}
243277
pub fn build_sqlite_enum_check_name(table: &str, column: &str) -> String {
244-
format!("chk_{}_{}", table, column)
278+
format!("chk_{}__{}", table, column)
245279
}
246280

247281
/// Generate CHECK constraint expression for SQLite enum column

crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_column__tests__add_column_with_enum_type@add_column_with_enum_type_Sqlite.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
source: crates/vespertide-query/src/sql/add_column.rs
33
expression: sql
44
---
5-
CREATE TABLE "users_temp" ( "id" integer NOT NULL, "status" enum_text , CONSTRAINT "chk_users_status" CHECK ("status" IN ('active', 'inactive')));
5+
CREATE TABLE "users_temp" ( "id" integer NOT NULL, "status" enum_text , CONSTRAINT "chk_users__status" CHECK ("status" IN ('active', 'inactive')));
66
INSERT INTO "users_temp" ("id", "status") SELECT "id", NULL AS "status" FROM "users";
77
DROP TABLE "users";
88
ALTER TABLE "users_temp" RENAME TO "users"

crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_constraint__tests__add_constraint@add_constraint_add_constraint_unique_named_mysql.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
source: crates/vespertide-query/src/sql/add_constraint.rs
33
expression: "result.iter().map(|q| q.build(backend)).collect::<Vec<String>>().join(\"\\n\")"
44
---
5-
CREATE UNIQUE INDEX `uq_email` ON `users` (`email`)
5+
CREATE UNIQUE INDEX `uq_users__uq_email` ON `users` (`email`)

crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_constraint__tests__add_constraint@add_constraint_add_constraint_unique_named_postgres.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
source: crates/vespertide-query/src/sql/add_constraint.rs
33
expression: "result.iter().map(|q| q.build(backend)).collect::<Vec<String>>().join(\"\\n\")"
44
---
5-
CREATE UNIQUE INDEX "uq_email" ON "users" ("email")
5+
CREATE UNIQUE INDEX "uq_users__uq_email" ON "users" ("email")

crates/vespertide-query/src/sql/snapshots/vespertide_query__sql__add_constraint__tests__add_constraint@add_constraint_add_constraint_unique_named_sqlite.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
source: crates/vespertide-query/src/sql/add_constraint.rs
33
expression: "result.iter().map(|q| q.build(backend)).collect::<Vec<String>>().join(\"\\n\")"
44
---
5-
CREATE UNIQUE INDEX "uq_email" ON "users" ("email")
5+
CREATE UNIQUE INDEX "uq_users__uq_email" ON "users" ("email")

0 commit comments

Comments
 (0)