fix: CREATE UNIQUE INDEX actually enforces uniqueness (and persists)
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
CI / test (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled
This commit is contained in:
@@ -387,6 +387,7 @@ type
|
|||||||
ciColumns*: seq[string]
|
ciColumns*: seq[string]
|
||||||
ciExpr*: Node
|
ciExpr*: Node
|
||||||
ciKind*: IndexKind
|
ciKind*: IndexKind
|
||||||
|
ciUnique*: bool
|
||||||
of nkDropIndex:
|
of nkDropIndex:
|
||||||
diName*: string
|
diName*: string
|
||||||
of nkFrom:
|
of nkFrom:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
## Also hosts the AST-to-SQL serializer used for VIEW DDL persistence.
|
## Also hosts the AST-to-SQL serializer used for VIEW DDL persistence.
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/tables
|
import std/tables
|
||||||
|
import std/sets
|
||||||
import std/locks
|
import std/locks
|
||||||
import ../ast
|
import ../ast
|
||||||
import ../../storage/lsm
|
import ../../storage/lsm
|
||||||
@@ -28,6 +29,7 @@ var restoreEnginesHook*: proc(ctx: ExecutionContext)
|
|||||||
proc newExecutionContext*(db: LSMTree, registry: DatabaseRegistry = nil): ExecutionContext =
|
proc newExecutionContext*(db: LSMTree, registry: DatabaseRegistry = nil): ExecutionContext =
|
||||||
result = ExecutionContext(db: db, tables: initTable[string, TableDef](),
|
result = ExecutionContext(db: db, tables: initTable[string, TableDef](),
|
||||||
btrees: initTable[string, BTreeIndex[string, IndexEntry]](),
|
btrees: initTable[string, BTreeIndex[string, IndexEntry]](),
|
||||||
|
uniqueIndexes: initHashSet[string](),
|
||||||
views: initTable[string, Node](),
|
views: initTable[string, Node](),
|
||||||
cteTables: initTable[string, seq[Row]](),
|
cteTables: initTable[string, seq[Row]](),
|
||||||
ftsIndexes: initTable[string, fts.InvertedIndex](),
|
ftsIndexes: initTable[string, fts.InvertedIndex](),
|
||||||
@@ -161,6 +163,7 @@ proc cloneForConnection*(ctx: ExecutionContext): ExecutionContext =
|
|||||||
svCopy[k] = v
|
svCopy[k] = v
|
||||||
result = ExecutionContext(db: ctx.db, tables: ctx.tables,
|
result = ExecutionContext(db: ctx.db, tables: ctx.tables,
|
||||||
btrees: ctx.btrees, views: ctx.views,
|
btrees: ctx.btrees, views: ctx.views,
|
||||||
|
uniqueIndexes: ctx.uniqueIndexes,
|
||||||
cteTables: initTable[string, seq[Row]](),
|
cteTables: initTable[string, seq[Row]](),
|
||||||
ftsIndexes: ctx.ftsIndexes,
|
ftsIndexes: ctx.ftsIndexes,
|
||||||
vectorIndexes: ctx.vectorIndexes,
|
vectorIndexes: ctx.vectorIndexes,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## Extracted from `executor.nim` (Task 9 of the executor split).
|
## Extracted from `executor.nim` (Task 9 of the executor split).
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/tables
|
import std/tables
|
||||||
|
import std/sets
|
||||||
import std/sequtils
|
import std/sequtils
|
||||||
import ../../storage/lsm
|
import ../../storage/lsm
|
||||||
import ../../storage/btree
|
import ../../storage/btree
|
||||||
@@ -21,6 +22,28 @@ import rls
|
|||||||
# Table storage
|
# 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]],
|
proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], values: seq[seq[string]],
|
||||||
kvPairs: var seq[(string, seq[byte])]): int =
|
kvPairs: var seq[(string, seq[byte])]): int =
|
||||||
if not hasPrivilege(ctx, table, "INSERT"):
|
if not hasPrivilege(ctx, table, "INSERT"):
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
## Executor types — shared by all exec/* modules and executor.nim
|
## Executor types — shared by all exec/* modules and executor.nim
|
||||||
import std/tables
|
import std/tables
|
||||||
|
import std/sets
|
||||||
import std/locks
|
import std/locks
|
||||||
import ../ast
|
import ../ast
|
||||||
import ../ir
|
import ../ir
|
||||||
@@ -92,6 +93,7 @@ type
|
|||||||
db*: LSMTree
|
db*: LSMTree
|
||||||
tables*: Table[string, TableDef]
|
tables*: Table[string, TableDef]
|
||||||
btrees*: Table[string, BTreeIndex[string, IndexEntry]]
|
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
|
views*: Table[string, Node] # view name -> SELECT AST
|
||||||
cteTables*: Table[string, seq[Row]] # CTE name -> rows
|
cteTables*: Table[string, seq[Row]] # CTE name -> rows
|
||||||
ftsIndexes*: Table[string, fts.InvertedIndex] # table.col -> FTS index
|
ftsIndexes*: Table[string, fts.InvertedIndex] # table.col -> FTS index
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import std/os
|
import std/os
|
||||||
import std/strutils
|
import std/strutils
|
||||||
import std/tables
|
import std/tables
|
||||||
|
import std/sets
|
||||||
import std/hashes
|
import std/hashes
|
||||||
import std/sequtils
|
import std/sequtils
|
||||||
import std/algorithm
|
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)
|
let (valid, errMsg) = validateConstraints(ctx, stmt.insTarget, mutableFields, mutableValues)
|
||||||
if not valid: return errResult(errMsg)
|
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
|
# Fire BEFORE INSERT triggers
|
||||||
var row = initTable[string, Value]()
|
var row = initTable[string, Value]()
|
||||||
if mutableValues.len > 0:
|
if mutableValues.len > 0:
|
||||||
@@ -637,6 +645,11 @@ 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)
|
||||||
|
# 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)
|
# FK ON UPDATE enforcement (parent side)
|
||||||
var refCols: seq[string] = @[]
|
var refCols: seq[string] = @[]
|
||||||
for _, childTbl in ctx.tables:
|
for _, childTbl in ctx.tables:
|
||||||
@@ -858,7 +871,9 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
var toDelete: seq[string] = @[]
|
var toDelete: seq[string] = @[]
|
||||||
for idxName in ctx.btrees.keys.toSeq():
|
for idxName in ctx.btrees.keys.toSeq():
|
||||||
if idxName.startsWith(dropName & "."): toDelete.add(idxName)
|
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)
|
# Drop FTS/HNSW engine indexes for this table (in-memory entries)
|
||||||
var ftsToDelete: seq[string] = @[]
|
var ftsToDelete: seq[string] = @[]
|
||||||
for key in ctx.ftsIndexes.keys.toSeq():
|
for key in ctx.ftsIndexes.keys.toSeq():
|
||||||
@@ -1387,17 +1402,24 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
let idxVal = colVals.join("|")
|
let idxVal = colVals.join("|")
|
||||||
if idxVal.len > 0 and not isNull(idxVal):
|
if idxVal.len > 0 and not isNull(idxVal):
|
||||||
let lsmKey = if "$key" in row: stmt.ciTarget & "." & valueToString(row["$key"]) else: ""
|
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: ""))
|
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
|
# Persist reconstructed DDL so restoreEngines can rebuild the index
|
||||||
# from table data after a restart (replay re-writes the same key).
|
# from table data after a restart (replay re-writes the same key).
|
||||||
# Unnamed indexes: persist the nameless form (see FTS branch above).
|
# 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:
|
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:
|
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))
|
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:
|
of nkDropIndex:
|
||||||
# Find and remove index by name from ctx.btrees
|
# Find and remove index by name from ctx.btrees
|
||||||
@@ -1411,14 +1433,16 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
|
|||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
# A custom index name only appears in the persisted DDL — match it
|
# A custom index name only appears in the persisted DDL — match it
|
||||||
# against the stored "CREATE INDEX <name> ON" text as well.
|
# against the stored "CREATE [UNIQUE] INDEX <name> ON" text as well.
|
||||||
let (hasDdl, ddl) = ctx.db.get(SchemaBtreeIndexPrefix & key)
|
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
|
targetKey = key
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
if found:
|
if found:
|
||||||
ctx.btrees.del(targetKey)
|
ctx.btrees.del(targetKey)
|
||||||
|
ctx.uniqueIndexes.excl(targetKey)
|
||||||
ctx.db.delete(SchemaBtreeIndexPrefix & targetKey)
|
ctx.db.delete(SchemaBtreeIndexPrefix & targetKey)
|
||||||
return okResult(msg="DROP INDEX " & stmt.diName)
|
return okResult(msg="DROP INDEX " & stmt.diName)
|
||||||
# FTS/HNSW engine indexes: in-memory maps are keyed by table.col, and a
|
# FTS/HNSW engine indexes: in-memory maps are keyed by table.col, and a
|
||||||
|
|||||||
@@ -1327,7 +1327,8 @@ proc parseCreateIndex(p: var Parser): Node =
|
|||||||
elif idxMethod == "ivfpq":
|
elif idxMethod == "ivfpq":
|
||||||
idxKind = ikIVFPQ
|
idxKind = ikIVFPQ
|
||||||
result = Node(kind: nkCreateIndex, ciName: idxName, ciTarget: tableName,
|
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 =
|
proc parseBeginTxn(p: var Parser): Node =
|
||||||
let tok = p.expect(tkBegin)
|
let tok = p.expect(tkBegin)
|
||||||
|
|||||||
@@ -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")
|
let a4 = parse("EXPORT TO 'o.csv' FROM t FORMAT csv DELIMITER ';' HEADER false")
|
||||||
check a4.stmts[0].expFormat == "csv"
|
check a4.stmts[0].expFormat == "csv"
|
||||||
check a4.stmts[0].expIncludeHeader == false
|
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
|
||||||
|
|||||||
@@ -441,6 +441,30 @@ suite "Schema persistence":
|
|||||||
db2.close()
|
db2.close()
|
||||||
removeDir(dir)
|
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":
|
test "Stable schema key format":
|
||||||
check tableSchemaKey("users") == "_schema:tables:users"
|
check tableSchemaKey("users") == "_schema:tables:users"
|
||||||
check serializeTableDdl(TableDef(
|
check serializeTableDdl(TableDef(
|
||||||
|
|||||||
Reference in New Issue
Block a user