test(sql): cover changeColumnTypeRejectsWhenUsingProducesNullForANotN...
This commit is contained in:
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!!)
|
||||
}
|
||||
|
||||
@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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user