From f45390a3d225798256b0e8898ab0d74edb1bf096 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 14:52:31 +0200 Subject: [PATCH] test(sql): cover DiskExecutorChangeColumnTypeTest and tempFile --- .../sql/DiskExecutorChangeColumnTypeTest.kt | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt new file mode 100644 index 0000000..27a1029 --- /dev/null +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt @@ -0,0 +1,162 @@ +package kestrel.store.sql + +import kestrel.KestrelException +import kestrel.engine.AlterTableResult +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import java.io.File +import java.math.BigDecimal +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * `ALTER TABLE t ALTER COLUMN col TYPE ...` on [DiskExecutor] - mirrors + * [kestrel.AlterTableTest]'s in-memory `ChangeColumnType` cases. Built on [DiskTable.rewriteRows] + * exactly like `ADD COLUMN` (see [DiskCatalog.changeColumnType]'s own doc): every row's stored + * bytes are rewritten in place under a single WAL transaction, so a NOT NULL/CHECK failure + * discovered partway through a multi-row rewrite aborts the whole transaction rather than leaving + * some rows converted and others not - the disk-specific angle [kestrel.AlterTableTest] cannot + * exercise at all, since the in-memory engine has no WAL to roll back. + */ +class DiskExecutorChangeColumnTypeTest { + private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() } + + private fun int(value: Long) = Value.IntegerValue(value) + private fun text(value: String) = Value.TextValue(value) + private fun real(value: Double) = Value.RealValue(value) + private fun decimal(value: String) = Value.DecimalValue(BigDecimal(value)) + + private class Database(val dataFile: File, val walFile: File) { + var catalog = DiskCatalog.open(dataFile, walFile) + private set + var executor = DiskExecutor(catalog) + private set + + fun run(sql: String): List = Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + fun exec(sql: String): StatementResult = run(sql).single() + fun select(sql: String): SelectResult = exec(sql) as SelectResult + + /** Closes and reopens the catalog against the same files, simulating a fresh process. */ + fun reopen() { + catalog.close() + catalog = DiskCatalog.open(dataFile, walFile) + executor = DiskExecutor(catalog) + } + } + + private fun newDatabase(): Database = Database(tempFile("kestrel-disk-alter-type-data"), tempFile("kestrel-disk-alter-type-wal")) + + @Test + fun changeColumnTypeWideningIntegerToRealCoercesExistingRowsWithNoExplicitUsing() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, score INTEGER)") + db.run("INSERT INTO t VALUES (1, 10), (2, 20)") + val result = db.exec("ALTER TABLE t ALTER COLUMN score TYPE REAL") + assertEquals(AlterTableResult("t"), result) + assertEquals(listOf(listOf(real(10.0)), listOf(real(20.0))), db.select("SELECT score FROM t ORDER BY id ASC").rows) + } + + @Test + fun changeColumnTypeWideningIntegerToDecimalCoercesExistingRows() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, amount INTEGER)") + db.run("INSERT INTO t VALUES (1, 42)") + db.exec("ALTER TABLE t ALTER COLUMN amount TYPE DECIMAL(10, 2)") + assertEquals(listOf(listOf(decimal("42.00"))), db.select("SELECT amount FROM t").rows) + } + + @Test + fun changeColumnTypeWithUsingConvertsTextToIntegerViaCast() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, code TEXT)") + db.run("INSERT INTO t VALUES (1, '100'), (2, '200')") + db.exec("ALTER TABLE t ALTER COLUMN code TYPE INTEGER USING CAST(code AS INTEGER)") + assertEquals(listOf(listOf(int(100)), listOf(int(200))), db.select("SELECT code FROM t ORDER BY id ASC").rows) + } + + @Test + fun changeColumnTypeWithUsingCanReferenceTheRowsOtherColumns() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, price INTEGER, quantity INTEGER)") + db.run("INSERT INTO t VALUES (1, 10, 3)") + db.exec("ALTER TABLE t ALTER COLUMN price TYPE INTEGER USING price * quantity") + assertEquals(listOf(listOf(int(30))), db.select("SELECT price FROM t").rows) + } + + @Test + fun changeColumnTypeRejectsImplicitCoercionWhenTypesAreIncompatibleAndNoUsingIsGiven() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, code TEXT)") + db.run("INSERT INTO t VALUES (1, 'abc')") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN code TYPE INTEGER") } + assertTrue(error.message!!.contains("type mismatch"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsUnknownColumn() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN missing TYPE TEXT") } + assertTrue(error.message!!.contains("no such column"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsGeneratedColumn() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, price INTEGER, doubled INTEGER GENERATED ALWAYS AS (price * 2) STORED)") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN doubled TYPE REAL") } + assertTrue(error.message!!.contains("GENERATED"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsPrimaryKeyColumn() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN id TYPE TEXT") } + assertTrue(error.message!!.contains("PRIMARY KEY"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsColumnUsedByItsOwnForeignKey() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER, FOREIGN KEY (dept_id) REFERENCES departments (id))") + val error = assertFailsWith { db.run("ALTER TABLE employees ALTER COLUMN dept_id TYPE TEXT") } + assertTrue(error.message!!.contains("FOREIGN KEY"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsColumnReferencedByAnotherTablesForeignKey() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY, code TEXT, CONSTRAINT uq_code UNIQUE (code))") + db.run( + "CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_code TEXT, " + + "FOREIGN KEY (dept_code) REFERENCES departments (code))" + ) + val error = assertFailsWith { db.run("ALTER TABLE departments ALTER COLUMN code TYPE INTEGER") } + assertTrue(error.message!!.contains("employees"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsColumnUsedByAUniqueConstraint() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT, CONSTRAINT uq_email UNIQUE (email))") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN email TYPE TEXT") } + assertTrue(error.message!!.contains("UNIQUE"), error.message!!) + } + + @Test + fun changeColumnTypeRejectsColumnWithASecondaryIndex() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)") + db.run("CREATE INDEX idx_name ON t (name)") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN name TYPE TEXT") } + assertTrue(error.message!!.contains("idx_name"), error.message!!) + } +