diff --git a/src/test/kotlin/kestrel/MergeTest.kt b/src/test/kotlin/kestrel/MergeTest.kt index 4951d62..ce9f6e4 100644 --- a/src/test/kotlin/kestrel/MergeTest.kt +++ b/src/test/kotlin/kestrel/MergeTest.kt @@ -154,3 +154,159 @@ class MergeTest { assertTrue(error.message!!.contains("a second time"), error.message) } + @Test + fun aTargetRowMatchedByTwoSourceRowsIsFineWhenOnlyOneClauseActuallyFires() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 5), (1, 999)") + exec( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN MATCHED AND staging.amount > 100 THEN UPDATE SET balance = accounts.balance + staging.amount" + ) + assertEquals(listOf(listOf(int(1099))), select("SELECT balance FROM accounts").rows) + } + + @Test + fun anUnqualifiedColumnPresentOnBothSidesIsAmbiguousInOnCondition() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)") + run("CREATE TABLE s (id INTEGER, v INTEGER)") + val error = assertFailsWith { + run("MERGE INTO t USING s ON id = s.id WHEN MATCHED THEN UPDATE SET v = 1") + } + assertTrue(error.message!!.contains("ambiguous"), error.message) + } + + @Test + fun aQualifierMatchingNeitherSideFails() { + setUpAccountsAndStaging() + val error = assertFailsWith { + run("MERGE INTO accounts USING staging ON bogus.id = staging.id WHEN MATCHED THEN DELETE") + } + assertTrue(error.message!!.contains("no such table or alias"), error.message) + } + + @Test + fun aWhenNotMatchedClauseCannotReferenceTheTargetTable() { + setUpAccountsAndStaging() + val error = assertFailsWith { + run( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN NOT MATCHED AND accounts.balance > 0 THEN INSERT (id, balance) VALUES (staging.id, 1)" + ) + } + assertTrue(error.message!!.contains("WHEN NOT MATCHED"), error.message) + } + + @Test + fun columnValidationRunsUpFrontEvenWhenNoSourceRowEverReachesTheBadClause() { + setUpAccountsAndStaging() + val error = assertFailsWith { + run( + "MERGE INTO accounts USING staging ON accounts.id = staging.id " + + "WHEN NOT MATCHED THEN INSERT (id, nope) VALUES (staging.id, 1)" + ) + } + assertTrue(error.message!!.contains("no such column"), error.message) + } + + @Test + fun theTargetAndSourceCannotShareTheSameNameOrAlias() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + run("CREATE TABLE other (id INTEGER)") + val error = assertFailsWith { + run("MERGE INTO t USING other AS t ON t.id = t.id WHEN MATCHED THEN DELETE") + } + assertTrue(error.message!!.contains("target and source"), error.message) + } + + @Test + fun sourceCanBeADerivedTableSubquery() { + setUpAccountsAndStaging() + run("INSERT INTO staging VALUES (1, 10), (1, 20)") + exec( + "MERGE INTO accounts USING (SELECT id, SUM(amount) AS total FROM staging GROUP BY id) AS agg " + + "ON accounts.id = agg.id " + + "WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (agg.id, agg.total)" + ) + assertEquals(listOf(listOf(int(1), int(30))), select("SELECT id, balance FROM accounts").rows) + } + + @Test + fun aDefaultKeywordInTheInsertValuesUsesTheColumnDefault() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT DEFAULT 'unlabeled')") + run("CREATE TABLE s (id INTEGER)") + run("INSERT INTO s VALUES (1)") + exec("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id, label) VALUES (s.id, DEFAULT)") + assertEquals(listOf(listOf(int(1), text("unlabeled"))), select("SELECT id, label FROM t").rows) + } + + @Test + fun anOmittedColumnListInsertsIntoEveryColumnInSchemaOrder() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + run("CREATE TABLE s (id INTEGER, label TEXT)") + run("INSERT INTO s VALUES (1, 'a')") + exec("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.label)") + assertEquals(listOf(listOf(int(1), text("a"))), select("SELECT id, label FROM t").rows) + } + + @Test + fun aNextvalDefaultStillAdvancesThroughAMergeInsert() { + run("CREATE SEQUENCE seq") + run("CREATE TABLE t (id INTEGER PRIMARY KEY DEFAULT NEXTVAL('seq'), label TEXT)") + run("CREATE TABLE s (label TEXT)") + run("INSERT INTO s VALUES ('a'), ('b')") + exec("MERGE INTO t USING s ON t.label = s.label WHEN NOT MATCHED THEN INSERT (label) VALUES (s.label)") + assertEquals( + listOf(listOf(int(1), text("a")), listOf(int(2), text("b"))), + select("SELECT id, label FROM t ORDER BY id").rows + ) + } + + @Test + fun notNullViolationOnAMergeInsertFailsAndInsertsNothing() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT NOT NULL)") + run("CREATE TABLE s (id INTEGER)") + run("INSERT INTO s VALUES (1)") + assertFailsWith { + run("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id)") + } + assertEquals(emptyList(), select("SELECT * FROM t").rows) + } + + @Test + fun aForeignKeyViolationOnAMergeUpdateIsRejected() { + run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id))") + run("CREATE TABLE moves (id INTEGER, new_dept INTEGER)") + run("INSERT INTO departments VALUES (1)") + run("INSERT INTO employees VALUES (1, 1)") + run("INSERT INTO moves VALUES (1, 999)") + val error = assertFailsWith { + 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))), select("SELECT dept_id FROM employees").rows) + } + + @Test + fun rowTriggersFireForEachMergeDrivenAction() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + run("CREATE TABLE s (id INTEGER, label TEXT)") + run("CREATE TABLE log (event TEXT)") + run("INSERT INTO t VALUES (1, 'old'), (2, 'gone')") + run("INSERT INTO s VALUES (1, 'new'), (2, 'new'), (3, 'fresh')") + run("CREATE TRIGGER log_insert AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('insert:', NEW.label)); END") + run("CREATE TRIGGER log_update AFTER UPDATE ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('update:', NEW.label)); END") + run("CREATE TRIGGER log_delete AFTER DELETE ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('delete:', OLD.label)); END") + 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")), + select("SELECT event FROM log").rows.map { it.single() }.toSet() + ) + } +