diff --git a/src/test/kotlin/kestrel/MergeTest.kt b/src/test/kotlin/kestrel/MergeTest.kt index ce9f6e4..2112d87 100644 --- a/src/test/kotlin/kestrel/MergeTest.kt +++ b/src/test/kotlin/kestrel/MergeTest.kt @@ -310,3 +310,150 @@ class MergeTest { ) } + @Test + fun rollingBackATransactionUndoesEveryEffectOfAMerge() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 50), (2, 20)") + run("BEGIN") + 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)" + ) + run("ROLLBACK") + assertEquals(listOf(listOf(int(1), int(100))), select("SELECT id, balance FROM accounts").rows) + } + + @Test + fun anAliasedTargetAndSourceCanBeReferencedByTheirAlias() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 25)") + 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))), select("SELECT balance FROM accounts").rows) + } + + @Test + fun mergeRequiresAtLeastOneWhenClause() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + run("CREATE TABLE s (id INTEGER)") + assertFailsWith { run("MERGE INTO t USING s ON t.id = s.id") } + } + + @Test + fun mergingIntoAnUnknownTableFails() { + run("CREATE TABLE s (id INTEGER)") + assertFailsWith { + run("MERGE INTO nope USING s ON nope.id = s.id WHEN MATCHED THEN DELETE") + } + } + + @Test + fun returningStarProjectsEveryColumnOfEveryActionedRow() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 10), (2, 5)") + val result = 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() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 10)") + val result = 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() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 0)") + val result = 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(), select("SELECT * FROM accounts").rows) + } + + @Test + fun mergeActionLabelsEachReturnedRowByWhichClauseFired() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)") + run("CREATE TABLE s (id INTEGER, label TEXT)") + run("INSERT INTO t VALUES (1, 'old'), (2, 'gone')") + run("INSERT INTO s VALUES (1, 'new'), (2, 'new'), (3, 'fresh')") + val result = 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() { + setUpAccountsAndStaging() + run("INSERT INTO accounts VALUES (1, 100)") + run("INSERT INTO staging VALUES (1, 5)") + val result = 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() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + run("INSERT INTO t VALUES (1)") + val error = assertFailsWith { select("SELECT MERGE_ACTION() FROM t") } + assertTrue(error.message!!.contains("MERGE_ACTION"), error.message) + } + + @Test + fun mergeActionInAPlainInsertReturningIsRejectedOnceARowActuallyReturns() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + val error = assertFailsWith { + run("INSERT INTO t VALUES (1) RETURNING MERGE_ACTION()") + } + assertTrue(error.message!!.contains("MERGE_ACTION"), error.message) + } + + @Test + fun returningColumnValidationRunsUpFrontEvenWhenNoSourceRowEverReachesAMatchingClause() { + setUpAccountsAndStaging() + val error = assertFailsWith { + 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) + } +}