diff --git a/src/test/kotlin/kestrel/GeneratedColumnTest.kt b/src/test/kotlin/kestrel/GeneratedColumnTest.kt new file mode 100644 index 0000000..62a2675 --- /dev/null +++ b/src/test/kotlin/kestrel/GeneratedColumnTest.kt @@ -0,0 +1,158 @@ +package kestrel + +import kestrel.catalog.Catalog +import kestrel.engine.Executor +import kestrel.engine.MergeResult +import kestrel.engine.SelectResult +import kestrel.engine.StatementResult +import kestrel.sql.lexer.Lexer +import kestrel.sql.parser.Parser +import kestrel.storage.Value +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * `GENERATED ALWAYS AS (expr) STORED` on the in-memory engine - a column whose value is always + * derived from the rest of its own row, never assigned directly, and recomputed on every + * INSERT/UPDATE. See [kestrel.store.sql.DiskExecutorGeneratedColumnTest] for the disk engine. + */ +class GeneratedColumnTest { + private val catalog = Catalog() + private val executor = Executor(catalog) + + private fun run(sql: String): List = + Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) } + + private fun exec(sql: String): StatementResult = run(sql).single() + + private fun select(sql: String): SelectResult = exec(sql) as SelectResult + + private fun int(value: Long) = Value.IntegerValue(value) + + // ---- computed on INSERT ---- + + @Test + fun generatedColumnIsComputedWhenOmittedFromInsert() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a) VALUES (5)") + assertEquals(listOf(listOf(int(5), int(10))), select("SELECT a, b FROM t").rows) + } + + @Test + fun generatedColumnIsComputedWhenOmittedFromPositionalInsertColumnList() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a, b) VALUES (5, DEFAULT)") + assertEquals(listOf(listOf(int(5), int(10))), select("SELECT a, b FROM t").rows) + } + + @Test + fun insertRejectsAnExplicitValueForAGeneratedColumn() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + val error = assertFailsWith { run("INSERT INTO t (a, b) VALUES (5, 99)") } + assertTrue(error.message!!.contains("GENERATED")) + } + + @Test + fun generatedColumnRecomputesFreshForEveryRowOfAMultiRowInsert() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a) VALUES (1), (2), (3)") + assertEquals( + listOf(listOf(int(1), int(2)), listOf(int(2), int(4)), listOf(int(3), int(6))), + select("SELECT a, b FROM t ORDER BY a").rows + ) + } + + @Test + fun generatedColumnCanReferenceMultipleOtherColumns() { + run("CREATE TABLE t (price INTEGER, qty INTEGER, total INTEGER GENERATED ALWAYS AS (price * qty) STORED)") + run("INSERT INTO t (price, qty) VALUES (7, 3)") + assertEquals(listOf(listOf(int(21))), select("SELECT total FROM t").rows) + } + + // ---- computed on UPDATE ---- + + @Test + fun updatingADependencyColumnRecomputesTheGeneratedColumn() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a) VALUES (5)") + run("UPDATE t SET a = 9") + assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows) + } + + @Test + fun updateRejectsAnExplicitValueForAGeneratedColumn() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a) VALUES (5)") + val error = assertFailsWith { run("UPDATE t SET b = 1") } + assertTrue(error.message!!.contains("GENERATED")) + } + + @Test + fun updateSetToDefaultOnAGeneratedColumnRecomputesIt() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (a) VALUES (5)") + run("UPDATE t SET a = 9, b = DEFAULT") + assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows) + } + + // ---- ON CONFLICT DO UPDATE ---- + + @Test + fun onConflictDoUpdateRecomputesTheGeneratedColumn() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (id, a) VALUES (1, 5)") + run("INSERT INTO t (id, a) VALUES (1, 20) ON CONFLICT (id) DO UPDATE SET a = EXCLUDED.a") + assertEquals(listOf(listOf(int(40))), select("SELECT b FROM t").rows) + } + + @Test + fun onConflictDoUpdateRejectsAnExplicitValueForAGeneratedColumn() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("INSERT INTO t (id, a) VALUES (1, 5)") + assertFailsWith { + run("INSERT INTO t (id, a) VALUES (1, 20) ON CONFLICT (id) DO UPDATE SET b = 1") + } + } + + // ---- MERGE ---- + + @Test + fun mergeInsertComputesTheGeneratedColumn() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("CREATE TABLE s (id INTEGER, a INTEGER)") + run("INSERT INTO s VALUES (1, 5)") + val result = exec( + "MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id, a) VALUES (s.id, s.a)" + ) as MergeResult + assertEquals(MergeResult(1, 0, 0), result) + assertEquals(listOf(listOf(int(10))), select("SELECT b FROM t").rows) + } + + @Test + fun mergeUpdateRecomputesTheGeneratedColumn() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)") + run("CREATE TABLE s (id INTEGER, a INTEGER)") + run("INSERT INTO t (id, a) VALUES (1, 5)") + run("INSERT INTO s VALUES (1, 9)") + exec("MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET a = s.a") + assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows) + } + + // ---- ordering with CHECK / NOT NULL ---- + + @Test + fun checkConstraintSeesTheComputedGeneratedValueNotItsOldOne() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED CHECK (b > 0))") + assertFailsWith { run("INSERT INTO t (a) VALUES (-1)") } + run("INSERT INTO t (a) VALUES (3)") + assertEquals(listOf(listOf(int(6))), select("SELECT b FROM t").rows) + } + + @Test + fun notNullOnAGeneratedColumnRejectsARowWhoseComputedValueIsNull() { + run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a) STORED NOT NULL)") + assertFailsWith { run("INSERT INTO t (a) VALUES (NULL)") } + } +