diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorOnConflictTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorOnConflictTest.kt index 531841b..400df78 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorOnConflictTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorOnConflictTest.kt @@ -161,3 +161,128 @@ class DiskExecutorOnConflictTest { assertTrue(error.message!!.contains("no unique or primary key constraint"), error.message!!) } + @Test + fun conflictTargetMatchesADeclaredUniqueConstraintNotJustThePrimaryKey() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com', 'ada')") + val count = db.insertCount( + "INSERT INTO users VALUES (2, 'ada@example.com', 'ada2') ON CONFLICT (email) DO UPDATE SET name = excluded.name" + ) + assertEquals(1, count) + assertEquals("ada2", (db.select("SELECT name FROM users WHERE email = 'ada@example.com'").rows[0][0] as Value.TextValue).value) + assertEquals(1, db.select("SELECT * FROM users").rows.size) + } + + @Test + fun conflictOnADifferentUnrelatedUniqueColumnStillThrows() { + val db = newDatabase() + db.run("CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, tag TEXT UNIQUE)") + db.run("INSERT INTO users VALUES (1, 'ada@example.com', 'x')") + val error = assertFailsWith { + db.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() { + val db = newDatabase() + db.seed() + val error = assertFailsWith { + db.run("INSERT INTO accounts VALUES (1, 'ada', 100) ON CONFLICT (id) DO UPDATE SET name = NULL") + } + assertTrue(error.message!!.contains("NOT NULL"), error.message!!) + } + + @Test + fun conflictDoUpdateEnforcesCheckConstraintsOnTheResultingRow() { + val db = newDatabase() + db.run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER CHECK (balance >= 0))") + db.run("INSERT INTO accounts VALUES (1, 100)") + val error = assertFailsWith { + db.run("INSERT INTO accounts VALUES (1, 100) ON CONFLICT (id) DO UPDATE SET balance = -5") + } + assertTrue(error.message!!.contains("CHECK constraint"), error.message!!) + } + + @Test + fun conflictDoUpdateRunsForeignKeyValidationOnTheUpdatedRow() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("INSERT INTO departments VALUES (1)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept INTEGER REFERENCES departments(id))") + db.run("INSERT INTO employees VALUES (1, 1)") + val error = assertFailsWith { + db.run("INSERT INTO employees VALUES (1, 1) ON CONFLICT (id) DO UPDATE SET dept = 999") + } + assertTrue(error.message!!.contains("FOREIGN KEY"), error.message!!) + } + + @Test + fun conflictDoUpdateCascadesToChildTablesWhenTheReferencedKeyChanges() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY)") + db.run("INSERT INTO departments VALUES (1)") + db.run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept INTEGER REFERENCES departments(id) ON UPDATE CASCADE)") + db.run("INSERT INTO employees VALUES (1, 1)") + db.insertCount("INSERT INTO departments VALUES (1) ON CONFLICT (id) DO UPDATE SET id = 2") + assertEquals(int(2), db.select("SELECT dept FROM employees WHERE id = 1").rows[0][0]) + } + + @Test + fun compositePrimaryKeyConflictTargetIsRecognised() { + val db = newDatabase() + db.run("CREATE TABLE grades (student INTEGER, course INTEGER, score INTEGER, PRIMARY KEY (student, course))") + db.run("INSERT INTO grades VALUES (1, 10, 50)") + val count = db.insertCount( + "INSERT INTO grades VALUES (1, 10, 90) ON CONFLICT (student, course) DO UPDATE SET score = excluded.score" + ) + assertEquals(1, count) + assertEquals(int(90), db.select("SELECT score FROM grades WHERE student = 1 AND course = 10").rows[0][0]) + } + + @Test + fun conflictDoUpdateDefaultKeywordFallsBackToColumnDefault() { + val db = newDatabase() + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, note TEXT DEFAULT 'fresh')") + db.run("INSERT INTO t VALUES (1, 'stale')") + db.insertCount("INSERT INTO t VALUES (1, 'stale') ON CONFLICT (id) DO UPDATE SET note = DEFAULT") + assertEquals("fresh", (db.select("SELECT note FROM t WHERE id = 1").rows[0][0] as Value.TextValue).value) + } + + @Test + fun onConflictInsideAnExplicitTransactionParticipatesInItsCommit() { + val db = newDatabase() + db.seed() + db.run("BEGIN") + db.insertCount("INSERT INTO accounts VALUES (1, 'ada', 999) ON CONFLICT (id) DO UPDATE SET balance = excluded.balance") + db.insertCount("INSERT INTO accounts VALUES (2, 'linus', 200) ON CONFLICT (id) DO NOTHING") + db.run("ROLLBACK") + assertEquals(int(100), db.select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals(1, db.select("SELECT * FROM accounts").rows.size) + } + + /** + * Durability proof unique to the disk-backed executor: a resolved conflict (one row inserted + * fresh, one row updated in place) must be readable after a real close/reopen of the backing + * files, not just correct in the live process's buffer pool - mirrors the reopen tests already + * established for SEQUENCE ([DiskExecutorSequenceTest.sequencePositionSurvivesAReopen]) and + * SAVEPOINT ([DiskExecutorSavepointTest.dataWrittenAfterASavepointRollbackSurvivesAReopenAfterCommit]). + */ + @Test + fun conflictResolutionSurvivesAReopen() { + val db = newDatabase() + db.seed() + db.insertCount( + """ + INSERT INTO accounts VALUES (1, 'ada', 999), (2, 'linus', 200) + ON CONFLICT (id) DO UPDATE SET balance = excluded.balance + """.trimIndent() + ) + db.reopen() + assertEquals(int(999), db.select("SELECT balance FROM accounts WHERE id = 1").rows[0][0]) + assertEquals(int(200), db.select("SELECT balance FROM accounts WHERE id = 2").rows[0][0]) + assertEquals(2, db.select("SELECT * FROM accounts").rows.size) + } +}