feat(fk): Foreign Key Enforcement — ON DELETE/UPDATE CASCADE, SET NULL, RESTRICT

- Add tkRestrict token to lexer
- Parse ON DELETE CASCADE/SET NULL/RESTRICT and ON UPDATE CASCADE/SET NULL/RESTRICT
  in both table-level and column-level FK constraints
- Add fkOnDelete/fkOnUpdate to ColumnDef and ForeignKeyDef
- Fix table-level FK constraint application (third pass after columns are created)
- Implement enforceFkOnDelete, enforceFkOnUpdate, enforceFkOnChildUpdate helpers
- Wire FK enforcement into DELETE and UPDATE execution paths
- Add 9 regression tests covering all FK actions
This commit is contained in:
2026-05-17 13:02:39 +03:00
parent dc4ad86ee1
commit 2e0969245c
4 changed files with 312 additions and 2 deletions
+144 -1
View File
@@ -92,6 +92,7 @@ type
refTable*: string
refColumn*: string
onDelete*: string # CASCADE, SET NULL, RESTRICT
onUpdate*: string # CASCADE, SET NULL, RESTRICT
CheckDef* = object
name*: string
@@ -121,6 +122,8 @@ type
defaultVal*: string
fkTable*: string
fkColumn*: string
fkOnDelete*: string
fkOnUpdate*: string
autoIncrement*: bool
Row* = Table[string, string]
@@ -314,6 +317,8 @@ proc restoreSchema(ctx: ExecutionContext) =
of "fkey":
colDef.fkTable = cst.cstRefTable
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
colDef.fkOnDelete = cst.cstOnDelete
colDef.fkOnUpdate = cst.cstOnUpdate
else: discard
tbl.columns.add(colDef)
ctx.tables[stmt.crtName] = tbl
@@ -1436,6 +1441,96 @@ proc execUpdateRow*(ctx: ExecutionContext, table: string, key: string, sets: Tab
vengine.insert(vecIdx, docId, vec, meta)
return 1
# ----------------------------------------------------------------------
# Foreign Key Enforcement
# ----------------------------------------------------------------------
proc findReferencingRows(ctx: ExecutionContext, childTable: string, fkCol: string, fkValue: string): seq[Row] =
result = @[]
for row in execScan(ctx, childTable):
if fkCol in row and row[fkCol] == fkValue:
result.add(row)
proc enforceFkOnDelete(ctx: ExecutionContext, parentTable: string, parentCol: string, parentVal: string): (bool, string) =
for childTblName, childTbl in ctx.tables:
for col in childTbl.columns:
if col.fkTable == parentTable and col.fkColumn == parentCol:
let action = if col.fkOnDelete.len > 0: col.fkOnDelete else: "RESTRICT"
let refs = findReferencingRows(ctx, childTblName, col.name, parentVal)
if refs.len > 0:
case action
of "CASCADE":
for refRow in refs:
if "$key" in refRow:
var dummy: seq[(string, seq[byte])] = @[]
discard execDelete(ctx, childTblName, refRow["$key"], dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[(string, seq[byte])] = @[]
discard execUpdateRow(ctx, childTblName, refRow["$key"], sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
return (true, "")
proc enforceFkOnUpdate(ctx: ExecutionContext, parentTable: string, parentCol: string, oldVal: string, newVal: string): (bool, string) =
for childTblName, childTbl in ctx.tables:
for col in childTbl.columns:
if col.fkTable == parentTable and col.fkColumn == parentCol:
let action = if col.fkOnUpdate.len > 0: col.fkOnUpdate else: "RESTRICT"
let refs = findReferencingRows(ctx, childTblName, col.name, oldVal)
if refs.len > 0:
case action
of "CASCADE":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = newVal
var dummy: seq[(string, seq[byte])] = @[]
discard execUpdateRow(ctx, childTblName, refRow["$key"], sets, dummy)
of "SET NULL":
for refRow in refs:
if "$key" in refRow:
var sets = initTable[string, string]()
sets[col.name] = "\\N"
var dummy: seq[(string, seq[byte])] = @[]
discard execUpdateRow(ctx, childTblName, refRow["$key"], sets, dummy)
of "RESTRICT", "NO ACTION":
return (false, "FOREIGN KEY violation: row is referenced by " & childTblName & "." & col.name)
return (true, "")
proc enforceFkOnChildUpdate(ctx: ExecutionContext, childTable: string, fkCol: string, newVal: string): (bool, string) =
let tbl = ctx.getTableDef(childTable)
var parentTable = ""
var parentCol = ""
for col in tbl.columns:
if col.name == fkCol:
parentTable = col.fkTable
parentCol = col.fkColumn
break
if parentTable.len == 0 or parentCol.len == 0:
return (true, "")
if isNull(newVal):
return (true, "")
let fkKey = parentTable & "." & parentCol & "=" & newVal
let (fkExists, _) = ctx.db.get(fkKey)
if fkExists:
return (true, "")
var found = false
let prefix = parentTable & "."
for entry in ctx.db.scanMemTable():
if entry.deleted: continue
if entry.key.startsWith(prefix):
let rest = entry.key[prefix.len..^1]
if rest.startsWith(parentCol & "=") and rest[parentCol.len+1..^1] == newVal:
found = true
break
if not found:
return (false, "FOREIGN KEY violation: '" & newVal & "' not found in " & parentTable & "." & parentCol)
return (true, "")
# ----------------------------------------------------------------------
# Constraint Validation
# ----------------------------------------------------------------------
@@ -3656,6 +3751,22 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
updValues.add("\\N")
let (valid, errMsg) = validateConstraints(ctx, stmt.updTarget, updFields, @[updValues], skipPkCheck = true)
if not valid: return errResult(errMsg)
# FK ON UPDATE enforcement (parent side)
var refCols: seq[string] = @[]
for _, childTbl in ctx.tables:
for col in childTbl.columns:
if col.fkTable == stmt.updTarget and col.fkColumn notin refCols:
refCols.add(col.fkColumn)
for refCol in refCols:
if refCol in sets and refCol in row:
let (fkOk, fkErr) = enforceFkOnUpdate(ctx, stmt.updTarget, refCol, row[refCol], sets[refCol])
if not fkOk:
return errResult(fkErr)
# FK ON UPDATE enforcement (child side — validate new FK values)
for colName, newVal in sets:
let (fkOk, fkErr) = enforceFkOnChildUpdate(ctx, stmt.updTarget, colName, newVal)
if not fkOk:
return errResult(fkErr)
# Fire BEFORE UPDATE triggers
var oldRow = row
var newRow = row
@@ -3686,6 +3797,18 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
# Fire BEFORE DELETE triggers
fireTriggers(ctx, stmt.delTarget, "before", "delete", row)
# FK ON DELETE enforcement
var refCols: seq[string] = @[]
for _, childTbl in ctx.tables:
for col in childTbl.columns:
if col.fkTable == stmt.delTarget and col.fkColumn notin refCols:
refCols.add(col.fkColumn)
for refCol in refCols:
if refCol in row:
let (fkOk, fkErr) = enforceFkOnDelete(ctx, stmt.delTarget, refCol, row[refCol])
if not fkOk:
return errResult(fkErr)
count += execDelete(ctx, stmt.delTarget, row["$key"], kvPairs)
# Fire AFTER DELETE triggers
@@ -3781,12 +3904,15 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
tbl.foreignKeys.add(ForeignKeyDef(
refTable: cstNode.cstRefTable,
refColumn: if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: "",
onDelete: cstNode.cstOnDelete))
onDelete: cstNode.cstOnDelete,
onUpdate: cstNode.cstOnUpdate))
if cstNode.cstColumns.len > 0:
for i, c in tbl.columns:
if c.name in cstNode.cstColumns:
tbl.columns[i].fkTable = cstNode.cstRefTable
tbl.columns[i].fkColumn = if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: ""
tbl.columns[i].fkOnDelete = cstNode.cstOnDelete
tbl.columns[i].fkOnUpdate = cstNode.cstOnUpdate
elif cstNode.cstType == "check":
tbl.checks.add(CheckDef(name: "check_" & $tbl.checks.len, checkNode: cstNode.cstCheck))
@@ -3815,10 +3941,27 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
of "fkey":
colDef.fkTable = cst.cstRefTable
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
colDef.fkOnDelete = cst.cstOnDelete
colDef.fkOnUpdate = cst.cstOnUpdate
of "check":
tbl.checks.add(CheckDef(name: "check_" & col.cdName, checkNode: cst.cstCheck))
else: discard
tbl.columns.add(colDef)
# Third pass: apply table-level constraints to columns
for cstNode in stmt.crtConstraints:
if cstNode.kind == nkConstraintDef:
if cstNode.cstType == "pkey":
for i, c in tbl.columns:
if c.name in cstNode.cstColumns:
tbl.columns[i].isPk = true
elif cstNode.cstType == "fkey":
if cstNode.cstColumns.len > 0:
for i, c in tbl.columns:
if c.name in cstNode.cstColumns:
tbl.columns[i].fkTable = cstNode.cstRefTable
tbl.columns[i].fkColumn = if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: ""
tbl.columns[i].fkOnDelete = cstNode.cstOnDelete
tbl.columns[i].fkOnUpdate = cstNode.cstOnUpdate
ctx.tables[stmt.crtName] = tbl
# Persist schema
+2
View File
@@ -85,6 +85,7 @@ type
tkForeign
tkReferences
tkCascade
tkRestrict
tkUnique
tkCheck
tkDefault
@@ -308,6 +309,7 @@ const keywords*: Table[string, TokenKind] = {
"foreign": tkForeign,
"references": tkReferences,
"cascade": tkCascade,
"restrict": tkRestrict,
"unique": tkUnique,
"check": tkCheck,
"default": tkDefault,
+54 -1
View File
@@ -984,6 +984,40 @@ proc parseCreateTable(p: var Parser): Node =
discard p.advance()
discard p.match(tkNull)
cst.cstOnDelete = "SET NULL"
elif p.peek().kind == tkRestrict:
discard p.advance()
cst.cstOnDelete = "RESTRICT"
elif p.peek().kind == tkUpdate:
discard p.advance()
if p.peek().kind == tkCascade:
discard p.advance()
cst.cstOnUpdate = "CASCADE"
elif p.peek().kind == tkSet:
discard p.advance()
discard p.match(tkNull)
cst.cstOnUpdate = "SET NULL"
elif p.peek().kind == tkRestrict:
discard p.advance()
cst.cstOnUpdate = "RESTRICT"
elif p.peek().kind == tkSet:
discard p.advance()
discard p.match(tkNull)
cst.cstOnDelete = "SET NULL"
elif p.peek().kind == tkRestrict:
discard p.advance()
cst.cstOnDelete = "RESTRICT"
elif p.peek().kind == tkUpdate:
discard p.advance()
if p.peek().kind == tkCascade:
discard p.advance()
cst.cstOnUpdate = "CASCADE"
elif p.peek().kind == tkSet:
discard p.advance()
discard p.match(tkNull)
cst.cstOnUpdate = "SET NULL"
elif p.peek().kind == tkRestrict:
discard p.advance()
cst.cstOnUpdate = "RESTRICT"
elif p.match(tkUnique):
cst.cstType = "unique"
if p.peek().kind == tkLParen:
@@ -1031,7 +1065,7 @@ proc parseCreateTable(p: var Parser): Node =
colDef.cdType = "BIGINT"
# Parse column constraints
while p.peek().kind in {tkPrimary, tkNot, tkNull, tkUnique, tkCheck, tkDefault, tkReferences, tkAutoIncrement}:
while p.peek().kind in {tkPrimary, tkNot, tkNull, tkUnique, tkCheck, tkDefault, tkReferences, tkAutoIncrement, tkOn}:
let cst = Node(kind: nkConstraintDef)
cst.cstColumns = @[colName]; cst.cstRefColumns = @[]
if p.match(tkPrimary):
@@ -1071,6 +1105,25 @@ proc parseCreateTable(p: var Parser): Node =
if p.peek().kind == tkCascade:
discard p.advance()
cst.cstOnDelete = "CASCADE"
elif p.peek().kind == tkSet:
discard p.advance()
discard p.match(tkNull)
cst.cstOnDelete = "SET NULL"
elif p.peek().kind == tkRestrict:
discard p.advance()
cst.cstOnDelete = "RESTRICT"
elif p.peek().kind == tkUpdate:
discard p.advance()
if p.peek().kind == tkCascade:
discard p.advance()
cst.cstOnUpdate = "CASCADE"
elif p.peek().kind == tkSet:
discard p.advance()
discard p.match(tkNull)
cst.cstOnUpdate = "SET NULL"
elif p.peek().kind == tkRestrict:
discard p.advance()
cst.cstOnUpdate = "RESTRICT"
colDef.cdConstraints.add(cst)
result.crtColumns.add(colDef)
+112
View File
@@ -3731,3 +3731,115 @@ suite "Join Performance — Hash Join & Index Nested Loop":
check r.rows[3]["total"] == "\\N"
suite "Foreign Key Enforcement":
var db: LSMTree
var ctx: qexec.ExecutionContext
var tmpDir: string
setup:
tmpDir = getTempDir() / "baradb_fk_test_" & $getMonoTime().ticks
db = newLSMTree(tmpDir)
ctx = qexec.newExecutionContext(db)
teardown:
removeDir(tmpDir)
test "ON DELETE CASCADE removes child rows":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id) ON DELETE CASCADE)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child (id, parent_id) VALUES (10, 1)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child (id, parent_id) VALUES (20, 1)"))
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent WHERE id = 1"))
check del.success
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child"))
check childSel.success
check childSel.rows.len == 0
test "ON DELETE SET NULL sets FK to NULL":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent2 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child2 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent2(id) ON DELETE SET NULL)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent2 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child2 (id, parent_id) VALUES (10, 1)"))
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent2 WHERE id = 1"))
check del.success
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child2"))
check childSel.success
check childSel.rows.len == 1
check childSel.rows[0]["parent_id"] == "\\N"
test "ON DELETE RESTRICT blocks delete when referenced":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent3 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child3 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent3(id) ON DELETE RESTRICT)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent3 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child3 (id, parent_id) VALUES (10, 1)"))
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent3 WHERE id = 1"))
check not del.success
check del.message.contains("FOREIGN KEY violation")
let parentSel = qexec.executeQuery(ctx, parse("SELECT * FROM parent3"))
check parentSel.rows.len == 1
test "ON UPDATE CASCADE updates child rows":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent4 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child4 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent4(id) ON UPDATE CASCADE)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent4 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child4 (id, parent_id) VALUES (10, 1)"))
let upd = qexec.executeQuery(ctx, parse("UPDATE parent4 SET id = 99 WHERE id = 1"))
check upd.success
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child4"))
check childSel.success
check childSel.rows.len == 1
check childSel.rows[0]["parent_id"] == "99"
test "ON UPDATE SET NULL sets FK to NULL":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent5 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child5 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent5(id) ON UPDATE SET NULL)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent5 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child5 (id, parent_id) VALUES (10, 1)"))
let upd = qexec.executeQuery(ctx, parse("UPDATE parent5 SET id = 99 WHERE id = 1"))
check upd.success
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child5"))
check childSel.success
check childSel.rows.len == 1
check childSel.rows[0]["parent_id"] == "\\N"
test "ON UPDATE RESTRICT blocks update when referenced":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent6 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child6 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent6(id) ON UPDATE RESTRICT)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent6 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child6 (id, parent_id) VALUES (10, 1)"))
let upd = qexec.executeQuery(ctx, parse("UPDATE parent6 SET id = 99 WHERE id = 1"))
check not upd.success
check upd.message.contains("FOREIGN KEY violation")
test "UPDATE child with valid FK value succeeds":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent7 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child7 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent7(id))"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent7 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent7 (id, name) VALUES (2, 'Bob')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child7 (id, parent_id) VALUES (10, 1)"))
let upd = qexec.executeQuery(ctx, parse("UPDATE child7 SET parent_id = 2 WHERE id = 10"))
check upd.success
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child7"))
check childSel.rows[0]["parent_id"] == "2"
test "UPDATE child with invalid FK value fails":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent8 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child8 (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent8(id))"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent8 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child8 (id, parent_id) VALUES (10, 1)"))
let upd = qexec.executeQuery(ctx, parse("UPDATE child8 SET parent_id = 999 WHERE id = 10"))
check not upd.success
check upd.message.contains("FOREIGN KEY violation")
test "Table-level FK with ON DELETE CASCADE":
discard qexec.executeQuery(ctx, parse("CREATE TABLE parent9 (id INTEGER PRIMARY KEY, name TEXT)"))
discard qexec.executeQuery(ctx, parse("CREATE TABLE child9 (id INTEGER PRIMARY KEY, parent_id INTEGER, FOREIGN KEY (parent_id) REFERENCES parent9(id) ON DELETE CASCADE)"))
discard qexec.executeQuery(ctx, parse("INSERT INTO parent9 (id, name) VALUES (1, 'Alice')"))
discard qexec.executeQuery(ctx, parse("INSERT INTO child9 (id, parent_id) VALUES (10, 1)"))
let del = qexec.executeQuery(ctx, parse("DELETE FROM parent9 WHERE id = 1"))
check del.success
let childSel = qexec.executeQuery(ctx, parse("SELECT * FROM child9"))
check childSel.rows.len == 0