Skip to content

Commit 0e65a9d

Browse files
Ludv1gLclaude
andcommitted
feat: Make create_constraint enforce index uniqueness (clockworklabs#4465 review)
Addresses Centril's review on PR clockworklabs#4465. The previous implementation only inserted metadata but never converted the in-memory index to unique. - Add make_unique/make_non_unique/iter_duplicates on TypedIndex/TableIndex - Rename create_constraint -> create_st_constraint (metadata-only) - New create_constraint makes index unique with can_merge + rollback - Remove CheckAddUniqueConstraintValid precheck - Add 6 transactionality tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 81e7490 commit 0e65a9d

13 files changed

Lines changed: 746 additions & 90 deletions

File tree

crates/core/src/db/update.rs

Lines changed: 1 addition & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use spacetimedb_datastore::locking_tx_datastore::MutTxId;
55
use spacetimedb_lib::db::auth::StTableType;
66
use spacetimedb_lib::identity::AuthCtx;
77
use spacetimedb_lib::AlgebraicValue;
8-
use spacetimedb_primitives::{ColList, ColSet, TableId};
8+
use spacetimedb_primitives::{ColSet, TableId};
99
use spacetimedb_schema::schema::ConstraintSchema;
1010
use spacetimedb_schema::auto_migrate::{AutoMigratePlan, ManualMigratePlan, MigratePlan};
1111
use spacetimedb_schema::def::{TableDef, ViewDef};
@@ -134,53 +134,6 @@ fn auto_migrate_database(
134134
anyhow::bail!("Precheck failed: added sequence {sequence_name} already has values in range",);
135135
}
136136
}
137-
spacetimedb_schema::auto_migrate::AutoMigratePrecheck::CheckAddUniqueConstraintValid(
138-
constraint_name,
139-
) => {
140-
let table_def = plan.new.stored_in_table_def(constraint_name).unwrap();
141-
let constraint_def = &table_def.constraints[constraint_name];
142-
let cols = constraint_def.data.unique_columns().unwrap();
143-
let col_list: ColList = cols.clone().into();
144-
let table_id = stdb.table_id_from_name_mut(tx, &table_def.name)?.unwrap();
145-
146-
// Scan all rows, count occurrences of each projected value
147-
let mut value_counts: std::collections::HashMap<AlgebraicValue, usize> =
148-
std::collections::HashMap::new();
149-
for row in stdb.iter_mut(tx, table_id)? {
150-
let val = row.project(&col_list)?;
151-
*value_counts.entry(val).or_insert(0) += 1;
152-
}
153-
154-
// Collect duplicates (count > 1)
155-
let duplicates: Vec<_> = value_counts
156-
.into_iter()
157-
.filter(|(_, count)| *count > 1)
158-
.collect();
159-
160-
if !duplicates.is_empty() {
161-
let total_groups = duplicates.len();
162-
let examples: String = duplicates
163-
.iter()
164-
.take(10)
165-
.map(|(val, count)| format!(" - {val:?} appears {count} times"))
166-
.collect::<Vec<_>>()
167-
.join("\n");
168-
let col_names: Vec<_> = cols
169-
.iter()
170-
.filter_map(|col_id| table_def.get_column(col_id).map(|c| c.name.to_string()))
171-
.collect();
172-
anyhow::bail!(
173-
"Precheck failed: cannot add unique constraint '{}' on table '{}' column(s) [{}]:\n\
174-
{} duplicate group(s) found.\n{}{}",
175-
constraint_name,
176-
table_def.name,
177-
col_names.join(", "),
178-
total_groups,
179-
examples,
180-
if total_groups > 10 { "\n ... and more" } else { "" }
181-
);
182-
}
183-
}
184137
}
185138
}
186139

crates/datastore/src/locking_tx_datastore/committed_state.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,14 +1324,30 @@ impl CommittedState {
13241324
.unwrap_or_else(|e| match e {});
13251325
}
13261326
// A constraint was removed. Add it back.
1327-
ConstraintRemoved(table_id, constraint_schema) => {
1327+
ConstraintRemoved(table_id, constraint_schema, index_id) => {
13281328
let table = self.tables.get_mut(&table_id)?;
13291329
table.with_mut_schema(|s| s.update_constraint(constraint_schema));
1330+
// If the constraint had a unique index, make it unique again.
1331+
if let Some(index_id) = index_id {
1332+
if let Some(idx) = table.indexes.get_mut(&index_id) {
1333+
idx.make_unique().expect("rollback: index should have no duplicates");
1334+
}
1335+
}
13301336
}
13311337
// A constraint was added. Remove it.
1332-
ConstraintAdded(table_id, constraint_id) => {
1338+
ConstraintAdded(table_id, constraint_id, index_id, pointer_map) => {
13331339
let table = self.tables.get_mut(&table_id)?;
13341340
table.with_mut_schema(|s| s.remove_constraint(constraint_id));
1341+
// If the constraint made an index unique, revert it to non-unique.
1342+
if let Some(index_id) = index_id {
1343+
if let Some(idx) = table.indexes.get_mut(&index_id) {
1344+
idx.make_non_unique();
1345+
}
1346+
}
1347+
// Restore the pointer map if it was taken.
1348+
if let Some(pm) = pointer_map {
1349+
table.restore_pointer_map(pm);
1350+
}
13351351
}
13361352
// A sequence was removed. Add it back.
13371353
SequenceRemoved(table_id, seq, schema) => {

crates/datastore/src/locking_tx_datastore/datastore.rs

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1309,7 +1309,7 @@ mod tests {
13091309
use spacetimedb_lib::error::ResultTest;
13101310
use spacetimedb_lib::st_var::StVarValue;
13111311
use spacetimedb_lib::{resolved_type_via_v9, ScheduleAt, TimeDuration};
1312-
use spacetimedb_primitives::{col_list, ArgId, ColId, ScheduleId, ViewId};
1312+
use spacetimedb_primitives::{col_list, ArgId, ColId, ColSet, ScheduleId, ViewId};
13131313
use spacetimedb_sats::algebraic_value::ser::value_serialize;
13141314
use spacetimedb_sats::bsatn::ToBsatn;
13151315
use spacetimedb_sats::layout::RowTypeLayout;
@@ -3984,4 +3984,197 @@ mod tests {
39843984
);
39853985
Ok(())
39863986
}
3987+
3988+
/// Helper: create a table with a non-unique btree index on `col_pos` but no constraints.
3989+
fn table_with_non_unique_index(col_pos: u16) -> TableSchema {
3990+
let indices = vec![IndexSchema::for_test(
3991+
"Foo_idx_btree",
3992+
BTreeAlgorithm::from(col_pos),
3993+
)];
3994+
basic_table_schema_with_indices(indices, Vec::<ConstraintSchema>::new())
3995+
}
3996+
3997+
/// Helper: create a table with a non-unique btree index on multiple columns but no constraints.
3998+
fn table_with_non_unique_multi_col_index(cols: impl Into<ColList>) -> TableSchema {
3999+
let indices = vec![IndexSchema::for_test(
4000+
"Foo_multi_idx_btree",
4001+
BTreeAlgorithm { columns: cols.into() },
4002+
)];
4003+
basic_table_schema_with_indices(indices, Vec::<ConstraintSchema>::new())
4004+
}
4005+
4006+
#[test]
4007+
fn test_create_constraint_makes_index_unique() -> ResultTest<()> {
4008+
let datastore = get_datastore()?;
4009+
4010+
// TX1: create table with non-unique index on col 0.
4011+
let mut tx = begin_mut_tx(&datastore);
4012+
let schema = table_with_non_unique_index(0);
4013+
let table_id = datastore.create_table_mut_tx(&mut tx, schema)?;
4014+
commit(&datastore, tx)?;
4015+
4016+
// TX2: insert unique rows and commit.
4017+
let mut tx = begin_mut_tx(&datastore);
4018+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Alice", 30))?;
4019+
insert(&datastore, &mut tx, table_id, &u32_str_u32(2, "Bob", 25))?;
4020+
commit(&datastore, tx)?;
4021+
4022+
// TX3: add unique constraint — should succeed since data is unique.
4023+
let mut tx = begin_mut_tx(&datastore);
4024+
let mut constraint = ConstraintSchema::unique_for_test("Foo_id_unique", 0u16);
4025+
constraint.table_id = table_id;
4026+
datastore.create_constraint_mut_tx(&mut tx, constraint)?;
4027+
4028+
// Inserting a duplicate should now fail (index is unique).
4029+
let dup_result = insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Charlie", 20));
4030+
assert!(dup_result.is_err(), "duplicate insert should fail after adding unique constraint");
4031+
commit(&datastore, tx)?;
4032+
4033+
Ok(())
4034+
}
4035+
4036+
#[test]
4037+
fn test_create_constraint_rollback_restores_non_unique() -> ResultTest<()> {
4038+
let datastore = get_datastore()?;
4039+
4040+
// TX1: create table with non-unique index on col 0.
4041+
let mut tx = begin_mut_tx(&datastore);
4042+
let schema = table_with_non_unique_index(0);
4043+
let table_id = datastore.create_table_mut_tx(&mut tx, schema)?;
4044+
commit(&datastore, tx)?;
4045+
4046+
// TX2: insert unique rows and commit.
4047+
let mut tx = begin_mut_tx(&datastore);
4048+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Alice", 30))?;
4049+
insert(&datastore, &mut tx, table_id, &u32_str_u32(2, "Bob", 25))?;
4050+
commit(&datastore, tx)?;
4051+
4052+
// TX3: add unique constraint, then rollback.
4053+
let mut tx = begin_mut_tx(&datastore);
4054+
let mut constraint = ConstraintSchema::unique_for_test("Foo_id_unique", 0u16);
4055+
constraint.table_id = table_id;
4056+
datastore.create_constraint_mut_tx(&mut tx, constraint)?;
4057+
let _ = datastore.rollback_mut_tx(tx);
4058+
4059+
// TX4: after rollback, duplicates should be allowed again.
4060+
let mut tx = begin_mut_tx(&datastore);
4061+
let result = insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Charlie", 20));
4062+
assert!(result.is_ok(), "duplicate insert should succeed after rollback of unique constraint");
4063+
Ok(())
4064+
}
4065+
4066+
#[test]
4067+
fn test_create_constraint_fails_with_duplicates() -> ResultTest<()> {
4068+
let datastore = get_datastore()?;
4069+
4070+
// TX1: create table with non-unique index on col 0.
4071+
let mut tx = begin_mut_tx(&datastore);
4072+
let schema = table_with_non_unique_index(0);
4073+
let table_id = datastore.create_table_mut_tx(&mut tx, schema)?;
4074+
commit(&datastore, tx)?;
4075+
4076+
// TX2: insert duplicate rows and commit.
4077+
let mut tx = begin_mut_tx(&datastore);
4078+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Alice", 30))?;
4079+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Bob", 25))?; // duplicate id=1
4080+
commit(&datastore, tx)?;
4081+
4082+
// TX3: try to add unique constraint — should fail.
4083+
let mut tx = begin_mut_tx(&datastore);
4084+
let mut constraint = ConstraintSchema::unique_for_test("Foo_id_unique", 0u16);
4085+
constraint.table_id = table_id;
4086+
let result = datastore.create_constraint_mut_tx(&mut tx, constraint);
4087+
assert!(result.is_err(), "create_constraint should fail when duplicates exist");
4088+
4089+
Ok(())
4090+
}
4091+
4092+
#[test]
4093+
fn test_create_constraint_multi_col() -> ResultTest<()> {
4094+
let datastore = get_datastore()?;
4095+
4096+
// TX1: create table with non-unique multi-column index on (col 0, col 2).
4097+
let mut tx = begin_mut_tx(&datastore);
4098+
let schema = table_with_non_unique_multi_col_index(col_list![0, 2]);
4099+
let table_id = datastore.create_table_mut_tx(&mut tx, schema)?;
4100+
commit(&datastore, tx)?;
4101+
4102+
// TX2: insert rows unique on (id, age) and commit.
4103+
let mut tx = begin_mut_tx(&datastore);
4104+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Alice", 30))?;
4105+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Bob", 25))?; // same id, different age
4106+
commit(&datastore, tx)?;
4107+
4108+
// TX3: add unique constraint on (col 0, col 2) — should succeed.
4109+
let mut tx = begin_mut_tx(&datastore);
4110+
let mut constraint = ConstraintSchema::unique_for_test(
4111+
"Foo_id_age_unique",
4112+
ColSet::from(col_list![0, 2]),
4113+
);
4114+
constraint.table_id = table_id;
4115+
datastore.create_constraint_mut_tx(&mut tx, constraint)?;
4116+
commit(&datastore, tx)?;
4117+
4118+
Ok(())
4119+
}
4120+
4121+
#[test]
4122+
fn test_drop_constraint_makes_index_non_unique() -> ResultTest<()> {
4123+
let datastore = get_datastore()?;
4124+
4125+
// TX1: create table with unique constraint.
4126+
let mut tx = begin_mut_tx(&datastore);
4127+
let schema = basic_table_schema_with_indices(basic_indices(), basic_constraints());
4128+
let table_id = datastore.create_table_mut_tx(&mut tx, schema)?;
4129+
commit(&datastore, tx)?;
4130+
4131+
// TX2: insert a row.
4132+
let mut tx = begin_mut_tx(&datastore);
4133+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Alice", 30))?;
4134+
commit(&datastore, tx)?;
4135+
4136+
// TX3: drop the unique constraint on col 0.
4137+
let mut tx = begin_mut_tx(&datastore);
4138+
let constraint_id = tx
4139+
.constraint_id_from_name("Foo_id_key")?
4140+
.expect("constraint should exist");
4141+
datastore.drop_constraint_mut_tx(&mut tx, constraint_id)?;
4142+
4143+
// Inserting a duplicate on col 0 should now succeed.
4144+
let result = insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Bob", 25));
4145+
assert!(result.is_ok(), "duplicate insert should succeed after dropping unique constraint");
4146+
commit(&datastore, tx)?;
4147+
4148+
Ok(())
4149+
}
4150+
4151+
#[test]
4152+
fn test_drop_constraint_rollback_keeps_unique() -> ResultTest<()> {
4153+
let datastore = get_datastore()?;
4154+
4155+
// TX1: create table with unique constraint.
4156+
let mut tx = begin_mut_tx(&datastore);
4157+
let schema = basic_table_schema_with_indices(basic_indices(), basic_constraints());
4158+
let table_id = datastore.create_table_mut_tx(&mut tx, schema)?;
4159+
commit(&datastore, tx)?;
4160+
4161+
// TX2: insert a row.
4162+
let mut tx = begin_mut_tx(&datastore);
4163+
insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Alice", 30))?;
4164+
commit(&datastore, tx)?;
4165+
4166+
// TX3: drop constraint, then rollback.
4167+
let mut tx = begin_mut_tx(&datastore);
4168+
let constraint_id = tx
4169+
.constraint_id_from_name("Foo_id_key")?
4170+
.expect("constraint should exist");
4171+
datastore.drop_constraint_mut_tx(&mut tx, constraint_id)?;
4172+
let _ = datastore.rollback_mut_tx(tx);
4173+
4174+
// TX4: after rollback, constraint should be back — duplicates should fail.
4175+
let mut tx = begin_mut_tx(&datastore);
4176+
let dup_result = insert(&datastore, &mut tx, table_id, &u32_str_u32(1, "Bob", 25));
4177+
assert!(dup_result.is_err(), "duplicate insert should fail after rollback of drop constraint");
4178+
Ok(())
4179+
}
39874180
}

0 commit comments

Comments
 (0)