diff --git a/src/barabadb/query/executor.nim b/src/barabadb/query/executor.nim index af8ba83..8da52ba 100644 --- a/src/barabadb/query/executor.nim +++ b/src/barabadb/query/executor.nim @@ -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 diff --git a/src/barabadb/query/lexer.nim b/src/barabadb/query/lexer.nim index 5dcb333..8577c4a 100644 --- a/src/barabadb/query/lexer.nim +++ b/src/barabadb/query/lexer.nim @@ -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, diff --git a/src/barabadb/query/parser.nim b/src/barabadb/query/parser.nim index 09a9d82..c6df925 100644 --- a/src/barabadb/query/parser.nim +++ b/src/barabadb/query/parser.nim @@ -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) diff --git a/tests/test_all.nim b/tests/test_all.nim index 1449bf8..98afc53 100644 --- a/tests/test_all.nim +++ b/tests/test_all.nim @@ -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 +