diff --git a/src/barabadb/query/ast.nim b/src/barabadb/query/ast.nim index ce1e30f..f331f6a 100644 --- a/src/barabadb/query/ast.nim +++ b/src/barabadb/query/ast.nim @@ -387,6 +387,7 @@ type ciColumns*: seq[string] ciExpr*: Node ciKind*: IndexKind + ciUnique*: bool of nkDropIndex: diName*: string of nkFrom: diff --git a/src/barabadb/query/exec/context.nim b/src/barabadb/query/exec/context.nim index c5d08c3..5340421 100644 --- a/src/barabadb/query/exec/context.nim +++ b/src/barabadb/query/exec/context.nim @@ -4,6 +4,7 @@ ## Also hosts the AST-to-SQL serializer used for VIEW DDL persistence. import std/strutils import std/tables +import std/sets import std/locks import ../ast import ../../storage/lsm @@ -28,6 +29,7 @@ var restoreEnginesHook*: proc(ctx: ExecutionContext) proc newExecutionContext*(db: LSMTree, registry: DatabaseRegistry = nil): ExecutionContext = result = ExecutionContext(db: db, tables: initTable[string, TableDef](), btrees: initTable[string, BTreeIndex[string, IndexEntry]](), + uniqueIndexes: initHashSet[string](), views: initTable[string, Node](), cteTables: initTable[string, seq[Row]](), ftsIndexes: initTable[string, fts.InvertedIndex](), @@ -161,6 +163,7 @@ proc cloneForConnection*(ctx: ExecutionContext): ExecutionContext = svCopy[k] = v result = ExecutionContext(db: ctx.db, tables: ctx.tables, btrees: ctx.btrees, views: ctx.views, + uniqueIndexes: ctx.uniqueIndexes, cteTables: initTable[string, seq[Row]](), ftsIndexes: ctx.ftsIndexes, vectorIndexes: ctx.vectorIndexes, diff --git a/src/barabadb/query/exec/dml.nim b/src/barabadb/query/exec/dml.nim index 34ba37e..9abe254 100644 --- a/src/barabadb/query/exec/dml.nim +++ b/src/barabadb/query/exec/dml.nim @@ -3,6 +3,7 @@ ## Extracted from `executor.nim` (Task 9 of the executor split). import std/strutils import std/tables +import std/sets import std/sequtils import ../../storage/lsm import ../../storage/btree @@ -21,6 +22,28 @@ import rls # Table storage # ---------------------------------------------------------------------- +proc violatesUniqueIndex*(ctx: ExecutionContext, table: string, fields: seq[string], + rowVals: seq[string], excludeLsmKey: string = ""): string = + ## Returns the colKey of the first standalone UNIQUE index this row + ## violates, or "" when the row is clean. idxVal is built with the exact + ## convention of the CREATE INDEX population loop (getValue yields "\\N" + ## for a missing column, values joined with "|"). excludeLsmKey lets UPDATE + ## ignore the row's own existing entry. + if ctx.uniqueIndexes.len == 0: return "" + for colKey in ctx.uniqueIndexes: + if not colKey.startsWith(table & "."): continue + let idxCols = colKey[table.len + 1..^1].split(".") + var colVals: seq[string] = @[] + for c in idxCols: + colVals.add(getValue(rowVals, fields, c)) + let idxVal = colVals.join("|") + if idxVal.len == 0 or isNull(idxVal): continue + if colKey notin ctx.btrees: continue + for entry in ctx.btrees[colKey].get(idxVal): + if entry.lsmKey != excludeLsmKey: + return colKey + return "" + proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], values: seq[seq[string]], kvPairs: var seq[(string, seq[byte])]): int = if not hasPrivilege(ctx, table, "INSERT"): diff --git a/src/barabadb/query/exec/types.nim b/src/barabadb/query/exec/types.nim index 3cd7e98..61de048 100644 --- a/src/barabadb/query/exec/types.nim +++ b/src/barabadb/query/exec/types.nim @@ -1,5 +1,6 @@ ## Executor types — shared by all exec/* modules and executor.nim import std/tables +import std/sets import std/locks import ../ast import ../ir @@ -92,6 +93,7 @@ type db*: LSMTree tables*: Table[string, TableDef] btrees*: Table[string, BTreeIndex[string, IndexEntry]] + uniqueIndexes*: HashSet[string] # colKeys (table.col[.col...]) of UNIQUE standalone B-tree indexes views*: Table[string, Node] # view name -> SELECT AST cteTables*: Table[string, seq[Row]] # CTE name -> rows ftsIndexes*: Table[string, fts.InvertedIndex] # table.col -> FTS index diff --git a/src/barabadb/query/executor.nim b/src/barabadb/query/executor.nim index a8c1f19..087385d 100644 --- a/src/barabadb/query/executor.nim +++ b/src/barabadb/query/executor.nim @@ -5,6 +5,7 @@ import std/os import std/strutils import std/tables +import std/sets import std/hashes import std/sequtils import std/algorithm @@ -543,6 +544,13 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu let (valid, errMsg) = validateConstraints(ctx, stmt.insTarget, mutableFields, mutableValues) if not valid: return errResult(errMsg) + # Standalone UNIQUE index enforcement (same failure channel as + # validateConstraints: errResult before any row is written) + for rowVals in mutableValues: + let uCol = violatesUniqueIndex(ctx, stmt.insTarget, mutableFields, rowVals) + if uCol.len > 0: + return errResult("UNIQUE constraint violated: duplicate value for unique index '" & uCol & "'") + # Fire BEFORE INSERT triggers var row = initTable[string, Value]() if mutableValues.len > 0: @@ -637,6 +645,11 @@ 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) + # Standalone UNIQUE index enforcement — exclude this row's own entry + let uCol = violatesUniqueIndex(ctx, stmt.updTarget, updFields, updValues, + excludeLsmKey = stmt.updTarget & "." & old) + if uCol.len > 0: + return errResult("UNIQUE constraint violated: duplicate value for unique index '" & uCol & "'") # FK ON UPDATE enforcement (parent side) var refCols: seq[string] = @[] for _, childTbl in ctx.tables: @@ -858,7 +871,9 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu var toDelete: seq[string] = @[] for idxName in ctx.btrees.keys.toSeq(): if idxName.startsWith(dropName & "."): toDelete.add(idxName) - for idxName in toDelete: ctx.btrees.del(idxName) + for idxName in toDelete: + ctx.btrees.del(idxName) + ctx.uniqueIndexes.excl(idxName) # Drop FTS/HNSW engine indexes for this table (in-memory entries) var ftsToDelete: seq[string] = @[] for key in ctx.ftsIndexes.keys.toSeq(): @@ -1387,17 +1402,24 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu let idxVal = colVals.join("|") if idxVal.len > 0 and not isNull(idxVal): let lsmKey = if "$key" in row: stmt.ciTarget & "." & valueToString(row["$key"]) else: "" + if stmt.ciUnique and ctx.btrees[colKey].contains(idxVal): + # Duplicate data — abort without registering the index + ctx.btrees.del(colKey) + return errResult("UNIQUE constraint violated: duplicate value '" & idxVal & + "' for unique index '" & colKey & "'") ctx.btrees[colKey].insert(idxVal, IndexEntry(lsmKey: lsmKey, rowValue: "")) + if stmt.ciUnique: + ctx.uniqueIndexes.incl(colKey) # Persist reconstructed DDL so restoreEngines can rebuild the index # from table data after a restart (replay re-writes the same key). # Unnamed indexes: persist the nameless form (see FTS branch above). - # The CREATE INDEX AST does not track UNIQUE, so it is not preserved. + let uniqueKw = if stmt.ciUnique: "UNIQUE " else: "" let btreeDdl = if stmt.ciName.len > 0: - "CREATE INDEX " & idxName & " ON " & stmt.ciTarget & " (" & stmt.ciColumns.join(", ") & ")" + "CREATE " & uniqueKw & "INDEX " & idxName & " ON " & stmt.ciTarget & " (" & stmt.ciColumns.join(", ") & ")" else: - "CREATE INDEX ON " & stmt.ciTarget & " (" & stmt.ciColumns.join(", ") & ")" + "CREATE " & uniqueKw & "INDEX ON " & stmt.ciTarget & " (" & stmt.ciColumns.join(", ") & ")" ctx.db.put(SchemaBtreeIndexPrefix & colKey, cast[seq[byte]](btreeDdl)) - return okResult(msg="CREATE INDEX " & idxName & " on " & stmt.ciTarget) + return okResult(msg="CREATE " & uniqueKw & "INDEX " & idxName & " on " & stmt.ciTarget) of nkDropIndex: # Find and remove index by name from ctx.btrees @@ -1411,14 +1433,16 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu found = true break # A custom index name only appears in the persisted DDL — match it - # against the stored "CREATE INDEX ON" text as well. + # against the stored "CREATE [UNIQUE] INDEX ON" text as well. let (hasDdl, ddl) = ctx.db.get(SchemaBtreeIndexPrefix & key) - if hasDdl and cast[string](ddl).startsWith("CREATE INDEX " & stmt.diName & " ON "): + if hasDdl and (cast[string](ddl).startsWith("CREATE INDEX " & stmt.diName & " ON ") or + cast[string](ddl).startsWith("CREATE UNIQUE INDEX " & stmt.diName & " ON ")): targetKey = key found = true break if found: ctx.btrees.del(targetKey) + ctx.uniqueIndexes.excl(targetKey) ctx.db.delete(SchemaBtreeIndexPrefix & targetKey) return okResult(msg="DROP INDEX " & stmt.diName) # FTS/HNSW engine indexes: in-memory maps are keyed by table.col, and a diff --git a/src/barabadb/query/parser.nim b/src/barabadb/query/parser.nim index 00ddd37..f3bd137 100644 --- a/src/barabadb/query/parser.nim +++ b/src/barabadb/query/parser.nim @@ -1327,7 +1327,8 @@ proc parseCreateIndex(p: var Parser): Node = elif idxMethod == "ivfpq": idxKind = ikIVFPQ result = Node(kind: nkCreateIndex, ciName: idxName, ciTarget: tableName, - ciColumns: colNames, ciKind: idxKind, line: tok.line, col: tok.col) + ciColumns: colNames, ciKind: idxKind, ciUnique: isUnique, + line: tok.line, col: tok.col) proc parseBeginTxn(p: var Parser): Node = let tok = p.expect(tkBegin) diff --git a/tests/bugfix_test.nim b/tests/bugfix_test.nim index 8887474..f84885b 100644 --- a/tests/bugfix_test.nim +++ b/tests/bugfix_test.nim @@ -321,3 +321,39 @@ suite "Bug fixes — clause keywords usable as identifiers": let a4 = parse("EXPORT TO 'o.csv' FROM t FORMAT csv DELIMITER ';' HEADER false") check a4.stmts[0].expFormat == "csv" check a4.stmts[0].expIncludeHeader == false + +suite "Bug fixes — UNIQUE index enforcement": + + test "CREATE UNIQUE INDEX rejects duplicate INSERT": + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("CREATE TABLE accts (id INTEGER PRIMARY KEY, email TEXT)")) + discard executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (1, 'a@b.c')")) + let c = executeQuery(ctx, parse("CREATE UNIQUE INDEX accts_email ON accts (email)")) + check c.success + let dup = executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (2, 'a@b.c')")) + check not dup.success + let ok = executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (2, 'x@y.z')")) + check ok.success + + test "CREATE UNIQUE INDEX rejects duplicate UPDATE": + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("CREATE TABLE accts (id INTEGER PRIMARY KEY, email TEXT)")) + discard executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (1, 'a@b.c')")) + discard executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (2, 'x@y.z')")) + let c = executeQuery(ctx, parse("CREATE UNIQUE INDEX accts_email ON accts (email)")) + check c.success + let dup = executeQuery(ctx, parse("UPDATE accts SET email = 'a@b.c' WHERE id = 2")) + check not dup.success + let same = executeQuery(ctx, parse("UPDATE accts SET email = 'a@b.c' WHERE id = 1")) + check same.success + + test "CREATE UNIQUE INDEX over duplicate data fails": + var ctx = setupCtx() + defer: teardown(ctx) + discard executeQuery(ctx, parse("CREATE TABLE accts (id INTEGER PRIMARY KEY, email TEXT)")) + discard executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (1, 'a@b.c')")) + discard executeQuery(ctx, parse("INSERT INTO accts (id, email) VALUES (2, 'a@b.c')")) + let c = executeQuery(ctx, parse("CREATE UNIQUE INDEX accts_email ON accts (email)")) + check not c.success diff --git a/tests/test_schema_persist.nim b/tests/test_schema_persist.nim index 375900c..cb04b4b 100644 --- a/tests/test_schema_persist.nim +++ b/tests/test_schema_persist.nim @@ -441,6 +441,30 @@ suite "Schema persistence": db2.close() removeDir(dir) + test "UNIQUE B-tree index survives reopen and still enforces": + let dir = "/tmp/baradb_schema_persist_unique" + removeDir(dir) + block: + var db = newLSMTree(dir) + var ctx = newExecutionContext(db) + check execSql(ctx, "CREATE TABLE accts (id INTEGER PRIMARY KEY, email TEXT)").success + check execSql(ctx, "CREATE UNIQUE INDEX accts_email ON accts (email)").success + check execSql(ctx, "INSERT INTO accts (id, email) VALUES (1, 'a@b.c')").success + check "accts.email" in ctx.btrees + db.close() + # Reopen fresh context (simulates process restart) + block: + var db2 = newLSMTree(dir) + var ctx2 = newExecutionContext(db2) + # UNIQUE flag must survive restart via the persisted DDL. + check "accts.email" in ctx2.btrees + let dup = execSql(ctx2, "INSERT INTO accts (id, email) VALUES (2, 'a@b.c')") + check not dup.success + let ok = execSql(ctx2, "INSERT INTO accts (id, email) VALUES (2, 'x@y.z')") + check ok.success + db2.close() + removeDir(dir) + test "Stable schema key format": check tableSchemaKey("users") == "_schema:tables:users" check serializeTableDdl(TableDef(