diff --git a/src/test/kotlin/kestrel/AlterTableTest.kt b/src/test/kotlin/kestrel/AlterTableTest.kt index 45d93e4..6a7c2eb 100644 --- a/src/test/kotlin/kestrel/AlterTableTest.kt +++ b/src/test/kotlin/kestrel/AlterTableTest.kt @@ -633,3 +633,160 @@ class AlterTableTest { assertEquals(listOf(listOf(text("pending"))), select("SELECT status FROM t").rows) } + // ---- ADD CONSTRAINT / DROP CONSTRAINT ---- + + @Test + fun addConstraintCheckSucceedsWhenEveryExistingRowSatisfiesIt() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER)") + run("INSERT INTO t VALUES (1, 30), (2, 40)") + val result = exec("ALTER TABLE t ADD CONSTRAINT age_positive CHECK (age > 0)") + assertEquals(AlterTableResult("t"), result) + val error = assertFailsWith { run("INSERT INTO t VALUES (3, -1)") } + assertTrue(error.message!!.contains("age_positive"), error.message!!) + } + + @Test + fun addConstraintCheckRejectsWhenAnExistingRowAlreadyViolatesIt() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER)") + run("INSERT INTO t VALUES (1, 30), (2, -5)") + val error = assertFailsWith { run("ALTER TABLE t ADD CONSTRAINT age_positive CHECK (age > 0)") } + assertTrue(error.message!!.contains("age_positive"), error.message!!) + // Rejected, so never installed: a fresh negative row must still be free to insert. + run("INSERT INTO t VALUES (3, -1)") + assertEquals(2, select("SELECT id FROM t WHERE age < 0").rows.size) + } + + @Test + fun addConstraintCheckRejectsAReferenceToAnUnknownColumn() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER)") + val error = assertFailsWith { run("ALTER TABLE t ADD CONSTRAINT c1 CHECK (bogus > 0)") } + assertTrue(error.message!!.contains("bogus"), error.message!!) + } + + @Test + fun addConstraintCheckNameRoundTripsThroughSqlWriterDumpAndReplay() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER)") + run("ALTER TABLE t ADD CONSTRAINT age_positive CHECK (age > 0)") + val dumped = SqlWriter.dump(catalog) + assertTrue(dumped.contains("CONSTRAINT age_positive CHECK"), dumped) + val freshCatalog = Catalog() + val freshExecutor = Executor(freshCatalog) + for (statement in Parser(Lexer(dumped).tokenize()).parseProgram()) freshExecutor.execute(statement) + val error = assertFailsWith { + freshExecutor.execute(Parser(Lexer("INSERT INTO t VALUES (5, -1)").tokenize()).parseProgram().single()) + } + assertTrue(error.message!!.contains("age_positive"), error.message!!) + } + + @Test + fun addConstraintUniqueSucceedsWhenNoExistingRowsAreDuplicates() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT)") + run("INSERT INTO t VALUES (1, 'a@x.com'), (2, 'b@x.com')") + exec("ALTER TABLE t ADD CONSTRAINT uq_email UNIQUE (email)") + run("INSERT INTO t VALUES (3, 'c@x.com')") + val error = assertFailsWith { run("INSERT INTO t VALUES (4, 'c@x.com')") } + assertTrue(error.message!!.contains("uq_email"), error.message!!) + } + + @Test + fun addConstraintUniqueRejectsWhenExistingRowsAreAlreadyDuplicated() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT)") + run("INSERT INTO t VALUES (1, 'a@x.com'), (2, 'a@x.com')") + val error = assertFailsWith { run("ALTER TABLE t ADD CONSTRAINT uq_email UNIQUE (email)") } + assertTrue(error.message!!.contains("uq_email"), error.message!!) + } + + @Test + fun addConstraintUniqueIgnoresNullValuesAmongExistingRows() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, email TEXT)") + run("INSERT INTO t VALUES (1, NULL), (2, NULL)") + exec("ALTER TABLE t ADD CONSTRAINT uq_email UNIQUE (email)") + run("INSERT INTO t VALUES (3, NULL)") + assertEquals(3, select("SELECT id FROM t").rows.size) + } + + @Test + fun addConstraintUniqueRejectsAReferenceToAnUnknownColumn() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY)") + val error = assertFailsWith { run("ALTER TABLE t ADD CONSTRAINT uq_bogus UNIQUE (bogus)") } + assertTrue(error.message!!.contains("bogus"), error.message!!) + } + + @Test + fun addConstraintForeignKeySucceedsWhenEveryExistingRowHasAMatchOrIsNull() { + run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER)") + run("INSERT INTO departments VALUES (1, 'eng')") + run("INSERT INTO employees VALUES (1, 1), (2, NULL)") + exec("ALTER TABLE employees ADD CONSTRAINT fk_dept FOREIGN KEY (dept_id) REFERENCES departments (id)") + val error = assertFailsWith { run("INSERT INTO employees VALUES (3, 99)") } + assertTrue(error.message!!.contains("FOREIGN KEY constraint failed"), error.message!!) + } + + @Test + fun addConstraintForeignKeyRejectsWhenAnExistingRowIsAlreadyOrphaned() { + run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER)") + run("INSERT INTO departments VALUES (1, 'eng')") + run("INSERT INTO employees VALUES (1, 1), (2, 99)") + val error = assertFailsWith { + run("ALTER TABLE employees ADD CONSTRAINT fk_dept FOREIGN KEY (dept_id) REFERENCES departments (id)") + } + assertTrue(error.message!!.contains("FOREIGN KEY constraint failed"), error.message!!) + } + + @Test + fun addConstraintForeignKeySelfReferenceValidatesAgainstExistingSiblingRows() { + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, manager_id INTEGER)") + run("INSERT INTO employees VALUES (1, NULL), (2, 1), (3, 99)") + assertFailsWith { + run("ALTER TABLE employees ADD CONSTRAINT fk_manager FOREIGN KEY (manager_id) REFERENCES employees (id)") + } + run("UPDATE employees SET manager_id = 1 WHERE id = 3") + exec("ALTER TABLE employees ADD CONSTRAINT fk_manager FOREIGN KEY (manager_id) REFERENCES employees (id)") + val error = assertFailsWith { run("INSERT INTO employees VALUES (4, 100)") } + assertTrue(error.message!!.contains("FOREIGN KEY constraint failed"), error.message!!) + } + + @Test + fun addConstraintForeignKeyRejectsWhenReferencedColumnsAreNotEnforcedUnique() { + run("CREATE TABLE departments (id INTEGER, name TEXT)") + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER)") + val error = assertFailsWith { + run("ALTER TABLE employees ADD CONSTRAINT fk_dept FOREIGN KEY (dept_id) REFERENCES departments (id)") + } + assertTrue(error.message!!.contains("PRIMARY KEY or UNIQUE"), error.message!!) + } + + @Test + fun addConstraintForeignKeyRejectsAColumnCountMismatch() { + // Caught by the parser itself (parseInlineForeignKey, shared with CREATE TABLE's own + // table-level FOREIGN KEY clause) before Executor.executeAddForeignKeyConstraint's own + // redundant arity check would even run - same as CREATE TABLE's identical redundancy. + run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER)") + val error = assertFailsWith { + run("ALTER TABLE employees ADD CONSTRAINT fk_bad FOREIGN KEY (dept_id) REFERENCES departments (id, name)") + } + assertTrue(error.message!!.contains("column(s)"), error.message!!) + } + + @Test + fun addConstraintForeignKeyOnDeleteCascadeAppliesAfterBeingAdded() { + run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)") + run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER)") + run("INSERT INTO departments VALUES (1, 'eng')") + run("INSERT INTO employees VALUES (1, 1), (2, 1)") + exec("ALTER TABLE employees ADD CONSTRAINT fk_dept FOREIGN KEY (dept_id) REFERENCES departments (id) ON DELETE CASCADE") + run("DELETE FROM departments WHERE id = 1") + assertEquals(emptyList(), select("SELECT id FROM employees").rows) + } + + @Test + fun addConstraintRejectsANameAlreadyUsedByADifferentKindOfConstraintOnTheSameTable() { + run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER, email TEXT)") + run("ALTER TABLE t ADD CONSTRAINT c1 CHECK (age > 0)") + val error = assertFailsWith { run("ALTER TABLE t ADD CONSTRAINT c1 UNIQUE (email)") } + assertTrue(error.message!!.contains("c1"), error.message!!) + } +