diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt index 27a1029..ca170d8 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorChangeColumnTypeTest.kt @@ -160,3 +160,87 @@ class DiskExecutorChangeColumnTypeTest { assertTrue(error.message!!.contains("idx_name"), error.message!!) } + @Test + fun changeColumnTypeRejectsWhenUsingProducesNullForANotNullColumnAndLeavesTheTableUnchanged() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, code TEXT NOT NULL)") + db.run("INSERT INTO t VALUES (1, '100'), (2, '')") + val error = assertFailsWith { + db.run("ALTER TABLE t ALTER COLUMN code TYPE INTEGER USING CASE WHEN code = '' THEN NULL ELSE CAST(code AS INTEGER) END") + } + assertTrue(error.message!!.contains("NOT NULL"), error.message!!) + assertEquals(listOf(listOf(text("100")), listOf(text(""))), db.select("SELECT code FROM t ORDER BY id ASC").rows) + } + + /** + * The disk-specific proof [kestrel.AlterTableTest] cannot give: row 1's converted value (20) + * would already have been written to its heap page by [DiskTable.rewriteRows] before row 2's + * CHECK violation is discovered and thrown - if the surrounding transaction were not aborted, + * row 1 would be left silently converted while row 2 was not. Asserting BOTH rows still hold + * their ORIGINAL, unconverted values after the failure is the proof that [DiskCatalog.changeColumnType]'s + * `catch { engine.abort(txn) }` really unwinds a partial per-row rewrite already applied to disk. + */ + @Test + fun changeColumnTypeRevalidatesACheckConstraintAgainstTheNewValuesAndLeavesTheTableUnchangedOnFailure() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, amount INTEGER, CHECK (amount <= 100))") + db.run("INSERT INTO t VALUES (1, 10), (2, 60)") + val error = assertFailsWith { + db.run("ALTER TABLE t ALTER COLUMN amount TYPE INTEGER USING amount * 2") + } + assertTrue(error.message!!.contains("CHECK constraint"), error.message!!) + assertEquals( + listOf(listOf(int(1), int(10)), listOf(int(2), int(60))), + db.select("SELECT id, amount FROM t ORDER BY id ASC").rows + ) + } + + @Test + fun changeColumnTypeSucceedsAndAppliesUsingToEveryRowWhenAllSatisfyAnExistingCheckConstraint() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, amount INTEGER, CHECK (amount <= 100))") + db.run("INSERT INTO t VALUES (1, 10), (2, 20)") + db.exec("ALTER TABLE t ALTER COLUMN amount TYPE INTEGER USING amount * 2") + assertEquals(listOf(listOf(int(20)), listOf(int(40))), db.select("SELECT amount FROM t ORDER BY id ASC").rows) + } + + @Test + fun changeColumnTypeRejectsAnIncompatibleDefaultExpressionUpFront() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'pending')") + val error = assertFailsWith { db.run("ALTER TABLE t ALTER COLUMN status TYPE INTEGER USING 0") } + assertTrue(error.message!!.contains("DEFAULT"), error.message!!) + } + + @Test + fun changeColumnTypeAcceptsAnOptionalColumnKeyword() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, amount INTEGER)") + db.run("INSERT INTO t VALUES (1, 5)") + db.exec("ALTER TABLE t ALTER amount TYPE REAL") + assertEquals(listOf(listOf(real(5.0))), db.select("SELECT amount FROM t").rows) + } + + /** The type change - and the converted data - must survive a full process close/reopen, not just live in the still-open transaction's page cache. */ + @Test + fun changeColumnTypeSurvivesReopen() { + 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)") + db.reopen() + assertEquals(listOf(listOf(decimal("42.00"))), db.select("SELECT amount FROM t").rows) + val error = assertFailsWith { db.run("INSERT INTO t VALUES (2, 'not-a-number')") } + assertTrue(error.message!!.contains("type mismatch"), error.message!!) + } + + @Test + fun changeColumnTypePreservesNotNullAndStillEnforcesItGoingForward() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)") + db.run("INSERT INTO t VALUES (1, 10)") + db.exec("ALTER TABLE t ALTER COLUMN amount TYPE REAL") + val error = assertFailsWith { db.run("INSERT INTO t VALUES (2, NULL)") } + assertTrue(error.message!!.contains("NOT NULL"), error.message!!) + } +} diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorCollateTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorCollateTest.kt new file mode 100644 index 0000000..5d302e4 --- /dev/null +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorCollateTest.kt @@ -0,0 +1,105 @@ +package kestrel.store.sql + +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.sql.ast.Statement +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `expr COLLATE BINARY|NOCASE|RTRIM` against the disk engine, mirroring a subset of + * [kestrel.CollateTest]'s in-memory scenarios (equality, RTRIM, ORDER BY, IN) to prove the shared + * [kestrel.engine.Evaluator]/[kestrel.engine.compareOrderKeyValues] code [kestrel.store.sql. + * DiskExecutor] reuses directly behaves identically here, plus one disk-only scenario: a + * COLLATE-bearing WHERE clause typed straight into the disk engine (never routed through + * [kestrel.persistence.SqlWriter] - the disk engine has no text WAL to round-trip through) keeps + * comparing the same way after a real file close/reopen. + */ +class DiskExecutorCollateTest { + private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() } + + private fun parse(sql: String): List = Parser(Lexer(sql).tokenize()).parseProgram() + + private fun int(value: Long) = Value.IntegerValue(value) + private fun text(value: String) = Value.TextValue(value) + private fun bool(value: Boolean) = Value.BooleanValue(value) + + private class Database(val dataFile: File, val walFile: File) { + var catalog = DiskCatalog.open(dataFile, walFile) + var executor = DiskExecutor(catalog) + + fun run(sql: String): List = Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + fun select(sql: String): SelectResult = run(sql).single() as SelectResult + + fun reopen() { + catalog.close() + catalog = DiskCatalog.open(dataFile, walFile) + executor = DiskExecutor(catalog) + } + } + + private fun newDatabase(): Database = Database(tempFile("kestrel-disk-collate-data"), tempFile("kestrel-disk-collate-wal")) + + private fun Database.seedNames() { + run("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)") + run("INSERT INTO people VALUES (1, 'Bob'), (2, 'bob'), (3, 'Alice'), (4, 'alice '), (5, 'Zoe')") + } + + @Test + fun equalityWithNocaseIgnoresAsciiCaseButPlainEqualityDoesNot() { + val db = newDatabase() + db.seedNames() + assertEquals(bool(true), db.select("SELECT name = 'BOB' COLLATE NOCASE FROM people WHERE id = 1").rows.single().single()) + assertEquals(bool(false), db.select("SELECT name = 'BOB' FROM people WHERE id = 1").rows.single().single()) + } + + @Test + fun rtrimIgnoresOnlyTrailingSpaces() { + val db = newDatabase() + db.seedNames() + assertEquals(bool(true), db.select("SELECT name = 'alice' COLLATE RTRIM FROM people WHERE id = 4").rows.single().single()) + assertEquals(bool(false), db.select("SELECT name = 'alice' FROM people WHERE id = 4").rows.single().single()) + } + + @Test + fun orderByCollateNocaseInterleavesUpperAndLowerCaseNames() { + val db = newDatabase() + db.seedNames() + val result = db.select("SELECT name FROM people ORDER BY name COLLATE NOCASE, id") + assertEquals(listOf("Alice", "alice ", "Bob", "bob", "Zoe").map { listOf(text(it)) }, result.rows) + } + + @Test + fun inListHonorsTheOperandsExplicitCollate() { + val db = newDatabase() + db.seedNames() + assertEquals( + bool(true), + db.select("SELECT name COLLATE NOCASE IN ('BOB', 'other') FROM people WHERE id = 1").rows.single().single() + ) + assertEquals( + bool(false), + db.select("SELECT name IN ('BOB', 'other') FROM people WHERE id = 1").rows.single().single() + ) + } + + @Test + fun aCollateComparisonKeepsComparingTheSameWayAfterARealFileCloseAndReopen() { + val db = newDatabase() + db.seedNames() + assertEquals( + listOf(int(1), int(2)), + db.select("SELECT id FROM people WHERE name COLLATE NOCASE = 'BOB' ORDER BY id").rows.map { it.single() } + ) + db.reopen() + assertEquals( + listOf(int(1), int(2)), + db.select("SELECT id FROM people WHERE name COLLATE NOCASE = 'BOB' ORDER BY id").rows.map { it.single() } + ) + } +}