diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt index 1003981..8c24af0 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt @@ -3488,3 +3488,161 @@ class DiskExecutorTest { assertEquals(listOf(listOf(text("ghost"))), db.select("SELECT dept_code FROM employees").rows) } + @Test + fun dropColumnRestrictsWhenUsedByItsOwnForeignKey() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id))") + val error = assertFailsWith { db.run("ALTER TABLE employees DROP COLUMN dept_id") } + assertTrue(error.message!!.contains("FOREIGN KEY")) + } + + @Test + fun dropColumnCascadeRemovesItsOwnForeignKey() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id))") + db.run("ALTER TABLE employees DROP COLUMN dept_id CASCADE") + assertEquals(listOf("id"), db.select("SELECT * FROM employees").columns) + } + + @Test + fun dropColumnRestrictsWhenUsedByAUniqueConstraint() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + val error = assertFailsWith { db.run("ALTER TABLE t DROP COLUMN email") } + assertTrue(error.message!!.contains("UNIQUE")) + } + + @Test + fun dropColumnCascadeRemovesTheUniqueConstraint() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") + db.run("INSERT INTO t VALUES (1, 'a@x.com')") + db.run("ALTER TABLE t DROP COLUMN email CASCADE") + db.run("INSERT INTO t VALUES (2)") + assertEquals(2, db.select("SELECT * FROM t").rows.size) + } + + @Test + fun dropColumnRestrictsWhenUsedByACheckConstraint() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))") + val error = assertFailsWith { db.run("ALTER TABLE t DROP COLUMN age") } + assertTrue(error.message!!.contains("CHECK")) + } + + @Test + fun dropColumnCascadeRemovesTheCheckConstraint() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))") + db.run("ALTER TABLE t DROP COLUMN age CASCADE") + db.run("INSERT INTO t VALUES (1)") + assertEquals(1, db.select("SELECT * FROM t").rows.size) + } + + @Test + fun dropColumnRestrictsWhenUsedByAnIndex() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER)") + db.run("CREATE INDEX idx_age ON t (age)") + val error = assertFailsWith { db.run("ALTER TABLE t DROP COLUMN age") } + assertTrue(error.message!!.contains("idx_age")) + } + + /** + * Beyond mirroring the in-memory engine's own coverage, this also proves the position + * realignment [DiskTable.rewriteRows] performs for every surviving secondary index: `dept` + * shifts from column position 2 to 1 once `age` (position 1) is dropped, and `idx_dept` must + * keep resolving against the new position rather than the stale one it was built with. + */ + @Test + fun dropColumnCascadeRemovesTheIndexAndASurvivingLaterIndexStillResolvesAtItsShiftedPosition() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER, dept TEXT)") + db.run("INSERT INTO t VALUES (1, 30, 'eng'), (2, 40, 'ops')") + db.run("CREATE INDEX idx_age ON t (age)") + db.run("CREATE INDEX idx_dept ON t (dept)") + db.run("ALTER TABLE t DROP COLUMN age CASCADE") + + val table = db.catalog.table("t") + assertEquals(listOf("idx_dept"), table.secondaryIndexes.map { it.name }) + val result = db.select("SELECT id FROM t WHERE dept = 'ops'") + assertEquals(listOf(listOf(int(2))), result.rows) + assertEquals(1, table.secondaryLookupCount) + } + + @Test + fun dropColumnDefinitionAndRemainingDataSurviveClosingAndReopeningTheCatalog() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, bio TEXT)") + db.run("INSERT INTO t VALUES (1, 'ada', 'mathematician')") + db.run("ALTER TABLE t DROP COLUMN bio") + db.reopen() + assertEquals(listOf("id", "name"), db.select("SELECT * FROM t").columns) + assertEquals(listOf(listOf(int(1), text("ada"))), db.select("SELECT * FROM t").rows) + } + + // ---- ALTER TABLE RENAME COLUMN ---- + + @Test + fun renameColumnRenamesSchemaAndPreservesData() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, bio TEXT)") + db.run("INSERT INTO t VALUES (1, 'mathematician')") + db.run("ALTER TABLE t RENAME COLUMN bio TO biography") + val result = db.select("SELECT * FROM t") + assertEquals(listOf("id", "biography"), result.columns) + assertEquals(listOf(listOf(int(1), text("mathematician"))), result.rows) + } + + @Test + fun renameColumnRejectsUnknownColumn() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + assertFailsWith { db.run("ALTER TABLE t RENAME COLUMN missing TO whatever") } + } + + @Test + fun renameColumnRejectsWhenNewNameAlreadyExists() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") + val error = assertFailsWith { db.run("ALTER TABLE t RENAME COLUMN name TO id") } + assertTrue(error.message!!.contains("already exists")) + } + + @Test + fun renameColumnUpdatesCompositePrimaryKeyListing() { + val db = newDatabase() + db.run("CREATE TABLE t (region TEXT, seq INTEGER, PRIMARY KEY (region, seq))") + db.run("INSERT INTO t VALUES ('eu', 1)") + db.run("ALTER TABLE t RENAME COLUMN region TO area") + assertFailsWith { db.run("INSERT INTO t VALUES ('eu', 1)") } + db.run("INSERT INTO t VALUES ('eu', 2)") + assertEquals(2, db.select("SELECT * FROM t").rows.size) + } + + @Test + fun renameColumnUpdatesItsOwnForeignKeyColumns() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id))") + db.run("INSERT INTO departments VALUES (1)") + db.run("ALTER TABLE employees RENAME COLUMN dept_id TO department_id") + assertFailsWith { db.run("INSERT INTO employees VALUES (1, 99)") } + db.run("INSERT INTO employees VALUES (1, 1)") + assertEquals(1, db.select("SELECT * FROM employees").rows.size) + } + + @Test + fun renameColumnPropagatesToOtherTablesForeignKeyReferencedColumns() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id) ON DELETE CASCADE)") + db.run("INSERT INTO departments VALUES (1)") + db.run("INSERT INTO employees VALUES (1, 1)") + db.run("ALTER TABLE departments RENAME COLUMN id TO dept_pk") + db.run("DELETE FROM departments WHERE dept_pk = 1") + assertTrue(db.select("SELECT * FROM employees").rows.isEmpty()) + } +