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:
@@ -92,6 +92,7 @@ type
|
|||||||
refTable*: string
|
refTable*: string
|
||||||
refColumn*: string
|
refColumn*: string
|
||||||
onDelete*: string # CASCADE, SET NULL, RESTRICT
|
onDelete*: string # CASCADE, SET NULL, RESTRICT
|
||||||
|
onUpdate*: string # CASCADE, SET NULL, RESTRICT
|
||||||
|
|
||||||
CheckDef* = object
|
CheckDef* = object
|
||||||
name*: string
|
name*: string
|
||||||
@@ -121,6 +122,8 @@ type
|
|||||||
defaultVal*: string
|
defaultVal*: string
|
||||||
fkTable*: string
|
fkTable*: string
|
||||||
fkColumn*: string
|
fkColumn*: string
|
||||||
|
fkOnDelete*: string
|
||||||
|
fkOnUpdate*: string
|
||||||
autoIncrement*: bool
|
autoIncrement*: bool
|
||||||
|
|
||||||
Row* = Table[string, string]
|
Row* = Table[string, string]
|
||||||
@@ -314,6 +317,8 @@ proc restoreSchema(ctx: ExecutionContext) =
|
|||||||
of "fkey":
|
of "fkey":
|
||||||
colDef.fkTable = cst.cstRefTable
|
colDef.fkTable = cst.cstRefTable
|
||||||
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
|
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
|
||||||
|
colDef.fkOnDelete = cst.cstOnDelete
|
||||||
|
colDef.fkOnUpdate = cst.cstOnUpdate
|
||||||
else: discard
|
else: discard
|
||||||
tbl.columns.add(colDef)
|
tbl.columns.add(colDef)
|
||||||
ctx.tables[stmt.crtName] = tbl
|
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)
|
vengine.insert(vecIdx, docId, vec, meta)
|
||||||
return 1
|
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
|
# Constraint Validation
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -3656,6 +3751,22 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
updValues.add("\\N")
|
updValues.add("\\N")
|
||||||
let (valid, errMsg) = validateConstraints(ctx, stmt.updTarget, updFields, @[updValues], skipPkCheck = true)
|
let (valid, errMsg) = validateConstraints(ctx, stmt.updTarget, updFields, @[updValues], skipPkCheck = true)
|
||||||
if not valid: return errResult(errMsg)
|
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
|
# Fire BEFORE UPDATE triggers
|
||||||
var oldRow = row
|
var oldRow = row
|
||||||
var newRow = row
|
var newRow = row
|
||||||
@@ -3686,6 +3797,18 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
# Fire BEFORE DELETE triggers
|
# Fire BEFORE DELETE triggers
|
||||||
fireTriggers(ctx, stmt.delTarget, "before", "delete", row)
|
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)
|
count += execDelete(ctx, stmt.delTarget, row["$key"], kvPairs)
|
||||||
|
|
||||||
# Fire AFTER DELETE triggers
|
# Fire AFTER DELETE triggers
|
||||||
@@ -3781,12 +3904,15 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
tbl.foreignKeys.add(ForeignKeyDef(
|
tbl.foreignKeys.add(ForeignKeyDef(
|
||||||
refTable: cstNode.cstRefTable,
|
refTable: cstNode.cstRefTable,
|
||||||
refColumn: if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: "",
|
refColumn: if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: "",
|
||||||
onDelete: cstNode.cstOnDelete))
|
onDelete: cstNode.cstOnDelete,
|
||||||
|
onUpdate: cstNode.cstOnUpdate))
|
||||||
if cstNode.cstColumns.len > 0:
|
if cstNode.cstColumns.len > 0:
|
||||||
for i, c in tbl.columns:
|
for i, c in tbl.columns:
|
||||||
if c.name in cstNode.cstColumns:
|
if c.name in cstNode.cstColumns:
|
||||||
tbl.columns[i].fkTable = cstNode.cstRefTable
|
tbl.columns[i].fkTable = cstNode.cstRefTable
|
||||||
tbl.columns[i].fkColumn = if cstNode.cstRefColumns.len > 0: cstNode.cstRefColumns[0] else: ""
|
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":
|
elif cstNode.cstType == "check":
|
||||||
tbl.checks.add(CheckDef(name: "check_" & $tbl.checks.len, checkNode: cstNode.cstCheck))
|
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":
|
of "fkey":
|
||||||
colDef.fkTable = cst.cstRefTable
|
colDef.fkTable = cst.cstRefTable
|
||||||
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
|
colDef.fkColumn = if cst.cstRefColumns.len > 0: cst.cstRefColumns[0] else: ""
|
||||||
|
colDef.fkOnDelete = cst.cstOnDelete
|
||||||
|
colDef.fkOnUpdate = cst.cstOnUpdate
|
||||||
of "check":
|
of "check":
|
||||||
tbl.checks.add(CheckDef(name: "check_" & col.cdName, checkNode: cst.cstCheck))
|
tbl.checks.add(CheckDef(name: "check_" & col.cdName, checkNode: cst.cstCheck))
|
||||||
else: discard
|
else: discard
|
||||||
tbl.columns.add(colDef)
|
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
|
ctx.tables[stmt.crtName] = tbl
|
||||||
|
|
||||||
# Persist schema
|
# Persist schema
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ type
|
|||||||
tkForeign
|
tkForeign
|
||||||
tkReferences
|
tkReferences
|
||||||
tkCascade
|
tkCascade
|
||||||
|
tkRestrict
|
||||||
tkUnique
|
tkUnique
|
||||||
tkCheck
|
tkCheck
|
||||||
tkDefault
|
tkDefault
|
||||||
@@ -308,6 +309,7 @@ const keywords*: Table[string, TokenKind] = {
|
|||||||
"foreign": tkForeign,
|
"foreign": tkForeign,
|
||||||
"references": tkReferences,
|
"references": tkReferences,
|
||||||
"cascade": tkCascade,
|
"cascade": tkCascade,
|
||||||
|
"restrict": tkRestrict,
|
||||||
"unique": tkUnique,
|
"unique": tkUnique,
|
||||||
"check": tkCheck,
|
"check": tkCheck,
|
||||||
"default": tkDefault,
|
"default": tkDefault,
|
||||||
|
|||||||
@@ -984,6 +984,40 @@ proc parseCreateTable(p: var Parser): Node =
|
|||||||
discard p.advance()
|
discard p.advance()
|
||||||
discard p.match(tkNull)
|
discard p.match(tkNull)
|
||||||
cst.cstOnDelete = "SET NULL"
|
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):
|
elif p.match(tkUnique):
|
||||||
cst.cstType = "unique"
|
cst.cstType = "unique"
|
||||||
if p.peek().kind == tkLParen:
|
if p.peek().kind == tkLParen:
|
||||||
@@ -1031,7 +1065,7 @@ proc parseCreateTable(p: var Parser): Node =
|
|||||||
colDef.cdType = "BIGINT"
|
colDef.cdType = "BIGINT"
|
||||||
|
|
||||||
# Parse column constraints
|
# 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)
|
let cst = Node(kind: nkConstraintDef)
|
||||||
cst.cstColumns = @[colName]; cst.cstRefColumns = @[]
|
cst.cstColumns = @[colName]; cst.cstRefColumns = @[]
|
||||||
if p.match(tkPrimary):
|
if p.match(tkPrimary):
|
||||||
@@ -1071,6 +1105,25 @@ proc parseCreateTable(p: var Parser): Node =
|
|||||||
if p.peek().kind == tkCascade:
|
if p.peek().kind == tkCascade:
|
||||||
discard p.advance()
|
discard p.advance()
|
||||||
cst.cstOnDelete = "CASCADE"
|
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)
|
colDef.cdConstraints.add(cst)
|
||||||
result.crtColumns.add(colDef)
|
result.crtColumns.add(colDef)
|
||||||
|
|
||||||
|
|||||||
@@ -3731,3 +3731,115 @@ suite "Join Performance — Hash Join & Index Nested Loop":
|
|||||||
check r.rows[3]["total"] == "\\N"
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user