Sfoglia il codice sorgente

execute FOREIGN KEY cascades

Accept ON DELETE/ON UPDATE CASCADE, SET NULL, and SET DEFAULT and execute
them as part of the statement that changes the parent row.

The cascade expansion lives in the storage Session so it observes the
transaction overlay: every parent delete or referenced-key update stages the
referencing rows' actions transitively, and the commit-time foreign-key
closure still validates the final state. A cascadeExpansion records the delete
and update events already applied, which terminates self-references and
constraint cycles. Required columns and CHECK predicates are validated per
cascaded row, so SET NULL on a NOT NULL child fails with 23502 immediately;
UNIQUE, foreign-key, and downstream NO ACTION/RESTRICT violations fail before
the batch is published, leaving no partial cascade. Point deletes and updates
on a cascading parent run in an implicit transaction; statements on tables
with only NO ACTION/RESTRICT keep the optimized point-write path.

Referential actions were already persisted; the DDL layer now accepts them
instead of rejecting them with 0A000, and the capability note documents the
supported actions and immediate execution.
Danilo Fragoso 1 giorno fa
parent
commit
6a6b847f94

BIN
bin/pizzasql


File diff suppressed because it is too large
+ 0 - 1
pkg/capabilities/capabilities.go


+ 5 - 3
pkg/capabilities/capabilities_test.go

@@ -149,8 +149,8 @@ func TestCompositePrimaryKeyIsAdvertised(t *testing.T) {
 }
 }
 
 
 // Foreign keys are advertised as enforced, with the SQLSTATE callers should
 // Foreign keys are advertised as enforced, with the SQLSTATE callers should
-// expect for a dangling reference and the explicit exclusions for cascades and
-// deferred enforcement.
+// expect for a dangling reference, the referential actions that execute
+// immediately, and the explicit exclusions for deferred enforcement.
 func TestForeignKeyIsAdvertised(t *testing.T) {
 func TestForeignKeyIsAdvertised(t *testing.T) {
 	doc := Current()
 	doc := Current()
 	fk := doc.Constraints.ForeignKey
 	fk := doc.Constraints.ForeignKey
@@ -164,7 +164,9 @@ func TestForeignKeyIsAdvertised(t *testing.T) {
 		t.Fatalf("foreign_key SQLSTATE = %q, want %q", fk.SQLState, pgerror.CodeForeignKeyViolation)
 		t.Fatalf("foreign_key SQLSTATE = %q, want %q", fk.SQLState, pgerror.CodeForeignKeyViolation)
 	}
 	}
 	for _, want := range []string{
 	for _, want := range []string{
-		"UNIQUE", "COMMIT", "MATCH SIMPLE", "MATCH keyword", "self", "CASCADE", "DEFERRABLE", "0A000",
+		"UNIQUE", "COMMIT", "MATCH SIMPLE", "MATCH keyword", "self",
+		"CASCADE", "SET NULL", "SET DEFAULT", "NOT NULL", "immediately", "transitively",
+		"DEFERRABLE", "0A000",
 		"PRAGMA foreign_keys", "rejects OFF", "foreign_key_list", "database-wide foreign-key write gate",
 		"PRAGMA foreign_keys", "rejects OFF", "foreign_key_list", "database-wide foreign-key write gate",
 		"DROP/RENAME COLUMN", "DROP TABLE", "pg_constraint",
 		"DROP/RENAME COLUMN", "DROP TABLE", "pg_constraint",
 	} {
 	} {

+ 619 - 0
pkg/executor/foreign_key_cascade_test.go

@@ -0,0 +1,619 @@
+package executor
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"github.com/danfragoso/pizzasql-next/pkg/capabilities"
+	"github.com/danfragoso/pizzasql-next/pkg/storage"
+)
+
+// requireNotNullViolation asserts err is a NOT NULL violation (23502).
+func requireNotNullViolation(t *testing.T, err error) *storage.NotNullViolationError {
+	t.Helper()
+	var violation *storage.NotNullViolationError
+	if !errors.As(err, &violation) {
+		t.Fatalf("error = %v, want NotNullViolationError", err)
+	}
+	return violation
+}
+
+// countRows returns the number of rows in table.
+func countRows(t *testing.T, e *Executor, table string) int64 {
+	t.Helper()
+	res := execMust(t, e, "SELECT count(*) FROM "+table)
+	return res.Rows[0][0].(int64)
+}
+
+// ON DELETE CASCADE removes the direct children and, transitively, their own
+// children. rows_affected counts only the rows the statement matched directly.
+func TestForeignKeyDeleteCascadeSingleAndMultiLevel(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE grandparent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY, gp_id INTEGER REFERENCES grandparent(id) ON DELETE CASCADE)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)")
+	execMust(t, e, "INSERT INTO grandparent (id) VALUES (1), (2)")
+	execMust(t, e, "INSERT INTO parent (id, gp_id) VALUES (10, 1), (20, 2)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (100, 10), (101, 10), (200, 20)")
+
+	res := execMust(t, e, "DELETE FROM grandparent WHERE id = 1")
+	if res.RowsAffected != 1 {
+		t.Fatalf("rows affected = %d, want 1 (cascaded rows are not counted)", res.RowsAffected)
+	}
+	if got := countRows(t, e, "grandparent"); got != 1 {
+		t.Fatalf("grandparent rows = %d, want 1", got)
+	}
+	if got := countRows(t, e, "parent"); got != 1 {
+		t.Fatalf("parent rows = %d, want 1", got)
+	}
+	if got := countRows(t, e, "child"); got != 1 {
+		t.Fatalf("child rows = %d, want 1", got)
+	}
+	if res := execMust(t, e, "SELECT id FROM child"); res.Rows[0][0] != int64(200) {
+		t.Fatalf("remaining child = %v, want 200", res.Rows)
+	}
+
+	// A scan delete cascades every matched parent row in one statement.
+	execMust(t, e, "DELETE FROM grandparent")
+	for _, tableName := range []string{"grandparent", "parent", "child"} {
+		if got := countRows(t, e, tableName); got != 0 {
+			t.Fatalf("%s rows after cascading scan delete = %d, want 0", tableName, got)
+		}
+	}
+}
+
+// ON UPDATE CASCADE copies the new parent key into every referencing child,
+// including a referenced UNIQUE index column and a composite key.
+func TestForeignKeyUpdateCascade(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY, code TEXT UNIQUE)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON UPDATE CASCADE, code TEXT REFERENCES parent(code) ON UPDATE CASCADE)")
+	execMust(t, e, "INSERT INTO parent (id, code) VALUES (1, 'a')")
+	execMust(t, e, "INSERT INTO child (id, parent_id, code) VALUES (10, 1, 'a')")
+
+	execMust(t, e, "UPDATE parent SET id = 5 WHERE id = 1")
+	if res := execMust(t, e, "SELECT parent_id, code FROM child WHERE id = 10"); res.Rows[0][0] != int64(5) || res.Rows[0][1] != "a" {
+		t.Fatalf("child after primary-key cascade = %v, want parent_id 5", res.Rows)
+	}
+	execMust(t, e, "UPDATE parent SET code = 'b' WHERE id = 5")
+	if res := execMust(t, e, "SELECT parent_id, code FROM child WHERE id = 10"); res.Rows[0][0] != int64(5) || res.Rows[0][1] != "b" {
+		t.Fatalf("child after unique-key cascade = %v, want code b", res.Rows)
+	}
+
+	execMust(t, e, "CREATE TABLE cparent (a TEXT, b INTEGER, PRIMARY KEY (a, b))")
+	execMust(t, e, "CREATE TABLE cchild (id INTEGER PRIMARY KEY, pa TEXT, pb INTEGER, FOREIGN KEY (pa, pb) REFERENCES cparent(a, b) ON UPDATE CASCADE)")
+	execMust(t, e, "INSERT INTO cparent (a, b) VALUES ('x', 1)")
+	execMust(t, e, "INSERT INTO cchild (id, pa, pb) VALUES (10, 'x', 1)")
+	execMust(t, e, "UPDATE cparent SET a = 'y', b = 2")
+	if res := execMust(t, e, "SELECT pa, pb FROM cchild WHERE id = 10"); res.Rows[0][0] != "y" || res.Rows[0][1] != int64(2) {
+		t.Fatalf("composite child after cascade = %v, want ('y', 2)", res.Rows)
+	}
+}
+
+// SET NULL clears the child columns on delete and on a key update, and a NOT
+// NULL child column rejects the action with 23502 without changing anything.
+func TestForeignKeySetNull(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE SET NULL ON UPDATE SET NULL)")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 1)")
+
+	execMust(t, e, "UPDATE parent SET id = 2 WHERE id = 1")
+	if res := execMust(t, e, "SELECT parent_id FROM child WHERE id = 10"); res.Rows[0][0] != nil {
+		t.Fatalf("parent_id after ON UPDATE SET NULL = %v, want NULL", res.Rows[0][0])
+	}
+	execMust(t, e, "UPDATE child SET parent_id = 2 WHERE id = 10")
+	execMust(t, e, "DELETE FROM parent WHERE id = 2")
+	if res := execMust(t, e, "SELECT parent_id FROM child WHERE id = 10"); res.Rows[0][0] != nil {
+		t.Fatalf("parent_id after ON DELETE SET NULL = %v, want NULL", res.Rows[0][0])
+	}
+
+	execMust(t, e, "CREATE TABLE required_parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE required_child (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES required_parent(id) ON DELETE SET NULL)")
+	execMust(t, e, "INSERT INTO required_parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO required_child (id, parent_id) VALUES (10, 1)")
+
+	_, err := execSQL(e, "DELETE FROM required_parent WHERE id = 1")
+	requireNotNullViolation(t, err)
+	if got := countRows(t, e, "required_parent"); got != 1 {
+		t.Fatalf("required_parent rows after rejected cascade = %d, want 1", got)
+	}
+	if res := execMust(t, e, "SELECT parent_id FROM required_child WHERE id = 10"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("required_child.parent_id after rejected cascade = %v, want 1", res.Rows[0][0])
+	}
+}
+
+// SET DEFAULT rewrites the child columns to their declared defaults, honoring
+// NOT NULL, CHECK, and FOREIGN KEY on the resulting row.
+func TestForeignKeySetDefault(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER DEFAULT 1 REFERENCES parent(id) ON DELETE SET DEFAULT ON UPDATE SET DEFAULT)")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1), (2), (4)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 2), (11, 4)")
+
+	execMust(t, e, "DELETE FROM parent WHERE id = 2")
+	if res := execMust(t, e, "SELECT parent_id FROM child WHERE id = 10"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("parent_id after ON DELETE SET DEFAULT = %v, want 1", res.Rows[0][0])
+	}
+	execMust(t, e, "UPDATE parent SET id = 3 WHERE id = 4")
+	if res := execMust(t, e, "SELECT parent_id FROM child WHERE id = 11"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("parent_id after ON UPDATE SET DEFAULT = %v, want the stationary default 1", res.Rows[0][0])
+	}
+
+	// A default that violates CHECK rolls the whole delete back with 23514.
+	execMust(t, e, "CREATE TABLE checked_parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE checked_child (id INTEGER PRIMARY KEY, parent_id INTEGER DEFAULT 0 CHECK (parent_id > 0) REFERENCES checked_parent(id) ON DELETE SET DEFAULT)")
+	execMust(t, e, "INSERT INTO checked_parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO checked_child (id, parent_id) VALUES (10, 1)")
+	_, err := execSQL(e, "DELETE FROM checked_parent WHERE id = 1")
+	requireCheckViolation(t, err, "")
+	if got := countRows(t, e, "checked_parent"); got != 1 {
+		t.Fatalf("checked_parent rows after rejected cascade = %d, want 1", got)
+	}
+
+	// A NOT NULL column without a default yields NULL, which is rejected.
+	execMust(t, e, "CREATE TABLE strict_parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE strict_child (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES strict_parent(id) ON DELETE SET DEFAULT)")
+	execMust(t, e, "INSERT INTO strict_parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO strict_child (id, parent_id) VALUES (10, 1)")
+	_, err = execSQL(e, "DELETE FROM strict_parent WHERE id = 1")
+	requireNotNullViolation(t, err)
+
+	// A default with no matching parent is a dangling reference at commit.
+	execMust(t, e, "CREATE TABLE dangling_parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE dangling_child (id INTEGER PRIMARY KEY, parent_id INTEGER DEFAULT 99 REFERENCES dangling_parent(id) ON DELETE SET DEFAULT)")
+	execMust(t, e, "INSERT INTO dangling_parent (id) VALUES (10)")
+	execMust(t, e, "INSERT INTO dangling_child (id, parent_id) VALUES (20, 10)")
+	_, err = execSQL(e, "DELETE FROM dangling_parent WHERE id = 10")
+	requireForeignKeyViolation(t, err)
+	if got := countRows(t, e, "dangling_child"); got != 1 {
+		t.Fatalf("dangling_child rows after rejected cascade = %d, want 1", got)
+	}
+}
+
+// A self-referential tree cascades transitively without visiting the deleted row
+// as its own child, and ON UPDATE CASCADE rewrites a row that references itself.
+func TestForeignKeySelfReferentialCascade(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE nodes (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES nodes(id) ON DELETE CASCADE ON UPDATE CASCADE)")
+	execMust(t, e, "INSERT INTO nodes (id, parent_id) VALUES (1, NULL), (2, 1), (3, 2), (4, NULL), (5, 5)")
+
+	execMust(t, e, "DELETE FROM nodes WHERE id = 1")
+	if res := execMust(t, e, "SELECT id FROM nodes ORDER BY id"); res.RowCount != 2 || res.Rows[0][0] != int64(4) || res.Rows[1][0] != int64(5) {
+		t.Fatalf("nodes after cascading delete = %v, want 4 and 5", res.Rows)
+	}
+	// A row that references itself leaves with its own reference.
+	execMust(t, e, "DELETE FROM nodes WHERE id = 5")
+	if got := countRows(t, e, "nodes"); got != 1 {
+		t.Fatalf("nodes after self-referencing delete = %d, want 1", got)
+	}
+
+	execMust(t, e, "DELETE FROM nodes")
+	execMust(t, e, "INSERT INTO nodes (id, parent_id) VALUES (1, 1), (2, 1)")
+	execMust(t, e, "UPDATE nodes SET id = 10 WHERE id = 1")
+	if res := execMust(t, e, "SELECT id, parent_id FROM nodes ORDER BY id"); res.RowCount != 2 ||
+		res.Rows[0][0] != int64(2) || res.Rows[0][1] != int64(10) ||
+		res.Rows[1][0] != int64(10) || res.Rows[1][1] != int64(10) {
+		t.Fatalf("nodes after self-referencing key update = %v, want (2,10) and (10,10)", res.Rows)
+	}
+}
+
+// installForeignKey appends a foreign key to table's persisted catalog. A
+// two-table cycle cannot be declared through CREATE TABLE because a forward
+// reference to a table that does not exist yet is rejected, so the test writes
+// the durable catalog directly and reopens the database.
+func installForeignKey(t *testing.T, pool *storage.KVPool, table string, fk storage.ForeignKeyConstraint) {
+	t.Helper()
+	schema := storage.NewSchemaManager(pool, "testdb")
+	stored, err := schema.GetSchema(table)
+	if err != nil {
+		t.Fatalf("get schema %s: %v", table, err)
+	}
+	stored.ForeignKeys = append(stored.ForeignKeys, fk)
+	data, err := json.Marshal(stored)
+	if err != nil {
+		t.Fatalf("marshal schema %s: %v", table, err)
+	}
+	key := "testdb:_schema:" + strings.ToLower(table)
+	if err := pool.WithClient(func(c *storage.KVClient) error { return c.Write(key, string(data)) }); err != nil {
+		t.Fatalf("write schema %s: %v", table, err)
+	}
+}
+
+// Two tables that reference each other form a cascade cycle. Deleting or
+// re-keying a row must terminate instead of bouncing between the tables.
+func TestForeignKeyMutualCascadeCycleTerminates(t *testing.T) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE a (id INTEGER PRIMARY KEY, b_id INTEGER)")
+	execMust(t, e, "CREATE TABLE b (id INTEGER PRIMARY KEY, a_id INTEGER REFERENCES a(id) ON DELETE CASCADE ON UPDATE CASCADE)")
+	installForeignKey(t, pool, "a", storage.ForeignKeyConstraint{
+		Name:       "a_b_fk",
+		Columns:    []string{"b_id"},
+		RefTable:   "b",
+		RefColumns: []string{"id"},
+		OnDelete:   "CASCADE",
+		OnUpdate:   "CASCADE",
+	})
+
+	reloaded := storage.NewSchemaManager(pool, "testdb")
+	cyc := newExec(reloaded, storage.NewTableManager(pool, reloaded, "testdb"))
+
+	insertCycle := func(aID, bID int) {
+		t.Helper()
+		execMust(t, cyc, "BEGIN")
+		execMust(t, cyc, fmt.Sprintf("INSERT INTO b (id, a_id) VALUES (%d, %d)", bID, aID))
+		execMust(t, cyc, fmt.Sprintf("INSERT INTO a (id, b_id) VALUES (%d, %d)", aID, bID))
+		execMust(t, cyc, "COMMIT")
+	}
+
+	insertCycle(1, 1)
+	execMust(t, cyc, "DELETE FROM a WHERE id = 1")
+	if got := countRows(t, cyc, "a") + countRows(t, cyc, "b"); got != 0 {
+		t.Fatalf("cycle rows after cascading delete = %d, want 0", got)
+	}
+
+	insertCycle(1, 1)
+	execMust(t, cyc, "UPDATE a SET id = 2 WHERE id = 1")
+	res := execMust(t, cyc, "SELECT id, b_id FROM a")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(2) || res.Rows[0][1] != int64(1) {
+		t.Fatalf("a after cycle key update = %v, want (2,1)", res.Rows)
+	}
+	res = execMust(t, cyc, "SELECT id, a_id FROM b")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) || res.Rows[0][1] != int64(2) {
+		t.Fatalf("b after cycle key update = %v, want (1,2)", res.Rows)
+	}
+	execMust(t, cyc, "UPDATE b SET id = 5 WHERE id = 1")
+	res = execMust(t, cyc, "SELECT id, b_id FROM a")
+	if res.RowCount != 1 || res.Rows[0][0] != int64(2) || res.Rows[0][1] != int64(5) {
+		t.Fatalf("a after second cycle key update = %v, want (2,5)", res.Rows)
+	}
+}
+
+// NO ACTION and RESTRICT keep the immediate 23503 behavior.
+func TestForeignKeyNoActionRestrictStillViolate(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE restricting (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE RESTRICT ON UPDATE RESTRICT)")
+	execMust(t, e, "CREATE TABLE no_action (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO restricting (id, parent_id) VALUES (10, 1)")
+	execMust(t, e, "INSERT INTO no_action (id, parent_id) VALUES (20, 1)")
+
+	_, err := execSQL(e, "DELETE FROM parent WHERE id = 1")
+	requireForeignKeyViolation(t, err)
+	_, err = execSQL(e, "UPDATE parent SET id = 2 WHERE id = 1")
+	requireForeignKeyViolation(t, err)
+
+	execMust(t, e, "DELETE FROM restricting")
+	_, err = execSQL(e, "DELETE FROM parent WHERE id = 1")
+	requireForeignKeyViolation(t, err)
+	execMust(t, e, "DELETE FROM no_action")
+	execMust(t, e, "DELETE FROM parent WHERE id = 1")
+}
+
+// A cascaded row that is itself referenced by a NO ACTION child aborts the whole
+// statement; no partially cascaded state is durable.
+func TestForeignKeyCascadeDownstreamFailureIsAtomic(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE grandparent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY, gp_id INTEGER REFERENCES grandparent(id) ON DELETE CASCADE)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
+	execMust(t, e, "INSERT INTO grandparent (id) VALUES (1), (2)")
+	execMust(t, e, "INSERT INTO parent (id, gp_id) VALUES (10, 1), (20, 2)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (100, 10)")
+
+	_, err := execSQL(e, "DELETE FROM grandparent WHERE id = 1")
+	requireForeignKeyViolation(t, err)
+	if got := countRows(t, e, "grandparent"); got != 2 {
+		t.Fatalf("grandparent rows after rejected cascade = %d, want 2", got)
+	}
+	if got := countRows(t, e, "parent"); got != 2 {
+		t.Fatalf("parent rows after rejected cascade = %d, want 2", got)
+	}
+	if got := countRows(t, e, "child"); got != 1 {
+		t.Fatalf("child rows after rejected cascade = %d, want 1", got)
+	}
+
+	// A multi-row delete is atomic: the row with the blocked chain also keeps
+	// the unrelated row in place.
+	_, err = execSQL(e, "DELETE FROM grandparent")
+	requireForeignKeyViolation(t, err)
+	if got := countRows(t, e, "grandparent"); got != 2 {
+		t.Fatalf("grandparent rows after rejected scan delete = %d, want 2", got)
+	}
+	if got := countRows(t, e, "parent"); got != 2 {
+		t.Fatalf("parent rows after rejected scan delete = %d, want 2", got)
+	}
+
+	// Removing the downstream reference lets the same delete cascade.
+	execMust(t, e, "DELETE FROM child")
+	execMust(t, e, "DELETE FROM grandparent")
+	if got := countRows(t, e, "grandparent") + countRows(t, e, "parent"); got != 0 {
+		t.Fatalf("rows after successful cascade = %d, want 0", got)
+	}
+}
+
+// A cascade inside an explicit transaction interacts with the commit-time final
+// state check: a consistent result commits and a dangling one does not.
+func TestForeignKeyCascadeInTransaction(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1), (2)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 1), (11, 1), (20, 2)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DELETE FROM parent WHERE id = 1")
+	execMust(t, e, "SELECT count(*) FROM child")
+	execMust(t, e, "COMMIT")
+	if got := countRows(t, e, "child"); got != 1 {
+		t.Fatalf("child rows after transactional cascade = %d, want 1", got)
+	}
+
+	// Re-adding a child under the deleted parent makes the final state dangling,
+	// so the commit is rejected and the cascade is discarded.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DELETE FROM parent WHERE id = 2")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (99, 2)")
+	_, err := execSQL(e, "COMMIT")
+	requireForeignKeyViolation(t, err)
+	if got := countRows(t, e, "parent"); got != 1 {
+		t.Fatalf("parent rows after rejected commit = %d, want 1", got)
+	}
+	if res := execMust(t, e, "SELECT id, parent_id FROM child ORDER BY id"); res.RowCount != 1 || res.Rows[0][0] != int64(20) {
+		t.Fatalf("child rows after rejected commit = %v, want only row 20", res.Rows)
+	}
+
+	// A point delete inside a transaction cascades too.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DELETE FROM parent WHERE id = 2")
+	execMust(t, e, "COMMIT")
+	if got := countRows(t, e, "parent") + countRows(t, e, "child"); got != 0 {
+		t.Fatalf("rows after transactional point cascade = %d, want 0", got)
+	}
+}
+
+// Referential actions persist with the schema and are enforced after a reload.
+func TestForeignKeyCascadeDurableReload(t *testing.T) {
+	pool, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, CONSTRAINT child_parent FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE CASCADE ON UPDATE CASCADE)")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 1)")
+
+	reloaded := storage.NewSchemaManager(pool, "testdb")
+	stored, err := reloaded.GetSchema("child")
+	if err != nil {
+		t.Fatalf("reload schema: %v", err)
+	}
+	if len(stored.ForeignKeys) != 1 || stored.ForeignKeys[0].OnDelete != "CASCADE" || stored.ForeignKeys[0].OnUpdate != "CASCADE" {
+		t.Fatalf("reloaded foreign keys = %+v, want ON DELETE/UPDATE CASCADE", stored.ForeignKeys)
+	}
+
+	reopened := newExec(reloaded, storage.NewTableManager(pool, reloaded, "testdb"))
+	execMust(t, reopened, "UPDATE parent SET id = 2 WHERE id = 1")
+	if res := execMust(t, reopened, "SELECT parent_id FROM child WHERE id = 10"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("child after reloaded ON UPDATE CASCADE = %v, want 2", res.Rows[0][0])
+	}
+	execMust(t, reopened, "DELETE FROM parent WHERE id = 2")
+	if got := countRows(t, reopened, "child"); got != 0 {
+		t.Fatalf("child rows after reloaded ON DELETE CASCADE = %d, want 0", got)
+	}
+}
+
+// The emulated SQLite catalog reproduces every accepted referential action.
+func TestForeignKeyCascadeInSQLiteCatalog(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, CONSTRAINT child_parent FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE CASCADE ON UPDATE SET NULL)")
+	execMust(t, e, "CREATE TABLE defaulted (id INTEGER PRIMARY KEY, parent_id INTEGER DEFAULT 1 REFERENCES parent(id) ON DELETE SET DEFAULT)")
+
+	res := execMust(t, e, "SELECT sql FROM sqlite_master WHERE name = 'child'")
+	sql, _ := res.Rows[0][0].(string)
+	want := "CONSTRAINT `child_parent` FOREIGN KEY (`parent_id`) REFERENCES `parent` (`id`) ON DELETE CASCADE ON UPDATE SET NULL"
+	if !strings.Contains(sql, want) {
+		t.Fatalf("catalog SQL %q does not contain %q", sql, want)
+	}
+	res = execMust(t, e, "SELECT sql FROM sqlite_master WHERE name = 'defaulted'")
+	sql, _ = res.Rows[0][0].(string)
+	if !strings.Contains(sql, "ON DELETE SET DEFAULT") {
+		t.Fatalf("catalog SQL %q does not contain the SET DEFAULT action", sql)
+	}
+}
+
+// The capability document advertises the supported referential actions.
+func TestForeignKeyCascadeCapability(t *testing.T) {
+	notes := capabilities.Current().Constraints.ForeignKey.Notes
+	for _, want := range []string{"CASCADE", "SET NULL", "SET DEFAULT", "immediately"} {
+		if !strings.Contains(notes, want) {
+			t.Fatalf("foreign_key capability notes = %q, want %q", notes, want)
+		}
+	}
+}
+
+// A cascading delete that races a child insert must not deadlock on the
+// database-wide foreign-key write gate. Either statement may lose (a
+// serialization failure or a 23503), but the pair always completes and never
+// leaves a dangling child row.
+func TestForeignKeyCascadeConcurrentDeleteNoDeadlock(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	deleter := newExec(schema, table)
+	inserter := newExec(schema, table)
+	execMust(t, inserter, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, inserter, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)")
+
+	const rounds = 40
+	for round := 1; round <= rounds; round++ {
+		execMust(t, inserter, fmt.Sprintf("INSERT INTO parent (id) VALUES (%d)", round))
+
+		start := make(chan struct{})
+		results := make(chan error, 2)
+		var wg sync.WaitGroup
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			<-start
+			_, err := execSQL(deleter, fmt.Sprintf("DELETE FROM parent WHERE id = %d", round))
+			results <- err
+		}()
+		go func() {
+			defer wg.Done()
+			<-start
+			_, err := execSQL(inserter, fmt.Sprintf("INSERT INTO child (id, parent_id) VALUES (%d, %d)", round, round))
+			results <- err
+		}()
+		close(start)
+
+		done := make(chan struct{})
+		go func() {
+			wg.Wait()
+			close(done)
+		}()
+		select {
+		case <-done:
+		case <-time.After(30 * time.Second):
+			t.Fatalf("round %d: cascading delete and child insert deadlocked", round)
+		}
+		close(results)
+		successes := 0
+		for err := range results {
+			if err == nil {
+				successes++
+				continue
+			}
+			var violation *storage.ForeignKeyViolationError
+			if !errors.As(err, &violation) && !errors.Is(err, storage.ErrSerialization) {
+				t.Fatalf("round %d: unexpected error %v", round, err)
+			}
+		}
+		if successes == 0 {
+			t.Fatalf("round %d: both statements failed", round)
+		}
+
+		dangling := execMust(t, inserter, "SELECT count(*) FROM child c WHERE NOT EXISTS (SELECT 1 FROM parent p WHERE p.id = c.parent_id)")
+		if dangling.Rows[0][0] != int64(0) {
+			t.Fatalf("round %d: %v dangling child rows", round, dangling.Rows[0][0])
+		}
+		execMust(t, inserter, "DELETE FROM child")
+		execMust(t, inserter, fmt.Sprintf("DELETE FROM parent WHERE id = %d", round))
+	}
+}
+
+// A cascaded rewrite moves the child's primary key when a foreign-key column is
+// part of it.
+func TestForeignKeyCascadeChildPrimaryKeyMoves(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER, parent_id INTEGER REFERENCES parent(id) ON UPDATE CASCADE ON DELETE CASCADE, PRIMARY KEY (parent_id, id))")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 1), (11, 1)")
+
+	execMust(t, e, "UPDATE parent SET id = 2 WHERE id = 1")
+	res := execMust(t, e, "SELECT id, parent_id FROM child ORDER BY id")
+	if res.RowCount != 2 || res.Rows[0][1] != int64(2) || res.Rows[1][1] != int64(2) {
+		t.Fatalf("child rows after composite key move = %v, want both parent_id 2", res.Rows)
+	}
+
+	execMust(t, e, "DELETE FROM parent WHERE id = 2")
+	if got := countRows(t, e, "child"); got != 0 {
+		t.Fatalf("child rows after cascade = %d, want 0", got)
+	}
+}
+
+// A cascade that makes a UNIQUE index collide rolls back the whole delete.
+func TestForeignKeyCascadeUniqueViolationRollsBack(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER UNIQUE DEFAULT 1 REFERENCES parent(id) ON DELETE SET DEFAULT)")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1), (2)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 1), (11, 2)")
+
+	_, err := execSQL(e, "DELETE FROM parent WHERE id = 2")
+	if err == nil {
+		t.Fatal("cascade with a UNIQUE collision was accepted")
+	}
+	var violation *storage.UniqueViolationError
+	if !errors.As(err, &violation) {
+		t.Fatalf("error = %v, want UniqueViolationError", err)
+	}
+	if got := countRows(t, e, "parent"); got != 2 {
+		t.Fatalf("parent rows after rejected cascade = %d, want 2", got)
+	}
+	if res := execMust(t, e, "SELECT id, parent_id FROM child ORDER BY id"); res.RowCount != 2 || res.Rows[1][1] != int64(2) {
+		t.Fatalf("child rows after rejected cascade = %v, want the original values", res.Rows)
+	}
+}
+
+// Inside an explicit transaction the commit-time foreign-key closure validation
+// rejects a cascade that leaves a downstream NO ACTION row dangling, and the
+// whole transaction is discarded.
+func TestForeignKeyCascadeTransactionDownstreamFailure(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE grandparent (id INTEGER PRIMARY KEY)")
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY, gp_id INTEGER REFERENCES grandparent(id) ON DELETE CASCADE)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
+	execMust(t, e, "INSERT INTO grandparent (id) VALUES (1)")
+	execMust(t, e, "INSERT INTO parent (id, gp_id) VALUES (10, 1)")
+	execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (100, 10)")
+
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DELETE FROM grandparent WHERE id = 1")
+	_, err := execSQL(e, "COMMIT")
+	requireForeignKeyViolation(t, err)
+	if got := countRows(t, e, "grandparent"); got != 1 {
+		t.Fatalf("grandparent rows after rejected commit = %d, want 1", got)
+	}
+	if got := countRows(t, e, "parent"); got != 1 {
+		t.Fatalf("parent rows after rejected commit = %d, want 1", got)
+	}
+	if got := countRows(t, e, "child"); got != 1 {
+		t.Fatalf("child rows after rejected commit = %d, want 1", got)
+	}
+
+	// The same transaction commits once the downstream row is part of the
+	// cascade itself.
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DELETE FROM child WHERE id = 100")
+	execMust(t, e, "DELETE FROM grandparent WHERE id = 1")
+	execMust(t, e, "COMMIT")
+	if got := countRows(t, e, "grandparent") + countRows(t, e, "parent") + countRows(t, e, "child"); got != 0 {
+		t.Fatalf("rows after committed cascade = %d, want 0", got)
+	}
+}
+
+// A referenced UNIQUE column may be nullable: moving it to NULL cascades the
+// NULL into the child, which leaves the reference in its MATCH SIMPLE form.
+func TestForeignKeyUpdateCascadeToNull(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE parent (id INTEGER PRIMARY KEY, code TEXT UNIQUE)")
+	execMust(t, e, "CREATE TABLE child (id INTEGER PRIMARY KEY, code TEXT REFERENCES parent(code) ON UPDATE CASCADE)")
+	execMust(t, e, "INSERT INTO parent (id, code) VALUES (1, 'x')")
+	execMust(t, e, "INSERT INTO child (id, code) VALUES (10, 'x')")
+
+	execMust(t, e, "UPDATE parent SET code = NULL WHERE id = 1")
+	if res := execMust(t, e, "SELECT code FROM child WHERE id = 10"); res.Rows[0][0] != nil {
+		t.Fatalf("child.code after cascade to NULL = %v, want NULL", res.Rows[0][0])
+	}
+}

+ 0 - 24
pkg/executor/foreign_key_test.go

@@ -428,30 +428,6 @@ func TestForeignKeyDDLRejections(t *testing.T) {
 			ddl:   "CREATE TABLE child (id INTEGER PRIMARY KEY, a TEXT, FOREIGN KEY (a) REFERENCES parent(a, b))",
 			ddl:   "CREATE TABLE child (id INTEGER PRIMARY KEY, a TEXT, FOREIGN KEY (a) REFERENCES parent(a, b))",
 			want:  "same number of columns",
 			want:  "same number of columns",
 		},
 		},
-		{
-			name:  "on delete cascade",
-			setup: []string{"CREATE TABLE parent (id INTEGER PRIMARY KEY)"},
-			ddl:   "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)",
-			want:  "cascades are not implemented",
-		},
-		{
-			name:  "on update cascade",
-			setup: []string{"CREATE TABLE parent (id INTEGER PRIMARY KEY)"},
-			ddl:   "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id) ON UPDATE CASCADE)",
-			want:  "cascades are not implemented",
-		},
-		{
-			name:  "on delete set null",
-			setup: []string{"CREATE TABLE parent (id INTEGER PRIMARY KEY)"},
-			ddl:   "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE SET NULL)",
-			want:  "cascades are not implemented",
-		},
-		{
-			name:  "on delete set default",
-			setup: []string{"CREATE TABLE parent (id INTEGER PRIMARY KEY)"},
-			ddl:   "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent(id) ON DELETE SET DEFAULT)",
-			want:  "cascades are not implemented",
-		},
 		{
 		{
 			name:  "deferrable",
 			name:  "deferrable",
 			setup: []string{"CREATE TABLE parent (id INTEGER PRIMARY KEY)"},
 			setup: []string{"CREATE TABLE parent (id INTEGER PRIMARY KEY)"},

+ 0 - 12
pkg/executor/foreign_keys.go

@@ -78,18 +78,6 @@ func (e *Executor) resolveForeignKey(table string, schema *storage.Schema, uniqu
 			label, table,
 			label, table,
 		)
 		)
 	}
 	}
-	for _, action := range []struct {
-		clause string
-		value  parser.ReferentialAction
-	}{{"ON DELETE", declaration.onDelete}, {"ON UPDATE", declaration.onUpdate}} {
-		switch action.value {
-		case parser.RefActionCascade, parser.RefActionSetNull, parser.RefActionSetDefault:
-			return storage.ForeignKeyConstraint{}, pgerror.Unsupported(
-				"foreign key %s on table %s uses %s %s; cascades are not implemented, use NO ACTION or RESTRICT",
-				label, table, action.clause, action.value,
-			)
-		}
-	}
 
 
 	childColumns := make([]string, 0, len(declaration.columns))
 	childColumns := make([]string, 0, len(declaration.columns))
 	seen := make(map[string]bool, len(declaration.columns))
 	seen := make(map[string]bool, len(declaration.columns))

+ 3 - 4
pkg/parser/parser.go

@@ -1536,10 +1536,9 @@ func (p *Parser) parseOnConflictAction() (ConflictAction, bool, error) {
 
 
 // parseForeignKeyClauses parses the optional ON DELETE/ON UPDATE referential
 // parseForeignKeyClauses parses the optional ON DELETE/ON UPDATE referential
 // actions and the optional DEFERRABLE clause that follow a REFERENCES target.
 // actions and the optional DEFERRABLE clause that follow a REFERENCES target.
-// The engine enforces foreign keys immediately, so the parsed actions are kept
-// for the DDL layer to accept (NO ACTION/RESTRICT) or reject (cascades), and a
-// DEFERRABLE clause is recorded so it can be rejected instead of silently
-// weakening the constraint.
+// Every action is parsed and persisted; the storage layer executes the
+// cascading forms, and a DEFERRABLE clause is recorded so the DDL layer can
+// reject it instead of silently weakening the constraint.
 func (p *Parser) parseForeignKeyClauses(onDelete, onUpdate *ReferentialAction, deferrable *bool) error {
 func (p *Parser) parseForeignKeyClauses(onDelete, onUpdate *ReferentialAction, deferrable *bool) error {
 	for p.curTokenIs(lexer.TokenON) {
 	for p.curTokenIs(lexer.TokenON) {
 		p.nextToken()
 		p.nextToken()

+ 375 - 0
pkg/storage/foreign_key_cascade.go

@@ -0,0 +1,375 @@
+package storage
+
+import (
+	"fmt"
+	"strings"
+	"time"
+)
+
+// ReferentialAction is one persisted FOREIGN KEY ON DELETE/ON UPDATE action.
+// The catalog stores the SQL spelling so sqlite_master can reproduce the
+// declaration; an omitted clause and NO ACTION behave identically.
+type ReferentialAction string
+
+const (
+	ReferentialActionNoAction   ReferentialAction = "NO ACTION"
+	ReferentialActionRestrict   ReferentialAction = "RESTRICT"
+	ReferentialActionCascade    ReferentialAction = "CASCADE"
+	ReferentialActionSetNull    ReferentialAction = "SET NULL"
+	ReferentialActionSetDefault ReferentialAction = "SET DEFAULT"
+)
+
+// DeleteAction returns the action applied when the parent row is deleted.
+func (fk ForeignKeyConstraint) DeleteAction() ReferentialAction {
+	return parseReferentialAction(fk.OnDelete)
+}
+
+// UpdateAction returns the action applied when a referenced parent key changes.
+func (fk ForeignKeyConstraint) UpdateAction() ReferentialAction {
+	return parseReferentialAction(fk.OnUpdate)
+}
+
+// parseReferentialAction maps the persisted spelling to its action. Anything
+// unrecognized is NO ACTION, the default the catalog uses when the clause was
+// omitted, so a hand-edited catalog cannot silently disable enforcement.
+func parseReferentialAction(text string) ReferentialAction {
+	switch strings.ToUpper(strings.TrimSpace(text)) {
+	case "CASCADE":
+		return ReferentialActionCascade
+	case "SET NULL":
+		return ReferentialActionSetNull
+	case "SET DEFAULT":
+		return ReferentialActionSetDefault
+	case "RESTRICT":
+		return ReferentialActionRestrict
+	default:
+		return ReferentialActionNoAction
+	}
+}
+
+// rewritesReferencingRows reports whether the action changes or removes the
+// child rows instead of rejecting the parent change.
+func (a ReferentialAction) rewritesReferencingRows() bool {
+	switch a {
+	case ReferentialActionCascade, ReferentialActionSetNull, ReferentialActionSetDefault:
+		return true
+	default:
+		return false
+	}
+}
+
+// HasCascadingReference reports whether table is the referenced parent of a
+// constraint whose DELETE action (forDelete) or UPDATE action rewrites the
+// referencing rows. Callers use it to choose the transactional write path, so a
+// statement whose only actions are NO ACTION/RESTRICT keeps the optimized
+// point-write path.
+func (m *SchemaManager) HasCascadingReference(table string, forDelete bool) (bool, error) {
+	refs, err := m.referencingForeignKeys(table)
+	if err != nil {
+		return false, err
+	}
+	for _, ref := range refs {
+		action := ref.FK.UpdateAction()
+		if forDelete {
+			action = ref.FK.DeleteAction()
+		}
+		if action.rewritesReferencingRows() {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// foreignKeyRawValues returns the unformatted values a row holds for the given
+// columns, and reports false when a column is missing. A NULL component is
+// returned as nil: on a parent-side update the cascading action copies the new
+// value, including a NULL that clears the reference.
+func foreignKeyRawValues(row Row, columns []string) ([]interface{}, bool) {
+	values := make([]interface{}, len(columns))
+	for i, name := range columns {
+		value, ok := rowColumnValue(row, name)
+		if !ok {
+			return nil, false
+		}
+		values[i] = value
+	}
+	return values, true
+}
+
+// foreignKeyActionKey formats a row's values for the cascade event key, keeping
+// a NULL component distinguishable so a key that becomes NULL still changes the
+// event identity.
+func foreignKeyActionKey(row Row, columns []string) ([]string, bool) {
+	values := make([]string, len(columns))
+	for i, name := range columns {
+		value, ok := rowColumnValue(row, name)
+		if !ok {
+			return nil, false
+		}
+		if value == nil {
+			values[i] = "\x00NULL"
+			continue
+		}
+		values[i] = formatIndexValue(value)
+	}
+	return values, true
+}
+
+// cascadeExpansion tracks the referential actions one statement already applied.
+// It is the termination guard for self-references and constraint cycles: a row
+// delete is applied at most once, and one (row, constraint, old key, new key)
+// child update at most once. Because the applied event includes the key values
+// involved, a cycle that revisits a row with a different key still terminates:
+// the set of values a cascade can copy is bounded by the rows it starts from.
+type cascadeExpansion struct {
+	applied map[string]bool
+	// now is the statement instant used to evaluate a SET DEFAULT expression
+	// default, so every cascaded row of one statement sees the same value.
+	now time.Time
+}
+
+func newCascadeExpansion(schema *SchemaManager) *cascadeExpansion {
+	return &cascadeExpansion{applied: make(map[string]bool), now: schema.defaultNow()}
+}
+
+func (e *cascadeExpansion) markDelete(table, key string) bool {
+	id := "D\x00" + strings.ToLower(table) + "\x00" + key
+	if e.applied[id] {
+		return false
+	}
+	e.applied[id] = true
+	return true
+}
+
+func (e *cascadeExpansion) markUpdate(table, key string, fk ForeignKeyConstraint, oldKey, newKey []string) bool {
+	id := "U\x00" + strings.ToLower(table) + "\x00" + key + "\x00" + strings.ToLower(fk.RefTable) + "\x00" + joinForeignKeyValues(fk.Columns) + "\x00" + joinForeignKeyValues(oldKey) + "\x00" + joinForeignKeyValues(newKey)
+	if e.applied[id] {
+		return false
+	}
+	e.applied[id] = true
+	return true
+}
+
+// referencingRows returns the effective child rows (durable rows with the
+// transaction overlay applied) that reference key through ref. The caller holds
+// the session lock, and selectLocked registers the read so a concurrent write on
+// the child side invalidates the commit instead of interleaving.
+func (s *Session) referencingRows(ref referencingForeignKey, key []string) ([]Row, error) {
+	return s.selectLocked(ref.Table, func(row Row) bool {
+		return foreignKeyMatches(row, ref.FK.Columns, key)
+	})
+}
+
+// stageCascadeDelete stages a row delete plus the ON DELETE actions of every
+// constraint that names its table as the parent. The row is staged before the
+// child scan, so a self-referential row is not visited as its own child.
+func (s *Session) stageCascadeDelete(table, key string, row Row, exp *cascadeExpansion) error {
+	if !exp.markDelete(table, key) {
+		return nil
+	}
+	if entry, ok := s.overlay[strings.ToLower(table)][key]; ok && entry.absent {
+		return nil
+	}
+	s.stageDelete(table, key, row)
+	return s.cascadeAfterDelete(table, row, exp)
+}
+
+// cascadeAfterDelete applies the ON DELETE actions triggered by removing row
+// from table.
+func (s *Session) cascadeAfterDelete(table string, row Row, exp *cascadeExpansion) error {
+	refs, err := s.schema.referencingForeignKeys(table)
+	if err != nil {
+		return err
+	}
+	for _, ref := range refs {
+		action := ref.FK.DeleteAction()
+		if !action.rewritesReferencingRows() {
+			continue
+		}
+		key, ok := foreignKeyValues(row, ref.FK.RefColumns)
+		if !ok {
+			continue
+		}
+		children, err := s.referencingRows(ref, key)
+		if err != nil {
+			return err
+		}
+		for _, child := range children {
+			if err := s.applyDeleteAction(ref, child, action, exp); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// applyDeleteAction performs one row's ON DELETE action. CASCADE removes the
+// child and recurses through its own constraints; SET NULL/SET DEFAULT rewrite
+// the child's foreign-key columns and then propagate as an update, because those
+// columns can themselves be a key another table references.
+func (s *Session) applyDeleteAction(ref referencingForeignKey, child Row, action ReferentialAction, exp *cascadeExpansion) error {
+	schema, err := s.schema.GetSchema(ref.Table)
+	if err != nil {
+		return err
+	}
+	pk, err := schema.PrimaryKeyForRow(child)
+	if err != nil {
+		return err
+	}
+	key := s.table.dataKey(ref.Table, pk)
+	if action == ReferentialActionCascade {
+		return s.stageCascadeDelete(ref.Table, key, child, exp)
+	}
+	updated, err := s.applySetAction(schema, ref.FK, child, action, exp)
+	if err != nil {
+		return err
+	}
+	return s.stageCascadeUpdate(ref.Table, child, updated, exp)
+}
+
+// applySetAction returns the child row with its foreign-key columns replaced by
+// NULL or by the column defaults. A column without a default receives NULL, the
+// SQL default, which then fails NOT NULL if the column is required.
+func (s *Session) applySetAction(schema *Schema, fk ForeignKeyConstraint, child Row, action ReferentialAction, exp *cascadeExpansion) (Row, error) {
+	updated := cloneRow(child)
+	for _, name := range fk.Columns {
+		if action == ReferentialActionSetNull {
+			updated[name] = nil
+			continue
+		}
+		column, ok := schema.GetColumn(name)
+		if !ok {
+			return nil, fmt.Errorf("foreign key %s: column %s is missing from table %s", fk.Label(), name, schema.Name)
+		}
+		value, hasDefault, err := s.schema.columnDefaultValue(*column, exp.now)
+		if err != nil {
+			return nil, err
+		}
+		if !hasDefault {
+			value = nil
+		}
+		updated[name] = value
+	}
+	return updated, nil
+}
+
+// cascadeAfterUpdate applies the ON UPDATE actions triggered by changing a
+// parent row whose referenced key moved from oldRow to newRow.
+func (s *Session) cascadeAfterUpdate(table string, oldRow, newRow Row, exp *cascadeExpansion) error {
+	refs, err := s.schema.referencingForeignKeys(table)
+	if err != nil {
+		return err
+	}
+	for _, ref := range refs {
+		action := ref.FK.UpdateAction()
+		if !action.rewritesReferencingRows() {
+			continue
+		}
+		oldKey, ok := foreignKeyValues(oldRow, ref.FK.RefColumns)
+		if !ok {
+			continue
+		}
+		newKey, newOK := foreignKeyActionKey(newRow, ref.FK.RefColumns)
+		if newOK && foreignKeyValuesEqual(oldKey, newKey) {
+			continue
+		}
+		parentValues, ok := foreignKeyRawValues(newRow, ref.FK.RefColumns)
+		if !ok {
+			continue
+		}
+		children, err := s.referencingRows(ref, oldKey)
+		if err != nil {
+			return err
+		}
+		childSchema, err := s.schema.GetSchema(ref.Table)
+		if err != nil {
+			return err
+		}
+		for _, child := range children {
+			childPK, err := childSchema.PrimaryKeyForRow(child)
+			if err != nil {
+				return err
+			}
+			childKey := s.table.dataKey(ref.Table, childPK)
+			if !exp.markUpdate(ref.Table, childKey, ref.FK, oldKey, newKey) {
+				continue
+			}
+			var updated Row
+			switch action {
+			case ReferentialActionCascade:
+				updated = cloneRow(child)
+				for i, column := range ref.FK.Columns {
+					updated[column] = parentValues[i]
+				}
+			case ReferentialActionSetNull, ReferentialActionSetDefault:
+				updated, err = s.applySetAction(childSchema, ref.FK, child, action, exp)
+				if err != nil {
+					return err
+				}
+			}
+			if err := s.stageCascadeUpdate(ref.Table, child, updated, exp); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// stageCascadeUpdate stages one cascaded row rewrite. The row keeps its key
+// unless a cascaded foreign-key column is part of it, in which case the staged
+// image moves and a collision with another row fails like any primary-key move.
+// Required columns and CHECK predicates are validated immediately so a SET NULL
+// on a NOT NULL column fails at the statement with 23502 instead of surfacing at
+// commit; FK and UNIQUE validity are validated against the transaction's final
+// state at commit.
+func (s *Session) stageCascadeUpdate(table string, oldRow, newRow Row, exp *cascadeExpansion) error {
+	schema, err := s.schema.GetSchema(table)
+	if err != nil {
+		return err
+	}
+	if err := s.schema.validateRowConstraints(schema, newRow); err != nil {
+		return err
+	}
+	oldPK, err := schema.PrimaryKeyForRow(oldRow)
+	if err != nil {
+		return err
+	}
+	newPK, err := schema.PrimaryKeyForRow(newRow)
+	if err != nil {
+		return err
+	}
+	oldKey := s.table.dataKey(table, oldPK)
+	newKey := s.table.dataKey(table, newPK)
+	if oldKey != newKey {
+		if _, gerr := s.getByPKLocked(table, newPK); gerr == nil {
+			return &PrimaryKeyViolationError{Table: table, Key: newPK}
+		} else if gerr != ErrKeyNotFound {
+			return gerr
+		}
+		s.stageDelete(table, oldKey, oldRow)
+	}
+	s.stagePut(table, newKey, newRow)
+	return s.cascadeAfterUpdate(table, oldRow, newRow, exp)
+}
+
+// rollbackToLocked discards mutations after a savepoint position. The caller
+// holds the session lock.
+func (s *Session) rollbackToLocked(pos int) {
+	for i := len(s.log) - 1; i >= pos && i >= 0; i-- {
+		m := s.log[i]
+		if m.prev == nil {
+			delete(s.overlay[m.table], m.key)
+			if len(s.overlay[m.table]) == 0 {
+				delete(s.overlay, m.table)
+			}
+		} else {
+			if s.overlay[m.table] == nil {
+				s.overlay[m.table] = make(map[string]*overlayEntry)
+			}
+			s.overlay[m.table][m.key] = m.prev
+		}
+	}
+	if pos < len(s.log) {
+		s.log = s.log[:pos]
+	}
+}

+ 8 - 5
pkg/storage/schema.go

@@ -38,8 +38,10 @@ type Schema struct {
 	// level alike. Inline and table-level declarations are equivalent once
 	// level alike. Inline and table-level declarations are equivalent once
 	// parsed, so both live in this one list.
 	// parsed, so both live in this one list.
 	Checks []CheckConstraint `json:"checks,omitempty"`
 	Checks []CheckConstraint `json:"checks,omitempty"`
-	// ForeignKeys holds the immediate FOREIGN KEY declarations. Each constraint
-	// is enforced on every write and re-validated at transaction commit.
+	// ForeignKeys holds the FOREIGN KEY declarations. Each constraint is
+	// enforced on every write and re-validated at transaction commit; a
+	// cascading action rewrites or removes the referencing rows as part of the
+	// same statement.
 	ForeignKeys []ForeignKeyConstraint `json:"foreign_keys,omitempty"`
 	ForeignKeys []ForeignKeyConstraint `json:"foreign_keys,omitempty"`
 }
 }
 
 
@@ -52,9 +54,10 @@ type ForeignKeyConstraint struct {
 	Columns    []string `json:"columns"`
 	Columns    []string `json:"columns"`
 	RefTable   string   `json:"ref_table"`
 	RefTable   string   `json:"ref_table"`
 	RefColumns []string `json:"ref_columns"`
 	RefColumns []string `json:"ref_columns"`
-	// OnDelete and OnUpdate hold the declared referential action text (only
-	// NO ACTION and RESTRICT are accepted). They are empty when the clause was
-	// omitted and are kept so sqlite_master can reproduce the declaration.
+	// OnDelete and OnUpdate hold the declared referential action text (NO
+	// ACTION, RESTRICT, CASCADE, SET NULL, or SET DEFAULT). They are empty when
+	// the clause was omitted and are kept so sqlite_master can reproduce the
+	// declaration.
 	OnDelete string `json:"on_delete,omitempty"`
 	OnDelete string `json:"on_delete,omitempty"`
 	OnUpdate string `json:"on_update,omitempty"`
 	OnUpdate string `json:"on_update,omitempty"`
 }
 }

+ 102 - 45
pkg/storage/tx.go

@@ -116,23 +116,7 @@ func (s *Session) Snapshot() int {
 func (s *Session) RollbackTo(pos int) {
 func (s *Session) RollbackTo(pos int) {
 	s.mu.Lock()
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	defer s.mu.Unlock()
-	for i := len(s.log) - 1; i >= pos && i >= 0; i-- {
-		m := s.log[i]
-		if m.prev == nil {
-			delete(s.overlay[m.table], m.key)
-			if len(s.overlay[m.table]) == 0 {
-				delete(s.overlay, m.table)
-			}
-		} else {
-			if s.overlay[m.table] == nil {
-				s.overlay[m.table] = make(map[string]*overlayEntry)
-			}
-			s.overlay[m.table][m.key] = m.prev
-		}
-	}
-	if pos < len(s.log) {
-		s.log = s.log[:pos]
-	}
+	s.rollbackToLocked(pos)
 	s.aborted = false
 	s.aborted = false
 }
 }
 
 
@@ -795,20 +779,56 @@ func (s *Session) insertLocked(table string, row Row) (int64, error) {
 	return rowid, nil
 	return rowid, nil
 }
 }
 
 
-// UpdateByPK stages or durably applies a single-row update.
+// UpdateByPK stages or durably applies a single-row update. A table that is the
+// parent of a cascading constraint runs in an implicit transaction so the parent
+// rewrite and every cascaded row publish in one commit.
 func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
 func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
 	s.mu.Lock()
 	s.mu.Lock()
 	if !s.inTx {
 	if !s.inTx {
 		s.mu.Unlock()
 		s.mu.Unlock()
-		return s.table.UpdateByPK(table, pk, updateFn)
+		cascades, err := s.schema.HasCascadingReference(table, false)
+		if err != nil {
+			return nil, false, err
+		}
+		if !cascades {
+			return s.table.UpdateByPK(table, pk, updateFn)
+		}
+		return s.updateByPKImplicit(table, pk, updateFn)
+	}
+	defer s.mu.Unlock()
+	return s.updateByPKLocked(table, pk, updateFn)
+}
+
+// updateByPKImplicit runs a cascading point update inside an implicit
+// transaction, so a failed cascade leaves no durable parent change behind.
+func (s *Session) updateByPKImplicit(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
+	if err := s.Begin(); err != nil {
+		return nil, false, err
 	}
 	}
+	oldRow, updated, err := s.updateByPKStaged(table, pk, updateFn)
+	if err != nil {
+		_ = s.Rollback()
+		return nil, false, err
+	}
+	if err := s.Commit(); err != nil {
+		return nil, false, err
+	}
+	return oldRow, updated, nil
+}
+
+func (s *Session) updateByPKStaged(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
+	s.mu.Lock()
 	defer s.mu.Unlock()
 	defer s.mu.Unlock()
+	return s.updateByPKLocked(table, pk, updateFn)
+}
 
 
+// updateByPKLocked stages a point update. The caller holds the session lock and
+// a transaction.
+func (s *Session) updateByPKLocked(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
 	schema, err := s.schema.GetSchema(table)
 	schema, err := s.schema.GetSchema(table)
 	if err != nil {
 	if err != nil {
 		return nil, false, err
 		return nil, false, err
 	}
 	}
-	key := s.table.dataKey(table, pk)
 
 
 	row, err := s.getByPKLocked(table, pk)
 	row, err := s.getByPKLocked(table, pk)
 	if err == ErrKeyNotFound {
 	if err == ErrKeyNotFound {
@@ -835,7 +855,11 @@ func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error))
 	if err := s.schema.validateRowConstraints(schema, row); err != nil {
 	if err := s.schema.validateRowConstraints(schema, row); err != nil {
 		return nil, false, err
 		return nil, false, err
 	}
 	}
-	s.stagePut(table, key, row)
+	pos := len(s.log)
+	if err := s.stageCascadeUpdate(table, oldRow, row, newCascadeExpansion(s.schema)); err != nil {
+		s.rollbackToLocked(pos)
+		return nil, false, err
+	}
 	return oldRow, true, nil
 	return oldRow, true, nil
 }
 }
 
 
@@ -850,14 +874,50 @@ func (s *Session) runUpdateFn(updateFn func(Row) (Row, error), row Row) (Row, er
 	return updateFn(row)
 	return updateFn(row)
 }
 }
 
 
-// DeleteByPK stages or durably applies a single-row delete.
+// DeleteByPK stages or durably applies a single-row delete. A table that is the
+// parent of a cascading constraint runs in an implicit transaction so the
+// parent delete and every cascaded row publish in one commit.
 func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 	s.mu.Lock()
 	s.mu.Lock()
-	defer s.mu.Unlock()
 	if !s.inTx {
 	if !s.inTx {
-		return s.table.DeleteByPK(table, pk)
+		s.mu.Unlock()
+		cascades, err := s.schema.HasCascadingReference(table, true)
+		if err != nil {
+			return nil, false, err
+		}
+		if !cascades {
+			return s.table.DeleteByPK(table, pk)
+		}
+		return s.deleteByPKImplicit(table, pk)
+	}
+	defer s.mu.Unlock()
+	return s.deleteByPKLocked(table, pk)
+}
+
+func (s *Session) deleteByPKImplicit(table, pk string) (Row, bool, error) {
+	if err := s.Begin(); err != nil {
+		return nil, false, err
+	}
+	row, deleted, err := s.deleteByPKStaged(table, pk)
+	if err != nil {
+		_ = s.Rollback()
+		return nil, false, err
 	}
 	}
+	if err := s.Commit(); err != nil {
+		return nil, false, err
+	}
+	return row, deleted, nil
+}
 
 
+func (s *Session) deleteByPKStaged(table, pk string) (Row, bool, error) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.deleteByPKLocked(table, pk)
+}
+
+// deleteByPKLocked stages a point delete. The caller holds the session lock and
+// a transaction.
+func (s *Session) deleteByPKLocked(table, pk string) (Row, bool, error) {
 	key := s.table.dataKey(table, pk)
 	key := s.table.dataKey(table, pk)
 	row, err := s.getByPKLocked(table, pk)
 	row, err := s.getByPKLocked(table, pk)
 	if err == ErrKeyNotFound {
 	if err == ErrKeyNotFound {
@@ -866,7 +926,11 @@ func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 	if err != nil {
 	if err != nil {
 		return nil, false, err
 		return nil, false, err
 	}
 	}
-	s.stageDelete(table, key, row)
+	pos := len(s.log)
+	if err := s.stageCascadeDelete(table, key, row, newCascadeExpansion(s.schema)); err != nil {
+		s.rollbackToLocked(pos)
+		return nil, false, err
+	}
 	return row, true, nil
 	return row, true, nil
 }
 }
 
 
@@ -910,15 +974,13 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 		return 0, err
 		return 0, err
 	}
 	}
 	count := 0
 	count := 0
+	exp := newCascadeExpansion(s.schema)
+	pos := len(s.log)
 	for _, row := range rows {
 	for _, row := range rows {
 		oldRow := cloneRow(row)
 		oldRow := cloneRow(row)
-		oldPK, err := schema.PrimaryKeyForRow(row)
-		if err != nil {
-			return count, err
-		}
-		oldKey := s.table.dataKey(table, oldPK)
 		updates, err := s.runUpdateFn(updateFn, row)
 		updates, err := s.runUpdateFn(updateFn, row)
 		if err != nil {
 		if err != nil {
+			s.rollbackToLocked(pos)
 			return count, err
 			return count, err
 		}
 		}
 		for name, value := range updates {
 		for name, value := range updates {
@@ -930,24 +992,13 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 			}
 			}
 		}
 		}
 		if err := s.schema.validateRowConstraints(schema, row); err != nil {
 		if err := s.schema.validateRowConstraints(schema, row); err != nil {
+			s.rollbackToLocked(pos)
 			return count, err
 			return count, err
 		}
 		}
-		newPK, err := schema.PrimaryKeyForRow(row)
-		if err != nil {
+		if err := s.stageCascadeUpdate(table, oldRow, row, exp); err != nil {
+			s.rollbackToLocked(pos)
 			return count, err
 			return count, err
 		}
 		}
-		newKey := s.table.dataKey(table, newPK)
-		if newKey != oldKey {
-			// The primary key changed: move the staged row instead of leaving a
-			// stale copy under the old key, and refuse to overwrite another row.
-			if _, gerr := s.getByPKLocked(table, newPK); gerr == nil {
-				return count, &PrimaryKeyViolationError{Table: table, Key: newPK}
-			} else if gerr != ErrKeyNotFound {
-				return count, gerr
-			}
-			s.stageDelete(table, oldKey, oldRow)
-		}
-		s.stagePut(table, newKey, row)
 		count++
 		count++
 	}
 	}
 	return count, nil
 	return count, nil
@@ -971,12 +1022,18 @@ func (s *Session) Delete(table string, filter func(Row) bool) (int, error) {
 		return 0, err
 		return 0, err
 	}
 	}
 	count := 0
 	count := 0
+	exp := newCascadeExpansion(s.schema)
+	pos := len(s.log)
 	for _, row := range rows {
 	for _, row := range rows {
 		pk, err := schema.PrimaryKeyForRow(row)
 		pk, err := schema.PrimaryKeyForRow(row)
 		if err != nil {
 		if err != nil {
+			s.rollbackToLocked(pos)
+			return count, err
+		}
+		if err := s.stageCascadeDelete(table, s.table.dataKey(table, pk), row, exp); err != nil {
+			s.rollbackToLocked(pos)
 			return count, err
 			return count, err
 		}
 		}
-		s.stageDelete(table, s.table.dataKey(table, pk), row)
 		count++
 		count++
 	}
 	}
 	return count, nil
 	return count, nil

Some files were not shown because too many files changed in this diff