diff --git a/src/test/kotlin/kestrel/UpsertTest.kt b/src/test/kotlin/kestrel/UpsertTest.kt new file mode 100644 index 0000000..ffd56f9 --- /dev/null +++ b/src/test/kotlin/kestrel/UpsertTest.kt @@ -0,0 +1,162 @@ +package kestrel + +import kestrel.catalog.Catalog +import kestrel.engine.Executor +import kestrel.engine.InsertResult +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 + +class UpsertTest { + 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 insertCount(sql: String): Int = (exec(sql) as InsertResult).count + + private fun seed() { + run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER NOT NULL)") + run("INSERT INTO accounts VALUES (1, 'ada', 100)") + } + + @Test + fun noConflictInsertsNormallyAndReportsOneRow() { + seed() + val count = insertCount("INSERT INTO accounts VALUES (2, 'linus', 200) ON CONFLICT (id) DO NOTHING") + assertEquals(1, count) + assertEquals(2, select("SELECT * FROM accounts").rows.size) + } + + @Test + fun conflictOnPrimaryKeyDoesNothingAndReportsZero() { + seed() + val count = insertCount("INSERT INTO accounts VALUES (1, 'ignored', 999) ON CONFLICT (id) DO NOTHING") + assertEquals(0, count) + assertEquals(Value.IntegerValue(100), select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals("ada", (select("SELECT name FROM accounts WHERE id = 1").rows[0][0] as Value.TextValue).value) + } + + @Test + fun conflictWithBareOnConflictWithNoTargetCatchesAnyUniqueViolation() { + seed() + run("CREATE UNIQUE INDEX idxName ON accounts (name)") + val count = insertCount("INSERT INTO accounts VALUES (2, 'ada', 300) ON CONFLICT DO NOTHING") + assertEquals(0, count) + assertEquals(1, select("SELECT * FROM accounts").rows.size) + } + + @Test + fun conflictDoUpdateAppliesTheAssignments() { + seed() + insertCount("INSERT INTO accounts VALUES (1, 'ada', 999) ON CONFLICT (id) DO UPDATE SET balance = 999") + assertEquals(Value.IntegerValue(999), select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals(1, select("SELECT * FROM accounts").rows.size) + } + + @Test + fun conflictDoUpdateCanReferenceExcludedForTheProposedRow() { + seed() + insertCount("INSERT INTO accounts VALUES (1, 'ada', 50) ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + excluded.balance") + assertEquals(Value.IntegerValue(150), select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + } + + @Test + fun conflictDoUpdateWithFailingWhereBehavesLikeDoNothingForThatRow() { + seed() + val count = insertCount( + "INSERT INTO accounts VALUES (1, 'ada', 999) ON CONFLICT (id) DO UPDATE SET balance = 999 WHERE excluded.balance < 0" + ) + assertEquals(0, count) + assertEquals(Value.IntegerValue(100), select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + } + + @Test + fun conflictDoUpdateWithPassingWhereApplies() { + seed() + val count = insertCount( + "INSERT INTO accounts VALUES (1, 'ada', 999) ON CONFLICT (id) DO UPDATE SET balance = 999 WHERE excluded.balance > 0" + ) + assertEquals(1, count) + assertEquals(Value.IntegerValue(999), select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + } + + @Test + fun multiRowInsertHandlesEachRowsConflictIndependently() { + seed() + val count = insertCount( + """ + INSERT INTO accounts VALUES (1, 'ada', 5), (2, 'linus', 200), (3, 'grace', 300) + ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + excluded.balance + """.trimIndent() + ) + assertEquals(3, count) + assertEquals(Value.IntegerValue(105), select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals(Value.IntegerValue(200), select("SELECT balance FROM accounts WHERE id = 2").rows[0][0]) + assertEquals(Value.IntegerValue(300), select("SELECT balance FROM accounts WHERE id = 3").rows[0][0]) + } + + @Test + fun laterRowInTheSameStatementSeesAnEarlierRowsInsert() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, hits INTEGER NOT NULL)") + val count = insertCount( + """ + INSERT INTO t VALUES (1, 1), (1, 1) + ON CONFLICT (id) DO UPDATE SET hits = t.hits + excluded.hits + """.trimIndent() + ) + assertEquals(2, count) + assertEquals(Value.IntegerValue(2), select("SELECT hits FROM t WHERE id = 1").rows[0][0]) + } + + @Test + fun conflictTargetMustMatchAnExistingUniqueOrPrimaryKey() { + seed() + val error = assertFailsWith { + run("INSERT INTO accounts VALUES (2, 'linus', 200) ON CONFLICT (name) DO NOTHING") + } + assertTrue(error.message!!.contains("no unique or primary key constraint"), error.message!!) + } + + @Test + fun conflictTargetMatchesADeclaredUniqueConstraintNotJustThePrimaryKey() { + run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT)") + run("INSERT INTO users VALUES (1, 'ada@example.com', 'ada')") + val count = insertCount( + "INSERT INTO users VALUES (2, 'ada@example.com', 'ada2') ON CONFLICT (email) DO UPDATE SET name = excluded.name" + ) + assertEquals(1, count) + assertEquals("ada2", (select("SELECT name FROM users WHERE email = 'ada@example.com'").rows[0][0] as Value.TextValue).value) + assertEquals(1, select("SELECT * FROM users").rows.size) + } + + @Test + fun conflictOnADifferentUnrelatedUniqueColumnStillThrows() { + run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, tag TEXT UNIQUE)") + run("INSERT INTO users VALUES (1, 'ada@example.com', 'x')") + val error = assertFailsWith { + run("INSERT INTO users VALUES (2, 'new@example.com', 'x') ON CONFLICT (email) DO NOTHING") + } + assertTrue(error.message!!.contains("UNIQUE constraint"), error.message!!) + } + + @Test + fun conflictDoUpdateEnforcesNotNullOnTheResultingRow() { + seed() + val error = assertFailsWith { + run("INSERT INTO accounts VALUES (1, 'ada', 100) ON CONFLICT (id) DO UPDATE SET name = NULL") + } + assertTrue(error.message!!.contains("NOT NULL"), error.message!!) + } +