nim-datastore/datastore/sqlite_datastore.nim
2022-06-22 13:16:43 -05:00

273 lines
6.0 KiB
Nim

import std/os
import std/times
import pkg/questionable
import pkg/questionable/results
import pkg/sqlite3_abi
import pkg/stew/byteutils
import pkg/upraises
import ./datastore
import ./sqlite
export datastore, sqlite
push: {.upraises: [].}
type
# 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), void]
SQLiteDatastore* = ref object of Datastore
dbPath: string
containsStmt: ContainsStmt
deleteStmt: DeleteStmt
env: SQLite
getStmt: GetStmt
putStmt: PutStmt
readOnly: bool
const
IdType = "TEXT"
DataType = "BLOB"
TimestampType = "INTEGER"
dbExt* = ".sqlite3"
tableTitle* = "Store"
# 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 """ & tableTitle & """
WHERE id = ?
);
"""
createStmtStr = """
CREATE TABLE IF NOT EXISTS """ & tableTitle & """ (
id """ & IdType & """ NOT NULL PRIMARY KEY,
data """ & DataType & """,
timestamp """ & TimestampType & """ NOT NULL
) WITHOUT ROWID;
"""
deleteStmtStr = """
DELETE FROM """ & tableTitle & """
WHERE id = ?;
"""
getStmtStr = """
SELECT data FROM """ & tableTitle & """
WHERE id = ?;
"""
putStmtStr = """
REPLACE INTO """ & tableTitle & """ (
id, data, timestamp
) VALUES (?, ?, ?);
"""
proc new*(
T: type SQLiteDatastore,
basePath = "data",
filename = "store" & dbExt,
readOnly = false,
inMemory = false): ?!T =
# 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)
var
basep, fname, dbPath: string
if inMemory:
if readOnly:
return failure "SQLiteDatastore cannot be read-only and in-memory"
else:
dbPath = ":memory:"
else:
try:
basep = normalizePathEnd(
if basePath.isAbsolute: basePath
else: getCurrentDir() / basePath)
fname = filename.normalizePathEnd
dbPath = basep / fname
if readOnly and not fileExists(dbPath):
return failure "read-only database does not exist: " & dbPath
else:
createDir(basep)
except IOError as e:
return failure e
except OSError as e:
return failure e
let
flags =
if readOnly: SQLITE_OPEN_READONLY
else: SQLITE_OPEN_READWRITE or SQLITE_OPEN_CREATE
open(dbPath, env.val, flags)
let
pragmaStmt = journalModePragmaStmt(env.val)
checkExec(pragmaStmt)
var
containsStmt: ContainsStmt
deleteStmt: DeleteStmt
getStmt: GetStmt
putStmt: PutStmt
if not readOnly:
checkExec(env.val, createStmtStr)
deleteStmt = ? DeleteStmt.prepare(env.val, deleteStmtStr)
putStmt = ? PutStmt.prepare(env.val, putStmtStr)
containsStmt = ? ContainsStmt.prepare(env.val, containsStmtStr)
getStmt = ? GetStmt.prepare(env.val, getStmtStr)
# 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"
success T(dbPath: dbPath, containsStmt: containsStmt, deleteStmt: deleteStmt,
env: env.release, getStmt: getStmt, putStmt: putStmt,
readOnly: readOnly)
proc dbPath*(self: SQLiteDatastore): string =
self.dbPath
proc env*(self: SQLiteDatastore): SQLite =
self.env
proc close*(self: SQLiteDatastore) =
self.containsStmt.dispose
self.getStmt.dispose
if not self.readOnly:
self.deleteStmt.dispose
self.putStmt.dispose
self.env.dispose
self[] = SQLiteDatastore()[]
proc timestamp*(t = epochTime()): int64 =
(t * 1_000_000).int64
proc idCol*(
s: RawStmtPtr,
index = 0): string =
$sqlite3_column_text(s, index.cint).cstring
proc dataCol*(
s: RawStmtPtr,
index = 1): seq[byte] =
let
i = index.cint
dataBytes = cast[ptr UncheckedArray[byte]](sqlite3_column_blob(s, i))
dataLen = sqlite3_column_bytes(s, i)
@(toOpenArray(dataBytes, 0, dataLen - 1))
proc timestampCol*(
s: RawStmtPtr,
index = 2): int64 =
sqlite3_column_int64(s, index.cint)
method contains*(
self: SQLiteDatastore,
key: Key): ?!bool {.locks: "unknown".} =
var
exists = false
proc onData(s: RawStmtPtr) {.closure.} =
exists = sqlite3_column_int64(s, 0).bool
discard ? self.containsStmt.query((key.id), onData)
success exists
method delete*(
self: SQLiteDatastore,
key: Key): ?!void {.locks: "unknown".} =
if self.readOnly:
failure "database is read-only":
else:
self.deleteStmt.exec((key.id))
method get*(
self: SQLiteDatastore,
key: Key): ?!(?seq[byte]) {.locks: "unknown".} =
# see comment in ./filesystem_datastore re: finer control of memory
# allocation in `method get`, could apply here as well if bytes were read
# incrementally with `sqlite3_blob_read`
var
bytes: seq[byte]
proc onData(s: RawStmtPtr) {.closure.} =
bytes = dataCol(s, 0)
let
exists = ? self.getStmt.query((key.id), onData)
if exists:
success bytes.some
else:
success seq[byte].none
proc put*(
self: SQLiteDatastore,
key: Key,
data: openArray[byte],
timestamp: int64): ?!void =
if self.readOnly:
failure "database is read-only"
else:
self.putStmt.exec((key.id, @data, timestamp))
method put*(
self: SQLiteDatastore,
key: Key,
data: openArray[byte]): ?!void {.locks: "unknown".} =
self.put(key, data, timestamp())
# method query*(
# self: SQLiteDatastore,
# query: ...): ?!(?...) {.locks: "unknown".} =
#
# success ....none