import std/os import pkg/questionable import pkg/questionable/results import pkg/upraises import ../backend import ./sqliteutils export sqliteutils type BoundIdCol* = proc (): string {.closure, gcsafe, upraises: [].} BoundDataCol* = proc (): DataBuffer {.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*[K] = SQLiteStmt[(K), void] DeleteStmt*[K] = SQLiteStmt[(K), void] GetStmt*[K] = SQLiteStmt[(K), void] PutStmt*[K, V] = SQLiteStmt[(K, V, int64), void] QueryStmt* = SQLiteStmt[(string), void] BeginStmt* = NoParamsStmt EndStmt* = NoParamsStmt RollbackStmt* = NoParamsStmt SQLiteDsDb*[K: DbKey, V: DbVal] = object readOnly*: bool dbPath*: DataBuffer containsStmt*: ContainsStmt[K] deleteStmt*: DeleteStmt[K] env*: SQLite getDataCol*: (RawStmtPtr, int) getStmt*: GetStmt[K] putStmt*: PutStmt[K,V] beginStmt*: BeginStmt endStmt*: EndStmt rollbackStmt*: RollbackStmt const DbExt* = ".sqlite3" TableName* = "Store" IdColName* = "id" DataColName* = "data" TimestampColName* = "timestamp" IdColType = "TEXT" DataColType = "BLOB" 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 & """, """ & 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 & """, """ & 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 """ 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*[V: DbVal](data: (RawStmtPtr, int)): V = let s = data[0] let index = data[1] checkColMetadata(s, index, DataColName) 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) # copy data out, since sqlite will free it V.toVal(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 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*[K, V](self: SQLiteDsDb[K, V]) = self.containsStmt.dispose self.getStmt.dispose self.beginStmt.dispose self.endStmt.dispose self.rollbackStmt.dispose if not RawStmtPtr(self.deleteStmt).isNil: self.deleteStmt.dispose if not RawStmtPtr(self.putStmt).isNil: self.putStmt.dispose self.env.dispose proc open*[K,V]( T: type SQLiteDsDb[K,V], path = Memory, flags = SQLITE_OPEN_READONLY): ?!SQLiteDsDb[K, V] = # 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[K] deleteStmt: DeleteStmt[K] getStmt: GetStmt[K] putStmt: PutStmt[K,V] beginStmt: BeginStmt endStmt: EndStmt rollbackStmt: RollbackStmt if not readOnly: checkExec(env.val, CreateStmtStr) deleteStmt = ? DeleteStmt[K].prepare( env.val, DeleteStmtStr, SQLITE_PREPARE_PERSISTENT) putStmt = ? PutStmt[K,V].prepare( env.val, PutStmtStr, 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[K].prepare( env.val, ContainsStmtStr, SQLITE_PREPARE_PERSISTENT) getStmt = ? GetStmt[K].prepare( env.val, GetStmtStr, 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 = (RawStmtPtr(getStmt), GetStmtDataCol) success SQLiteDsDb[K,V]( readOnly: readOnly, dbPath: DataBuffer.new path, containsStmt: containsStmt, deleteStmt: deleteStmt, env: env.release, getStmt: getStmt, getDataCol: getDataCol, putStmt: putStmt, beginStmt: beginStmt, endStmt: endStmt, rollbackStmt: rollbackStmt)