diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorMergeTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorMergeTest.kt index b98e68b..b8ef6f5 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorMergeTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorMergeTest.kt @@ -313,3 +313,225 @@ class DiskExecutorMergeTest { assertEquals(emptyList(), db.select("SELECT * FROM t").rows) } + @Test + fun aForeignKeyViolationOnAMergeUpdateIsRejected() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id))") + db.run("CREATE TABLE moves (id INTEGER, new_dept INTEGER)") + db.run("INSERT INTO departments VALUES (1)") + db.run("INSERT INTO employees VALUES (1, 1)") + db.run("INSERT INTO moves VALUES (1, 999)") + val error = assertFailsWith { + db.run("MERGE INTO employees USING moves ON employees.id = moves.id WHEN MATCHED THEN UPDATE SET dept_id = moves.new_dept") + } + assertTrue(error.message!!.contains("FOREIGN KEY"), error.message) + assertEquals(listOf(listOf(int(1))), db.select("SELECT dept_id FROM employees").rows) + } + + @Test + fun rowTriggersFireForEachMergeDrivenAction() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + db.run("CREATE TABLE s (id INTEGER, label TEXT)") + db.run("CREATE TABLE log (event TEXT)") + db.run("INSERT INTO t VALUES (1, 'old'), (2, 'gone')") + db.run("INSERT INTO s VALUES (1, 'new'), (2, 'new'), (3, 'fresh')") + db.run("CREATE TRIGGER log_insert AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('insert:', NEW.label)); END") + db.run("CREATE TRIGGER log_update AFTER UPDATE ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('update:', NEW.label)); END") + db.run("CREATE TRIGGER log_delete AFTER DELETE ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('delete:', OLD.label)); END") + db.exec( + "MERGE INTO t USING s ON t.id = s.id " + + "WHEN MATCHED AND s.id = 2 THEN DELETE " + + "WHEN MATCHED THEN UPDATE SET label = s.label " + + "WHEN NOT MATCHED THEN INSERT (id, label) VALUES (s.id, s.label)" + ) + assertEquals( + setOf(text("update:new"), text("delete:gone"), text("insert:fresh")), + db.select("SELECT event FROM log").rows.map { it.single() }.toSet() + ) + } + + @Test + fun rollingBackATransactionUndoesEveryEffectOfAMerge() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 50), (2, 20)") + db.run("BEGIN") + db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " + + "WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount)" + ) + db.run("ROLLBACK") + assertEquals(listOf(listOf(int(1), int(100))), db.select("SELECT id, balance FROM accounts").rows) + } + + @Test + fun anAliasedTargetAndSourceCanBeReferencedByTheirAlias() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 25)") + db.exec( + "MERGE INTO accounts AS acc USING staging AS s ON acc.id = s.id " + + "WHEN MATCHED THEN UPDATE SET balance = acc.balance + s.amount" + ) + assertEquals(listOf(listOf(int(125))), db.select("SELECT balance FROM accounts").rows) + } + + @Test + fun mergeRequiresAtLeastOneWhenClause() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + db.run("CREATE TABLE s (id INTEGER)") + assertFailsWith { db.run("MERGE INTO t USING s ON t.id = s.id") } + } + + @Test + fun mergingIntoAnUnknownTableFails() { + val db = newDatabase() + db.run("CREATE TABLE s (id INTEGER)") + assertFailsWith { + db.run("MERGE INTO nope USING s ON nope.id = s.id WHEN MATCHED THEN DELETE") + } + } + + @Test + fun mergeSurvivesClosingAndReopeningTheCatalog() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 50), (2, 20)") + db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " + + "WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount)" + ) + db.reopen() + assertEquals( + listOf(listOf(int(1), int(150)), listOf(int(2), int(20))), + db.select("SELECT id, balance FROM accounts ORDER BY id").rows + ) + } + + @Test + fun returningStarProjectsEveryColumnOfEveryActionedRow() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 10), (2, 5)") + val result = db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " + + "WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount) " + + "RETURNING *" + ) as MergeResult + assertEquals(listOf("id", "balance"), result.returning!!.columns) + assertEquals( + listOf(listOf(int(1), int(110)), listOf(int(2), int(5))), + result.returning!!.rows + ) + } + + @Test + fun returningEvaluatesExpressionsAgainstTheAffectedRow() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 10)") + val result = db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " + + "RETURNING balance * 2 AS doubled" + ) as MergeResult + assertEquals(listOf("doubled"), result.returning!!.columns) + assertEquals(listOf(listOf(int(220))), result.returning!!.rows) + } + + @Test + fun returningIncludesThePreDeletionImageOfADeletedRow() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 0)") + val result = db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id WHEN MATCHED THEN DELETE RETURNING id, balance" + ) as MergeResult + assertEquals(listOf(listOf(int(1), int(100))), result.returning!!.rows) + assertEquals(emptyList(), db.select("SELECT * FROM accounts").rows) + } + + @Test + fun mergeActionLabelsEachReturnedRowByWhichClauseFired() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + db.run("CREATE TABLE s (id INTEGER, label TEXT)") + db.run("INSERT INTO t VALUES (1, 'old'), (2, 'gone')") + db.run("INSERT INTO s VALUES (1, 'new'), (2, 'new'), (3, 'fresh')") + val result = db.exec( + "MERGE INTO t USING s ON t.id = s.id " + + "WHEN MATCHED AND s.id = 2 THEN DELETE " + + "WHEN MATCHED THEN UPDATE SET label = s.label " + + "WHEN NOT MATCHED THEN INSERT (id, label) VALUES (s.id, s.label) " + + "RETURNING id, MERGE_ACTION() AS kind" + ) as MergeResult + assertEquals( + setOf(listOf(int(1), text("UPDATE")), listOf(int(2), text("DELETE")), listOf(int(3), text("INSERT"))), + result.returning!!.rows.toSet() + ) + } + + @Test + fun noRowMatchingAnyClauseReturnsAnEmptyReturningResultNotANullOne() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 5)") + val result = db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED AND staging.amount > 1000 THEN UPDATE SET balance = 0 " + + "RETURNING *" + ) as MergeResult + assertEquals(emptyList(), result.returning!!.rows) + } + + @Test + fun mergeActionIsRejectedOutsideOfAMergesOwnReturningClause() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + db.run("INSERT INTO t VALUES (1)") + val error = assertFailsWith { db.select("SELECT MERGE_ACTION() FROM t") } + assertTrue(error.message!!.contains("MERGE_ACTION"), error.message) + } + + @Test + fun returningColumnValidationRunsUpFrontEvenWhenNoSourceRowEverReachesAMatchingClause() { + val db = newDatabase() + db.setUpAccountsAndStaging() + val error = assertFailsWith { + db.run( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN DELETE RETURNING nope" + ) + } + assertTrue(error.message!!.contains("no such column"), error.message) + } + + @Test + fun returningSurvivesClosingAndReopeningTheCatalog() { + val db = newDatabase() + db.setUpAccountsAndStaging() + db.run("INSERT INTO accounts VALUES (1, 100)") + db.run("INSERT INTO staging VALUES (1, 50)") + val beforeReopen = db.exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " + + "RETURNING id, balance" + ) as MergeResult + assertEquals(listOf(listOf(int(1), int(150))), beforeReopen.returning!!.rows) + db.reopen() + assertEquals(listOf(listOf(int(1), int(150))), db.select("SELECT id, balance FROM accounts").rows) + } +}