Răsfoiți Sursa

Merge pull request #36 from database-pizza/engine/b-fk-cascades

execute FOREIGN KEY cascades (CASCADE / SET NULL / SET DEFAULT)
Danilo Fragoso 1 zi în urmă
părinte
comite
698a4e8532

BIN
bin/pizzasql


Fișier diff suprimat deoarece este prea mare
+ 0 - 1
pkg/capabilities/capabilities.go


+ 6 - 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
-// 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) {
 	doc := Current()
 	fk := doc.Constraints.ForeignKey
@@ -164,7 +164,10 @@ func TestForeignKeyIsAdvertised(t *testing.T) {
 		t.Fatalf("foreign_key SQLSTATE = %q, want %q", fk.SQLState, pgerror.CodeForeignKeyViolation)
 	}
 	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", "transitively",
+		"autocommit", "no later than COMMIT", "statement savepoint", "hand-authored catalog",
+		"O(FK component)", "DEFERRABLE", "0A000",
 		"PRAGMA foreign_keys", "rejects OFF", "foreign_key_list", "database-wide foreign-key write gate",
 		"DROP/RENAME COLUMN", "DROP TABLE", "pg_constraint",
 	} {

+ 17 - 29
pkg/executor/executor.go

@@ -3494,37 +3494,25 @@ func (e *Executor) executeUpdateFrom(stmt *parser.UpdateStmt, schema *storage.Sc
 			if newPKErr != nil {
 				return newPKErr
 			}
-			if newPK != plan.oldPK {
-				if _, gerr := e.session.GetByPK(tableName, newPK); gerr == nil {
-					return &storage.PrimaryKeyViolationError{Table: tableName, Key: newPK}
-				} else if gerr != storage.ErrKeyNotFound {
-					return gerr
-				}
-				if _, deleted, derr := e.session.DeleteByPK(tableName, plan.oldPK); derr != nil {
-					return derr
-				} else if !deleted {
-					continue
-				}
-				if _, ierr := e.session.InsertWithRowID(tableName, plan.row); ierr != nil {
-					return ierr
-				}
-			} else {
-				updates := make(storage.Row, len(schema.Columns))
-				for _, col := range schema.Columns {
-					if v, ok := lookupRowValue(plan.row, col.Name); ok {
-						updates[col.Name] = v
-					}
-				}
-				_, updated, uerr := e.session.UpdateByPK(tableName, newPK, func(storage.Row) (storage.Row, error) {
-					return updates, nil
-				})
-				if uerr != nil {
-					return uerr
-				}
-				if !updated {
-					continue
+			// Apply the whole post-update image through one update so a primary-key
+			// move is a real UPDATE: the session stages the key move (with the
+			// duplicate check) and fires ON UPDATE actions. Implementing the move as
+			// DELETE + INSERT would fire ON DELETE actions instead and drop children.
+			updates := make(storage.Row, len(schema.Columns))
+			for _, col := range schema.Columns {
+				if v, ok := lookupRowValue(plan.row, col.Name); ok {
+					updates[col.Name] = v
 				}
 			}
+			_, updated, uerr := e.session.UpdateByPK(tableName, plan.oldPK, func(storage.Row) (storage.Row, error) {
+				return updates, nil
+			})
+			if uerr != nil {
+				return uerr
+			}
+			if !updated {
+				continue
+			}
 			count++
 			if len(stmt.Returning) > 0 {
 				if stored := e.fetchByPK(tableName, newPK); stored != nil {

+ 697 - 0
pkg/executor/foreign_key_cascade_test.go

@@ -0,0 +1,697 @@
+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", "autocommit", "no later than COMMIT", "O(FK component)"} {
+		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])
+	}
+}
+
+// UPDATE ... FROM that moves a parent key must apply ON UPDATE actions like a
+// plain UPDATE. The executor used to implement the move as DELETE + INSERT,
+// which fired ON DELETE CASCADE and silently dropped the children.
+func TestForeignKeyUpdateFromPrimaryKeyMoveCascades(t *testing.T) {
+	for _, tc := range []struct {
+		name       string
+		constraint string
+	}{
+		{name: "on delete and update cascade", constraint: "ON DELETE CASCADE ON UPDATE CASCADE"},
+		{name: "on update cascade only", constraint: "ON UPDATE CASCADE"},
+	} {
+		t.Run(tc.name, func(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) "+tc.constraint+")")
+			execMust(t, e, "CREATE TABLE src (id INTEGER PRIMARY KEY, new_id INTEGER)")
+			execMust(t, e, "INSERT INTO parent (id) VALUES (1)")
+			execMust(t, e, "INSERT INTO child (id, parent_id) VALUES (10, 1)")
+			execMust(t, e, "INSERT INTO src (id, new_id) VALUES (1, 2)")
+
+			res := execMust(t, e, "UPDATE parent SET id = src.new_id FROM src WHERE src.id = parent.id")
+			if res.RowsAffected != 1 {
+				t.Fatalf("rows affected = %d, want 1", res.RowsAffected)
+			}
+			if got := countRows(t, e, "child"); got != 1 {
+				t.Fatalf("child rows after UPDATE ... FROM key move = %d, want 1 (children were deleted)", got)
+			}
+			res = execMust(t, e, "SELECT id, parent_id FROM child")
+			if res.Rows[0][0] != int64(10) || res.Rows[0][1] != int64(2) {
+				t.Fatalf("child after UPDATE ... FROM key move = %v, want (10, 2)", res.Rows)
+			}
+			if res := execMust(t, e, "SELECT id FROM parent"); res.RowCount != 1 || res.Rows[0][0] != int64(2) {
+				t.Fatalf("parent after UPDATE ... FROM key move = %v, want id 2", res.Rows)
+			}
+		})
+	}
+}
+
+// A cascade failure inside an explicit transaction is detected at the statement
+// and rolls back to the statement savepoint, so the transaction can continue.
+// A plain NO ACTION/RESTRICT violation is instead deferred to COMMIT, and the
+// statement itself succeeds.
+func TestForeignKeyCascadeTransactionErrorTiming(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 required (id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parent(id) ON DELETE SET NULL)")
+	execMust(t, e, "CREATE TABLE restricted (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
+	execMust(t, e, "INSERT INTO parent (id) VALUES (1), (2)")
+	execMust(t, e, "INSERT INTO required (id, parent_id) VALUES (10, 1)")
+
+	// SET NULL on a NOT NULL child fails at the statement with 23502.
+	execMust(t, e, "BEGIN")
+	_, err := execSQL(e, "DELETE FROM parent WHERE id = 1")
+	requireNotNullViolation(t, err)
+	// The failed cascade rolled back to the statement savepoint: the parent and
+	// child are still visible to the transaction and the transaction is usable.
+	if res := execMust(t, e, "SELECT count(*) FROM parent"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("parent rows after statement rollback = %v, want 2", res.Rows)
+	}
+	if res := execMust(t, e, "SELECT parent_id FROM required WHERE id = 10"); res.Rows[0][0] != int64(1) {
+		t.Fatalf("required.parent_id after statement rollback = %v, want 1", res.Rows[0][0])
+	}
+	execMust(t, e, "ROLLBACK")
+
+	// A NO ACTION/RESTRICT violation is deferred to COMMIT: the DELETE succeeds
+	// as a statement and the transaction fails on commit.
+	execMust(t, e, "INSERT INTO restricted (id, parent_id) VALUES (20, 2)")
+	execMust(t, e, "BEGIN")
+	execMust(t, e, "DELETE FROM parent WHERE id = 2")
+	_, err = execSQL(e, "COMMIT")
+	requireForeignKeyViolation(t, err)
+	if res := execMust(t, e, "SELECT count(*) FROM parent"); res.Rows[0][0] != int64(2) {
+		t.Fatalf("parent rows after rejected commit = %v, want 2", res.Rows)
+	}
+}

+ 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))",
 			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",
 			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,
 		)
 	}
-	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))
 	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
 // 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 {
 	for p.curTokenIs(lexer.TokenON) {
 		p.nextToken()

+ 424 - 0
pkg/storage/foreign_key_cascade.go

@@ -0,0 +1,424 @@
+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
+}
+
+// beginCascadingPointWrite decides whether an autocommit point write must be
+// staged so its cascades publish with the parent change, and starts that
+// implicit transaction when it is needed.
+//
+// A table outside the foreign-key graph keeps the point-write fast path and
+// never touches the gate. For a participating table the database-wide
+// foreign-key write gate is taken before the cascade classification and held
+// across Begin and the caller's staging. CREATE TABLE ... REFERENCES takes the
+// same unconditional gate, so a constraint cannot be published between deciding
+// that the write is not cascading and opening the transaction that would have
+// applied its actions. The returned release function must be called before
+// Commit, which acquires the same non-reentrant gate.
+//
+// A table gains its first constraint only together with a new child table,
+// which is created empty under the gate and whose rows take the gate on insert.
+// If the participant probe above goes stale, the non-cascading point path
+// re-reads the catalog under the gate and rejects the write with 23503 instead
+// of ignoring the new constraint.
+//
+// The gate is held while Begin and the caller's staging take the session lock.
+// That order cannot invert with Commit: a Session serves one connection and one
+// statement at a time, so Commit on this Session runs after the staging path
+// releases the gate, while other sessions hold their own lock and contend only
+// on the shared gate.
+func (s *Session) beginCascadingPointWrite(table string, forDelete bool) (bool, func(), error) {
+	participates, err := s.schema.ForeignKeyParticipants(table)
+	if err != nil {
+		return false, nil, err
+	}
+	if !participates {
+		return false, func() {}, nil
+	}
+	unlockFK := s.table.LockForeignKeyWriteGate()
+	cascades, err := s.schema.HasCascadingReference(table, forDelete)
+	if err != nil {
+		unlockFK()
+		return false, nil, err
+	}
+	if !cascades {
+		unlockFK()
+		return false, func() {}, nil
+	}
+	if err := s.Begin(); err != nil {
+		unlockFK()
+		return false, nil, err
+	}
+	return true, unlockFK, 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
 	// parsed, so both live in this one list.
 	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"`
 }
 
@@ -52,9 +54,10 @@ type ForeignKeyConstraint struct {
 	Columns    []string `json:"columns"`
 	RefTable   string   `json:"ref_table"`
 	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"`
 	OnUpdate string `json:"on_update,omitempty"`
 }

+ 88 - 45
pkg/storage/tx.go

@@ -116,23 +116,7 @@ func (s *Session) Snapshot() int {
 func (s *Session) RollbackTo(pos int) {
 	s.mu.Lock()
 	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
 }
 
@@ -795,20 +779,48 @@ func (s *Session) insertLocked(table string, row Row) (int64, error) {
 	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) {
 	s.mu.Lock()
 	if !s.inTx {
 		s.mu.Unlock()
-		return s.table.UpdateByPK(table, pk, updateFn)
+		cascading, unlockFK, err := s.beginCascadingPointWrite(table, false)
+		if err != nil {
+			return nil, false, err
+		}
+		if !cascading {
+			return s.table.UpdateByPK(table, pk, updateFn)
+		}
+		oldRow, updated, err := s.updateByPKStaged(table, pk, updateFn)
+		unlockFK()
+		if err != nil {
+			_ = s.Rollback()
+			return nil, false, err
+		}
+		if err := s.Commit(); err != nil {
+			return nil, false, err
+		}
+		return oldRow, updated, nil
 	}
 	defer s.mu.Unlock()
+	return s.updateByPKLocked(table, pk, updateFn)
+}
+
+func (s *Session) updateByPKStaged(table, pk string, updateFn func(Row) (Row, error)) (Row, bool, error) {
+	s.mu.Lock()
+	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)
 	if err != nil {
 		return nil, false, err
 	}
-	key := s.table.dataKey(table, pk)
 
 	row, err := s.getByPKLocked(table, pk)
 	if err == ErrKeyNotFound {
@@ -835,7 +847,11 @@ func (s *Session) UpdateByPK(table, pk string, updateFn func(Row) (Row, error))
 	if err := s.schema.validateRowConstraints(schema, row); err != nil {
 		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
 }
 
@@ -850,14 +866,44 @@ func (s *Session) runUpdateFn(updateFn func(Row) (Row, error), row Row) (Row, er
 	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) {
 	s.mu.Lock()
-	defer s.mu.Unlock()
 	if !s.inTx {
-		return s.table.DeleteByPK(table, pk)
+		s.mu.Unlock()
+		cascading, unlockFK, err := s.beginCascadingPointWrite(table, true)
+		if err != nil {
+			return nil, false, err
+		}
+		if !cascading {
+			return s.table.DeleteByPK(table, pk)
+		}
+		row, deleted, err := s.deleteByPKStaged(table, pk)
+		unlockFK()
+		if err != nil {
+			_ = s.Rollback()
+			return nil, false, err
+		}
+		if err := s.Commit(); err != nil {
+			return nil, false, err
+		}
+		return row, deleted, nil
 	}
+	defer s.mu.Unlock()
+	return s.deleteByPKLocked(table, pk)
+}
+
+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)
 	row, err := s.getByPKLocked(table, pk)
 	if err == ErrKeyNotFound {
@@ -866,7 +912,11 @@ func (s *Session) DeleteByPK(table, pk string) (Row, bool, error) {
 	if err != nil {
 		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
 }
 
@@ -910,15 +960,13 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 		return 0, err
 	}
 	count := 0
+	exp := newCascadeExpansion(s.schema)
+	pos := len(s.log)
 	for _, row := range rows {
 		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)
 		if err != nil {
+			s.rollbackToLocked(pos)
 			return count, err
 		}
 		for name, value := range updates {
@@ -930,24 +978,13 @@ func (s *Session) UpdateFunc(table string, updateFn func(Row) (Row, error), filt
 			}
 		}
 		if err := s.schema.validateRowConstraints(schema, row); err != nil {
+			s.rollbackToLocked(pos)
 			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
 		}
-		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++
 	}
 	return count, nil
@@ -971,12 +1008,18 @@ func (s *Session) Delete(table string, filter func(Row) bool) (int, error) {
 		return 0, err
 	}
 	count := 0
+	exp := newCascadeExpansion(s.schema)
+	pos := len(s.log)
 	for _, row := range rows {
 		pk, err := schema.PrimaryKeyForRow(row)
 		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
 		}
-		s.stageDelete(table, s.table.dataKey(table, pk), row)
 		count++
 	}
 	return count, nil

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff