diff --git a/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt b/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt index d25030f..6875481 100644 --- a/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt +++ b/src/test/kotlin/kestrel/store/sql/DiskExecutorTest.kt @@ -3167,3 +3167,164 @@ class DiskExecutorTest { assertFailsWith { db.run("UPDATE employees SET dept_id = 99 WHERE id = 1") } } + @Test + fun foreignKeyRequiresReferencedTableToExist() { + val db = newDatabase() + assertFailsWith { + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, x INTEGER REFERENCES missing(id))") + } + } + + @Test + fun foreignKeyRequiresReferencedColumnToBeAPrimaryKey() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + assertFailsWith { + db.run("CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT REFERENCES departments(name))") + } + } + + @Test + fun tableLevelForeignKeySyntaxIsEquivalentToColumnLevel() { + val db = newDatabase() + db.run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + db.run( + "CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER, " + + "FOREIGN KEY (dept_id) REFERENCES departments(id))" + ) + db.run("INSERT INTO departments VALUES (1, 'eng')") + assertEquals(InsertResult(1), db.run("INSERT INTO employees VALUES (1, 1)").single()) + assertFailsWith { db.run("INSERT INTO employees VALUES (2, 99)") } + } + + @Test + fun deleteIsRestrictedByDefaultWhenChildrenReferenceTheRow() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + db.run("INSERT INTO employees VALUES (1, 'ada', 1)") + val error = assertFailsWith { db.run("DELETE FROM departments WHERE id = 1") } + assertTrue(error.message!!.contains("FOREIGN KEY")) + assertEquals(1, db.select("SELECT * FROM departments WHERE id = 1").rows.size) + } + + @Test + fun deleteWithoutReferencingChildrenSucceeds() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + assertEquals(DeleteResult(1), db.run("DELETE FROM departments WHERE id = 2").single()) + } + + @Test + fun onDeleteCascadeRemovesReferencingChildren() { + val db = newDatabase() + db.seedCascadeDepartments() + assertEquals(DeleteResult(1), db.run("DELETE FROM departments WHERE id = 1").single()) + assertEquals(listOf(listOf(text("grace"))), db.select("SELECT name FROM employees ORDER BY id").rows) + } + + @Test + fun onUpdateCascadeChangesChildForeignKeyWhenParentPrimaryKeyChanges() { + val db = newDatabase() + db.seedCascadeDepartments() + db.run("UPDATE departments SET id = 10 WHERE id = 1") + val depts = db.select("SELECT dept_id FROM employees ORDER BY id") + assertEquals(listOf(listOf(int(10)), listOf(int(10)), listOf(int(2))), depts.rows) + } + + @Test + fun onUpdateRestrictByDefaultBlocksPrimaryKeyChangeWhenChildrenReference() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + db.run("INSERT INTO employees VALUES (1, 'ada', 1)") + assertFailsWith { db.run("UPDATE departments SET id = 99 WHERE id = 1") } + } + + @Test + fun onDeleteSetNullNullsOutReferencingChildren() { + val db = newDatabase() + db.seedSetNullDepartments(childNotNull = false) + db.run("DELETE FROM departments WHERE id = 1") + assertEquals(listOf(listOf(Value.NullValue)), db.select("SELECT dept_id FROM employees WHERE id = 1").rows) + } + + @Test + fun onDeleteSetNullFailsWhenChildColumnIsNotNull() { + val db = newDatabase() + db.seedSetNullDepartments(childNotNull = true) + val error = assertFailsWith { db.run("DELETE FROM departments WHERE id = 1") } + assertTrue(error.message!!.contains("NOT NULL")) + } + + @Test + fun selfReferencingForeignKeyIsAllowedAndCascadesThroughMultipleLevels() { + val db = newDatabase() + db.run( + "CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " + + "manager_id INTEGER REFERENCES employees(id) ON DELETE CASCADE)" + ) + db.run( + "INSERT INTO employees VALUES " + + "(1, 'ceo', NULL), (2, 'vp', 1), (3, 'manager', 2), (4, 'ic', 3)" + ) + assertEquals(DeleteResult(1), db.run("DELETE FROM employees WHERE id = 1").single()) + assertEquals(emptyList(), db.select("SELECT id FROM employees").rows) + } + + @Test + fun selfReferencingForeignKeyAllowsForwardAndBackwardSiblingReferencesInTheSameInsertBatch() { + val db = newDatabase() + db.run( + "CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " + + "manager_id INTEGER REFERENCES employees(id))" + ) + // row 1 references row 2 (forward, not yet on disk) and row 3 references row 1 (backward) - + // order-independence within a single multi-row INSERT statement. + assertEquals( + InsertResult(3), + db.run("INSERT INTO employees VALUES (1, 'a', 2), (2, 'b', NULL), (3, 'c', 1)").single() + ) + } + + @Test + fun dropTableIsRestrictedByDefaultWhenAForeignKeyReferencesIt() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + val error = assertFailsWith { db.run("DROP TABLE departments") } + assertTrue(error.message!!.contains("FOREIGN KEY")) + } + + @Test + fun dropTableCascadeStripsTheForeignKeyButKeepsTheReferencingTableAndItsRows() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + db.run("INSERT INTO employees VALUES (1, 'ada', 1)") + db.run("DROP TABLE departments CASCADE") + assertEquals(listOf("employees"), db.catalog.tableNames) + assertEquals(1, db.select("SELECT * FROM employees").rows.size) + // the constraint is gone, so a now-dangling value is accepted + assertEquals(InsertResult(1), db.run("INSERT INTO employees VALUES (2, 'linus', 99)").single()) + } + + @Test + fun dropTableCascadeStrippedForeignKeySurvivesClosingAndReopeningTheCatalog() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + db.run("INSERT INTO employees VALUES (1, 'ada', 1)") + db.run("DROP TABLE departments CASCADE") + db.reopen() + assertEquals(listOf("employees"), db.catalog.tableNames) + assertEquals(InsertResult(1), db.run("INSERT INTO employees VALUES (2, 'linus', 99)").single()) + } + + @Test + fun dropTableCascadeDropsDependentViewsAndStripsForeignKeysInTheSameStatement() { + val db = newDatabase() + db.seedDepartmentsAndEmployees() + db.run("CREATE VIEW deptNames AS SELECT name FROM departments") + db.run("INSERT INTO employees VALUES (1, 'ada', 1)") + db.run("DROP TABLE departments CASCADE") + assertEquals(listOf("employees"), db.catalog.tableNames) + assertTrue(db.catalog.viewNames.isEmpty()) + assertEquals(InsertResult(1), db.run("INSERT INTO employees VALUES (2, 'linus', 99)").single()) + } +