test(sql): cover changeColumnTypeRejectsWhenUsingProducesNullForANotN...

This commit is contained in:
dualfroz committed 2026-09-11 14:52:52 +02:00
1 parent f45390a3d2
commit 7cf0ce3d90
2 files changed
+189

No files matched your search

@@ -160,3 +160,87 @@ class DiskExecutorChangeColumnTypeTest {
assertTrue(error.message!!.contains("idx_name"), error.message!!) 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<KestrelException> {
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<KestrelException> {
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<KestrelException> { 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<KestrelException> { 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<KestrelException> { db.run("INSERT INTO t VALUES (2, NULL)") }
assertTrue(error.message!!.contains("NOT NULL"), error.message!!)
}
}
@@ -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<Statement> = 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<StatementResult> = 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() }
)
}
}