mirror of
https://github.com/logos-storage/nim-datastore.git
synced 2026-01-02 21:53:05 +00:00
414 lines
11 KiB
Nim
414 lines
11 KiB
Nim
import std/os
|
|
import std/strformat
|
|
|
|
import pkg/questionable
|
|
import pkg/questionable/results
|
|
import pkg/upraises
|
|
|
|
import ./sqliteutils
|
|
|
|
export sqliteutils
|
|
|
|
type
|
|
BoundIdCol* = proc (): string {.closure, gcsafe, upraises: [].}
|
|
BoundVersionCol* = proc (): int64 {.closure, gcsafe, upraises: [].}
|
|
BoundDataCol* = proc (): seq[byte] {.closure, gcsafe, upraises: [].}
|
|
BoundTimestampCol* = proc (): int64 {.closure, gcsafe, upraises: [].}
|
|
|
|
# feels odd to use `void` for prepared statements corresponding to SELECT
|
|
# queries but it fits with the rest of the SQLite wrapper adapted from
|
|
# status-im/nwaku, at least in its current form in ./sqlite
|
|
ContainsStmt* = SQLiteStmt[(string), void]
|
|
DeleteStmt* = SQLiteStmt[(string), void]
|
|
GetStmt* = SQLiteStmt[(string), void]
|
|
PutStmt* = SQLiteStmt[(string, seq[byte], int64, int64), void]
|
|
QueryStmt* = SQLiteStmt[(string), void]
|
|
GetVersionedStmt* = SQLiteStmt[(string), void]
|
|
InsertVersionedStmt* = SQLiteStmt[(string, seq[byte], int64, int64), void]
|
|
UpdateVersionedStmt* = SQLiteStmt[(seq[byte], int64, int64, string, int64), void]
|
|
DeleteVersionedStmt* = SQLiteStmt[(string, int64), void]
|
|
GetChangesStmt* = NoParamsStmt
|
|
BeginStmt* = NoParamsStmt
|
|
EndStmt* = NoParamsStmt
|
|
RollbackStmt* = NoParamsStmt
|
|
|
|
SQLiteDsDb* = object
|
|
readOnly*: bool
|
|
dbPath*: string
|
|
containsStmt*: ContainsStmt
|
|
deleteStmt*: DeleteStmt
|
|
env*: SQLite
|
|
getDataCol*: BoundDataCol
|
|
getStmt*: GetStmt
|
|
putStmt*: PutStmt
|
|
getVersionedStmt*: GetVersionedStmt
|
|
updateVersionedStmt*: UpdateVersionedStmt
|
|
insertVersionedStmt*: InsertVersionedStmt
|
|
deleteVersionedStmt*: DeleteVersionedStmt
|
|
getChangesStmt*: GetChangesStmt
|
|
beginStmt*: BeginStmt
|
|
endStmt*: EndStmt
|
|
rollbackStmt*: RollbackStmt
|
|
|
|
const
|
|
DbExt* = ".sqlite3"
|
|
TableName* = "Store"
|
|
|
|
IdColName* = "id"
|
|
DataColName* = "data"
|
|
VersionColName* = "version"
|
|
TimestampColName* = "timestamp"
|
|
|
|
IdColType = "TEXT"
|
|
DataColType = "BLOB"
|
|
VersionColType = "INTEGER"
|
|
TimestampColType = "INTEGER"
|
|
|
|
Memory* = ":memory:"
|
|
|
|
# https://stackoverflow.com/a/9756276
|
|
# EXISTS returns a boolean value represented by an integer:
|
|
# https://sqlite.org/datatype3.html#boolean_datatype
|
|
# https://sqlite.org/lang_expr.html#the_exists_operator
|
|
ContainsStmtStr* = """
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM """ & TableName & """
|
|
WHERE """ & IdColName & """ = ?
|
|
)
|
|
"""
|
|
|
|
ContainsStmtExistsCol* = 0
|
|
|
|
CreateStmtStr* = """
|
|
CREATE TABLE IF NOT EXISTS """ & TableName & """ (
|
|
""" & IdColName & """ """ & IdColType & """ NOT NULL PRIMARY KEY,
|
|
""" & DataColName & """ """ & DataColType & """,
|
|
""" & VersionColName & """ """ & VersionColType & """ NOT NULL,
|
|
""" & TimestampColName & """ """ & TimestampColType & """ NOT NULL
|
|
) WITHOUT ROWID;
|
|
"""
|
|
|
|
DeleteStmtStr* = """
|
|
DELETE FROM """ & TableName & """
|
|
WHERE """ & IdColName & """ = ?
|
|
"""
|
|
|
|
GetStmtStr* = """
|
|
SELECT """ & DataColName & """ FROM """ & TableName & """
|
|
WHERE """ & IdColName & """ = ?
|
|
"""
|
|
|
|
GetStmtDataCol* = 0
|
|
|
|
PutStmtStr* = """
|
|
REPLACE INTO """ & TableName & """ (
|
|
""" & IdColName & """,
|
|
""" & DataColName & """,
|
|
""" & VersionColName & """,
|
|
""" & TimestampColName & """
|
|
) VALUES (?, ?, ?, ?)
|
|
"""
|
|
|
|
QueryStmtIdStr* = """
|
|
SELECT """ & IdColName & """ FROM """ & TableName &
|
|
""" WHERE """ & IdColName & """ GLOB ?
|
|
"""
|
|
|
|
QueryStmtDataIdStr* = """
|
|
SELECT """ & IdColName & """, """ & DataColName & """ FROM """ & TableName &
|
|
""" WHERE """ & IdColName & """ GLOB ?
|
|
"""
|
|
|
|
QueryStmtOffset* = """
|
|
OFFSET ?
|
|
"""
|
|
|
|
QueryStmtLimit* = """
|
|
LIMIT ?
|
|
"""
|
|
|
|
QueryStmtOrderAscending* = """
|
|
ORDER BY """ & IdColName & """ ASC
|
|
"""
|
|
|
|
QueryStmtOrderDescending* = """
|
|
ORDER BY """ & IdColName & """ DESC
|
|
"""
|
|
|
|
GetVersionedStmtStr* = fmt"""
|
|
SELECT {DataColName}, {VersionColName} FROM {TableName}
|
|
WHERE {IdColName} = ?
|
|
"""
|
|
|
|
GetVersionedStmtDataCol* = 0
|
|
GetVersionedStmtVersionCol* = 1
|
|
|
|
InsertVersionedStmtStr* = fmt"""
|
|
INSERT INTO {TableName}
|
|
(
|
|
{IdColName},
|
|
{DataColName},
|
|
{VersionColName},
|
|
{TimestampColName}
|
|
)
|
|
VALUES (?, ?, ?, ?)
|
|
"""
|
|
|
|
UpdateVersionedStmtStr* = fmt"""
|
|
UPDATE {TableName}
|
|
SET
|
|
{DataColName} = ?,
|
|
{VersionColName} = ?,
|
|
{TimestampColName} = ?
|
|
WHERE {IdColName} = ? AND {VersionColName} = ?
|
|
"""
|
|
|
|
DeleteVersionedStmtStr* = fmt"""
|
|
DELETE FROM {TableName}
|
|
WHERE {IdColName} = ? AND {VersionColName} = ?
|
|
"""
|
|
|
|
GetChangesStmtStr* = fmt"""
|
|
SELECT changes()
|
|
"""
|
|
|
|
BeginTransactionStr* = """
|
|
BEGIN;
|
|
"""
|
|
|
|
EndTransactionStr* = """
|
|
END;
|
|
"""
|
|
|
|
RollbackTransactionStr* = """
|
|
ROLLBACK;
|
|
"""
|
|
|
|
QueryStmtIdCol* = 0
|
|
QueryStmtDataCol* = 1
|
|
|
|
proc checkColMetadata(s: RawStmtPtr, i: int, expectedName: string) =
|
|
let
|
|
colName = sqlite3_column_origin_name(s, i.cint)
|
|
|
|
if colName.isNil:
|
|
raise (ref Defect)(msg: "no column exists for index " & $i & " in `" &
|
|
$sqlite3_sql(s) & "`")
|
|
|
|
if $colName != expectedName:
|
|
raise (ref Defect)(msg: "original column name for index " & $i & " was \"" &
|
|
$colName & "\" in `" & $sqlite3_sql(s) & "` but callee expected \"" &
|
|
expectedName & "\"")
|
|
|
|
proc idCol*(
|
|
s: RawStmtPtr,
|
|
index: int): BoundIdCol =
|
|
|
|
checkColMetadata(s, index, IdColName)
|
|
|
|
return proc (): string =
|
|
$sqlite3_column_text_not_null(s, index.cint)
|
|
|
|
proc dataCol*(
|
|
s: RawStmtPtr,
|
|
index: int): BoundDataCol =
|
|
|
|
checkColMetadata(s, index, DataColName)
|
|
|
|
return proc (): seq[byte] =
|
|
let
|
|
i = index.cint
|
|
blob = sqlite3_column_blob(s, i)
|
|
|
|
# detect out-of-memory error
|
|
# see the conversion table and final paragraph of:
|
|
# https://www.sqlite.org/c3ref/column_blob.html
|
|
# see also https://www.sqlite.org/rescode.html
|
|
|
|
# the "data" column can be NULL so in order to detect an out-of-memory error
|
|
# it is necessary to check that the result is a null pointer and that the
|
|
# result code is an error code
|
|
if blob.isNil:
|
|
let
|
|
v = sqlite3_errcode(sqlite3_db_handle(s))
|
|
|
|
if not (v in [SQLITE_OK, SQLITE_ROW, SQLITE_DONE]):
|
|
raise (ref Defect)(msg: $sqlite3_errstr(v))
|
|
|
|
let
|
|
dataLen = sqlite3_column_bytes(s, i)
|
|
dataBytes = cast[ptr UncheckedArray[byte]](blob)
|
|
|
|
@(toOpenArray(dataBytes, 0, dataLen - 1))
|
|
|
|
proc timestampCol*(
|
|
s: RawStmtPtr,
|
|
index: int): BoundTimestampCol =
|
|
|
|
checkColMetadata(s, index, TimestampColName)
|
|
|
|
return proc (): int64 =
|
|
sqlite3_column_int64(s, index.cint)
|
|
|
|
proc versionCol*(
|
|
s: RawStmtPtr,
|
|
index: int): BoundVersionCol =
|
|
|
|
checkColMetadata(s, index, VersionColName)
|
|
|
|
return proc (): int64 =
|
|
sqlite3_column_int64(s, index.cint)
|
|
|
|
proc changesCol*(
|
|
s: RawStmtPtr,
|
|
index: int): BoundVersionCol =
|
|
return proc (): int64 =
|
|
sqlite3_column_int64(s, index.cint)
|
|
|
|
proc getDBFilePath*(path: string): ?!string =
|
|
try:
|
|
let
|
|
(parent, name, ext) = path.normalizePathEnd.splitFile
|
|
dbExt = if ext == "": DbExt else: ext
|
|
absPath =
|
|
if parent.isAbsolute: parent
|
|
else: getCurrentDir() / parent
|
|
dbPath = absPath / name & dbExt
|
|
|
|
return success dbPath
|
|
except CatchableError as exc:
|
|
return failure(exc.msg)
|
|
|
|
proc close*(self: SQLiteDsDb) =
|
|
self.containsStmt.dispose
|
|
self.getStmt.dispose
|
|
self.beginStmt.dispose
|
|
self.endStmt.dispose
|
|
self.rollbackStmt.dispose
|
|
self.getVersionedStmt.dispose
|
|
self.updateVersionedStmt.dispose
|
|
self.insertVersionedStmt.dispose
|
|
self.deleteVersionedStmt.dispose
|
|
self.getChangesStmt.dispose
|
|
|
|
if not RawStmtPtr(self.deleteStmt).isNil:
|
|
self.deleteStmt.dispose
|
|
|
|
if not RawStmtPtr(self.putStmt).isNil:
|
|
self.putStmt.dispose
|
|
|
|
self.env.dispose
|
|
|
|
proc open*(
|
|
T: type SQLiteDsDb,
|
|
path = Memory,
|
|
flags = SQLITE_OPEN_READONLY): ?!SQLiteDsDb =
|
|
|
|
# make it optional to enable WAL with it enabled being the default?
|
|
|
|
# make it possible to specify a custom page size?
|
|
# https://www.sqlite.org/pragma.html#pragma_page_size
|
|
# https://www.sqlite.org/intern-v-extern-blob.html
|
|
|
|
var
|
|
env: AutoDisposed[SQLite]
|
|
|
|
defer:
|
|
disposeIfUnreleased(env)
|
|
|
|
let
|
|
isMemory = path == Memory
|
|
absPath = if isMemory: Memory else: ?path.getDBFilePath
|
|
readOnly = (SQLITE_OPEN_READONLY and flags).bool
|
|
|
|
if not isMemory:
|
|
if readOnly and not fileExists(absPath):
|
|
return failure "read-only database does not exist: " & absPath
|
|
elif not dirExists(absPath.parentDir):
|
|
return failure "directory does not exist: " & absPath
|
|
|
|
open(absPath, env.val, flags)
|
|
|
|
let
|
|
pragmaStmt = journalModePragmaStmt(env.val)
|
|
|
|
checkExec(pragmaStmt)
|
|
|
|
var
|
|
containsStmt: ContainsStmt
|
|
deleteStmt: DeleteStmt
|
|
getStmt: GetStmt
|
|
putStmt: PutStmt
|
|
getVersionedStmt: GetVersionedStmt
|
|
updateVersionedStmt: UpdateVersionedStmt
|
|
insertVersionedStmt: InsertVersionedStmt
|
|
deleteVersionedStmt: DeleteVersionedStmt
|
|
getChangesStmt: GetChangesStmt
|
|
beginStmt: BeginStmt
|
|
endStmt: EndStmt
|
|
rollbackStmt: RollbackStmt
|
|
|
|
if not readOnly:
|
|
checkExec(env.val, CreateStmtStr)
|
|
|
|
deleteStmt = ? DeleteStmt.prepare(
|
|
env.val, DeleteStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
putStmt = ? PutStmt.prepare(
|
|
env.val, PutStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
insertVersionedStmt = ? InsertVersionedStmt.prepare(
|
|
env.val, InsertVersionedStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
updateVersionedStmt = ? UpdateVersionedStmt.prepare(
|
|
env.val, UpdateVersionedStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
deleteVersionedStmt = ? DeleteVersionedStmt.prepare(
|
|
env.val, DeleteVersionedStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
getChangesStmt = ? GetChangesStmt.prepare(
|
|
env.val, GetChangesStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
beginStmt = ? BeginStmt.prepare(
|
|
env.val, BeginTransactionStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
endStmt = ? EndStmt.prepare(
|
|
env.val, EndTransactionStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
rollbackStmt = ? RollbackStmt.prepare(
|
|
env.val, RollbackTransactionStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
containsStmt = ? ContainsStmt.prepare(
|
|
env.val, ContainsStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
getStmt = ? GetStmt.prepare(
|
|
env.val, GetStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
getVersionedStmt = ? GetVersionedStmt.prepare(
|
|
env.val, GetVersionedStmtStr, SQLITE_PREPARE_PERSISTENT)
|
|
|
|
# if a readOnly/existing database does not satisfy the expected schema
|
|
# `pepare()` will fail and `new` will return an error with message
|
|
# "SQL logic error"
|
|
|
|
let
|
|
getDataCol = dataCol(RawStmtPtr(getStmt), GetStmtDataCol)
|
|
|
|
success T(
|
|
readOnly: readOnly,
|
|
dbPath: path,
|
|
containsStmt: containsStmt,
|
|
deleteStmt: deleteStmt,
|
|
env: env.release,
|
|
getStmt: getStmt,
|
|
getDataCol: getDataCol,
|
|
putStmt: putStmt,
|
|
getVersionedStmt: getVersionedStmt,
|
|
updateVersionedStmt: updateVersionedStmt,
|
|
insertVersionedStmt: insertVersionedStmt,
|
|
deleteVersionedStmt: deleteVersionedStmt,
|
|
getChangesStmt: getChangesStmt,
|
|
beginStmt: beginStmt,
|
|
endStmt: endStmt,
|
|
rollbackStmt: rollbackStmt)
|