diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt index 0c5d1f0..6fb03cb 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt @@ -957,3 +957,159 @@ class DiskExecutorTest { assertTrue(db.catalog.table("users").secondaryIndexes.isEmpty()) } + @Test + fun dropIndexStopsThePlannerFromPushingDownAFormerlyIndexedEquality() { + val db = newDatabase() + db.seedUsers() + db.run("CREATE INDEX idx_age ON users(age)") + db.run("DROP INDEX idx_age") + val table = db.catalog.table("users") + val scansBefore = table.scanCount + db.select("SELECT * FROM users WHERE age = 54") + assertEquals(0, table.secondaryLookupCount) + assertEquals(scansBefore + 1, table.scanCount) + } + + @Test + fun droppingAUniqueIndexStopsItFromRejectingFurtherDuplicates() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)") + db.run("CREATE UNIQUE INDEX idx_email ON users(email)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com')") + db.run("DROP INDEX idx_email") + db.run("INSERT INTO users VALUES (2, 'ada@example.com')") + assertEquals(2, db.select("SELECT * FROM users").rows.size) + } + + @Test + fun dropIndexOnACompositeIndexRemovesTheWholeIndexNotJustOneColumn() { + val db = newDatabase() + db.seedUsers() + db.run("CREATE INDEX idx_both ON users(age, name)") + db.run("DROP INDEX idx_both") + assertTrue(db.catalog.table("users").secondaryIndexes.isEmpty()) + } + + @Test + fun droppingAnUnknownIndexFails() { + val db = newDatabase() + db.seedUsers() + assertFailsWith { db.run("DROP INDEX ghost") } + } + + @Test + fun droppingAnAlreadyDroppedIndexFails() { + val db = newDatabase() + db.seedUsers() + db.run("CREATE INDEX idx_age ON users(age)") + db.run("DROP INDEX idx_age") + assertFailsWith { db.run("DROP INDEX idx_age") } + } + + @Test + fun dropIndexIsPersistedAcrossClosingAndReopeningTheCatalog() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)") + db.run("CREATE UNIQUE INDEX idx_email ON users(email)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com')") + db.run("DROP INDEX idx_email") + db.reopen() + assertTrue(db.catalog.table("users").secondaryIndexes.isEmpty()) + db.run("INSERT INTO users VALUES (2, 'ada@example.com')") + assertEquals(2, db.select("SELECT * FROM users").rows.size) + } + + @Test + fun dropIndexCannotRunInsideAnExplicitTransaction() { + val db = newDatabase() + db.seedUsers() + db.run("CREATE INDEX idx_age ON users(age)") + db.run("BEGIN") + assertFailsWith { db.run("DROP INDEX idx_age") } + db.run("ROLLBACK") + } + + // --- declarative UNIQUE constraints (inline `col TYPE UNIQUE` / table-level `UNIQUE (a, b, ...)`), + // enforced by a full heap scan per write rather than a backing index, mirroring + // kestrel.engine.Executor's own uniqueConstraints handling. + + @Test + fun inlineUniqueColumnRejectsADuplicateValueOnInsert() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com')") + assertFailsWith { db.run("INSERT INTO users VALUES (2, 'ada@example.com')") } + assertEquals(1, db.select("SELECT * FROM users").rows.size) + } + + @Test + fun inlineUniqueColumnAllowsMultipleNulls() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + db.run("INSERT INTO users VALUES (1, NULL), (2, NULL)") + assertEquals(2, db.select("SELECT * FROM users").rows.size) + } + + @Test + fun tableLevelCompositeUniqueConstraintRejectsADuplicateColumnCombination() { + val db = newDatabase() + db.run("CREATE TABLE registrations (event_id INTEGER, user_id INTEGER, UNIQUE (event_id, user_id))") + db.run("INSERT INTO registrations VALUES (1, 100)") + assertFailsWith { db.run("INSERT INTO registrations VALUES (1, 100)") } + // Sharing only one of the two columns is fine: the constraint is on the pair, not either column alone. + db.run("INSERT INTO registrations VALUES (1, 200)") + assertEquals(2, db.select("SELECT * FROM registrations").rows.size) + } + + @Test + fun uniqueConstraintRejectsAnUpdateThatWouldDuplicateAnotherRowsValue() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com'), (2, 'grace@example.com')") + assertFailsWith { db.run("UPDATE users SET email = 'ada@example.com' WHERE id = 2") } + } + + @Test + fun uniqueConstraintReferencingAnUnknownColumnIsRejectedWithAClearError() { + val db = newDatabase() + val failure = assertFailsWith { + db.run("CREATE TABLE bad (id INTEGER PRIMARY KEY, UNIQUE (missing_column))") + } + assertTrue(failure.message!!.contains("missing_column")) + } + + @Test + fun uniqueConstraintDataAndEnforcementSurviveClosingAndReopeningTheCatalog() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com')") + db.reopen() + assertFailsWith { db.run("INSERT INTO users VALUES (2, 'ada@example.com')") } + assertEquals(1, db.select("SELECT * FROM users").rows.size) + } + + // --- composite (multi-column) CREATE [UNIQUE] INDEX + + @Test + fun createIndexBuildsANonUniqueCompositeSecondaryIndex() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, age INTEGER, name TEXT)") + val created = db.run("CREATE INDEX idx_both ON users(age, name)").single() + assertEquals(CreateIndexResult("idx_both"), created) + val handle = db.catalog.table("users").secondaryIndexes.single() + assertEquals(listOf("age", "name"), handle.columnNames) + assertEquals(false, handle.unique) + } + + @Test + fun createUniqueCompositeIndexRejectsADuplicateColumnCombinationOnInsert() { + val db = newDatabase() + db.run("CREATE TABLE registrations (event_id INTEGER, user_id INTEGER)") + db.run("CREATE UNIQUE INDEX idx_reg ON registrations(event_id, user_id)") + db.run("INSERT INTO registrations VALUES (1, 100)") + assertFailsWith { db.run("INSERT INTO registrations VALUES (1, 100)") } + // Sharing only one of the two columns is fine: the index is on the pair, not either column alone. + db.run("INSERT INTO registrations VALUES (1, 200)") + assertEquals(2, db.select("SELECT * FROM registrations").rows.size) + } +