Initial stab at better documentation.
This commit is contained in:
parent
1d7c955805
commit
9bf3c4f3ec
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
*.sw?
|
||||
nimcache/
|
||||
htmdocs/
|
||||
|
7
Makefile
Normal file
7
Makefile
Normal file
@ -0,0 +1,7 @@
|
||||
SOURCES=$(shell find src -type f)
|
||||
|
||||
doc: $(shell find src -type f)
|
||||
nim doc --project --index:on --git.url:https://github.com/jdbernard/fiber-orm --outdir:htmdocs src/fiber_orm
|
||||
nim rst2html --outdir:htmdocs README.rst
|
||||
|
||||
.PHONY: doc
|
33
README.rst
Normal file
33
README.rst
Normal file
@ -0,0 +1,33 @@
|
||||
Fiber ORM
|
||||
~~~~~~~~~
|
||||
|
||||
Lightweight ORM supporting the `Postgres`_ and `SQLite`_ databases in Nim.
|
||||
It supports a simple, opinionated model mapper to generate SQL queries based
|
||||
on Nim objects. It also includes a simple connection pooling implementation.
|
||||
|
||||
.. _Postgres: https://nim-lang.org/docs/db_postgres.html
|
||||
.. _SQLite: https://nim-lang.org/docs/db_sqlite.html
|
||||
|
||||
Basic Usage
|
||||
===========
|
||||
|
||||
Object-Relational Modeling
|
||||
==========================
|
||||
|
||||
Model Class
|
||||
-----------
|
||||
|
||||
Table Name
|
||||
``````````
|
||||
|
||||
Column Names
|
||||
````````````
|
||||
|
||||
ID Field
|
||||
````````
|
||||
|
||||
Supported Data Types
|
||||
--------------------
|
||||
|
||||
Database Object
|
||||
===============
|
@ -1,3 +1,38 @@
|
||||
# Fiber ORM
|
||||
#
|
||||
# Copyright 2019 Jonathan Bernard <jonathan@jdbernard.com>
|
||||
|
||||
## Lightweight ORM supporting the `Postgres`_ and `SQLite`_ databases in Nim.
|
||||
## It supports a simple, opinionated model mapper to generate SQL queries based
|
||||
## on Nim objects. It also includes a simple connection pooling implementation.
|
||||
##
|
||||
## .. _Postgres: https://nim-lang.org/docs/db_postgres.html
|
||||
## .. _SQLite: https://nim-lang.org/docs/db_sqlite.html
|
||||
##
|
||||
## Basic Usage
|
||||
## ===========
|
||||
##
|
||||
## Object-Relational Modeling
|
||||
## ==========================
|
||||
##
|
||||
## Model Class
|
||||
## -----------
|
||||
##
|
||||
## Table Name
|
||||
## ``````````
|
||||
##
|
||||
## Column Names
|
||||
## ````````````
|
||||
##
|
||||
## ID Field
|
||||
## ````````
|
||||
##
|
||||
## Supported Data Types
|
||||
## --------------------
|
||||
##
|
||||
## Database Object
|
||||
## ===============
|
||||
|
||||
import std/db_postgres, std/macros, std/options, std/sequtils, std/strutils
|
||||
import namespaced_logging, uuids
|
||||
|
||||
@ -16,7 +51,8 @@ export
|
||||
util.rowToModel,
|
||||
util.tableName
|
||||
|
||||
type NotFoundError* = object of CatchableError
|
||||
type NotFoundError* = object of CatchableError ##\
|
||||
## Error type raised when no record matches a given ID
|
||||
|
||||
var logNs {.threadvar.}: LoggingNamespace
|
||||
|
||||
@ -31,6 +67,15 @@ proc newMutateClauses(): MutateClauses =
|
||||
values: @[])
|
||||
|
||||
proc createRecord*[T](db: DbConn, rec: T): T =
|
||||
## Create a new record. `rec` is expected to be a `model class`_. The `id
|
||||
## field`_ is only set if it is `non-empty`_
|
||||
##
|
||||
## Returns the newly created record.
|
||||
##
|
||||
## .. _model class: #objectminusrelational-modeling-model-class
|
||||
## .. _id field: #model-class-id-field
|
||||
## .. _non-empty:
|
||||
|
||||
var mc = newMutateClauses()
|
||||
populateMutateClauses(rec, true, mc)
|
||||
|
||||
@ -46,6 +91,9 @@ proc createRecord*[T](db: DbConn, rec: T): T =
|
||||
result = rowToModel(T, newRow)
|
||||
|
||||
proc updateRecord*[T](db: DbConn, rec: T): bool =
|
||||
## Update a record by id. `rec` is expected to be a `model class`_.
|
||||
##
|
||||
## .. _model class: #objectminusrelational-modeling-model-class
|
||||
var mc = newMutateClauses()
|
||||
populateMutateClauses(rec, false, mc)
|
||||
|
||||
@ -61,16 +109,21 @@ proc updateRecord*[T](db: DbConn, rec: T): bool =
|
||||
return numRowsUpdated > 0;
|
||||
|
||||
template deleteRecord*(db: DbConn, modelType: type, id: typed): untyped =
|
||||
## Delete a record by id.
|
||||
let sqlStmt = "DELETE FROM " & tableName(modelType) & " WHERE id = ?"
|
||||
log().debug "deleteRecord: [" & sqlStmt & "] id: " & $id
|
||||
db.tryExec(sql(sqlStmt), $id)
|
||||
|
||||
proc deleteRecord*[T](db: DbConn, rec: T): bool =
|
||||
## Delete a record by `id`_.
|
||||
##
|
||||
## .. _id: #model-class-id-field
|
||||
let sqlStmt = "DELETE FROM " & tableName(rec) & " WHERE id = ?"
|
||||
log().debug "deleteRecord: [" & sqlStmt & "] id: " & $rec.id
|
||||
return db.tryExec(sql(sqlStmt), $rec.id)
|
||||
|
||||
template getRecord*(db: DbConn, modelType: type, id: typed): untyped =
|
||||
## Fetch a record by id.
|
||||
let sqlStmt =
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
" FROM " & tableName(modelType) &
|
||||
@ -85,6 +138,9 @@ template getRecord*(db: DbConn, modelType: type, id: typed): untyped =
|
||||
rowToModel(modelType, row)
|
||||
|
||||
template findRecordsWhere*(db: DbConn, modelType: type, whereClause: string, values: varargs[string, dbFormat]): untyped =
|
||||
## Find all records matching a given `WHERE` clause. The number of elements in
|
||||
## the `values` array must match the number of placeholders (`?`) in the
|
||||
## provided `WHERE` clause.
|
||||
let sqlStmt =
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
" FROM " & tableName(modelType) &
|
||||
@ -94,6 +150,7 @@ template findRecordsWhere*(db: DbConn, modelType: type, whereClause: string, val
|
||||
db.getAllRows(sql(sqlStmt), values).mapIt(rowToModel(modelType, it))
|
||||
|
||||
template getAllRecords*(db: DbConn, modelType: type): untyped =
|
||||
## Fetch all records of the given type.
|
||||
let sqlStmt =
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
" FROM " & tableName(modelType)
|
||||
@ -102,6 +159,7 @@ template getAllRecords*(db: DbConn, modelType: type): untyped =
|
||||
db.getAllRows(sql(sqlStmt)).mapIt(rowToModel(modelType, it))
|
||||
|
||||
template findRecordsBy*(db: DbConn, modelType: type, lookups: seq[tuple[field: string, value: string]]): untyped =
|
||||
## Find all records matching the provided lookup values.
|
||||
let sqlStmt =
|
||||
"SELECT " & columnNamesForModel(modelType).join(",") &
|
||||
" FROM " & tableName(modelType) &
|
||||
@ -112,6 +170,23 @@ template findRecordsBy*(db: DbConn, modelType: type, lookups: seq[tuple[field: s
|
||||
db.getAllRows(sql(sqlStmt), values).mapIt(rowToModel(modelType, it))
|
||||
|
||||
macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untyped =
|
||||
## Generate all standard access procedures for the given model types. For a
|
||||
## `model class`_ named `SampleRecord`, this will generate the following
|
||||
## procedures:
|
||||
##
|
||||
## .. code-block:: Nim
|
||||
## proc getSampleRecord*(db: dbType): SampleRecord;
|
||||
## proc getAllSampleRecords*(db: dbType): SampleRecord;
|
||||
## proc createSampleRecord*(db: dbType, rec: SampleRecord): SampleRecord;
|
||||
## proc deleteSampleRecord*(db: dbType, rec: SampleRecord): bool;
|
||||
## proc deleteSampleRecord*(db: dbType, id: idType): bool;
|
||||
## proc updateSampleRecord*(db: dbType, rec: SampleRecord): bool;
|
||||
##
|
||||
## proc findSampleRecordsWhere*(
|
||||
## db: dbType, whereClause: string, values: varargs[string]): SampleRecord;
|
||||
##
|
||||
## `dbType` is expected to be some type that has a defined `withConn`_
|
||||
## procedure.
|
||||
result = newStmtList()
|
||||
|
||||
for t in modelTypes:
|
||||
@ -147,6 +222,15 @@ macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untype
|
||||
db.withConn: result = deleteRecord(conn, `t`, id)
|
||||
|
||||
macro generateLookup*(dbType: type, modelType: type, fields: seq[string]): untyped =
|
||||
## Create a lookup procedure for a given set of field names. For example,
|
||||
##
|
||||
## .. code-block:: Nim
|
||||
## generateLookup(SampleDB, SampleRecord, ["name", "location"])
|
||||
##
|
||||
## will generate the following procedure:
|
||||
##
|
||||
## .. code-block:: Nim
|
||||
## proc findSampleRecordsByNameAndLocation*(db: SampleDB,
|
||||
let fieldNames = fields[1].mapIt($it)
|
||||
let procName = ident("find" & pluralize($modelType.getType[1]) & "By" & fieldNames.mapIt(it.capitalize).join("And"))
|
||||
|
||||
@ -211,6 +295,20 @@ proc initPool*(
|
||||
poolSize = 10,
|
||||
hardCap = false,
|
||||
healthCheckQuery = "SELECT 'true' AS alive"): DbConnPool =
|
||||
## Initialize a new DbConnPool.
|
||||
##
|
||||
## * `connect` must be a factory which creates a new `DbConn`
|
||||
## * `poolSize` sets the desired capacity of the connection pool.
|
||||
## * `hardCap` defaults to `false`.
|
||||
##
|
||||
## When `false`, the pool can grow beyond the configured capacity, but will
|
||||
## release connections down to the its capacity (no less than `poolSize`).
|
||||
##
|
||||
## When `true` the pool will not create more than its configured capacity.
|
||||
## It a connection is requested, none are free, and the pool is at
|
||||
## capacity, this will result in an Error being raised.
|
||||
## * `healthCheckQuery` should be a simple and fast SQL query that the pool
|
||||
## can use to test the liveliness of pooled connections.
|
||||
|
||||
initDbConnPool(DbConnPoolConfig(
|
||||
connect: connect,
|
||||
|
@ -1,3 +1,9 @@
|
||||
# Fiber ORM
|
||||
#
|
||||
# Copyright 2019 Jonathan Bernard <jonathan@jdbernard.com>
|
||||
|
||||
## Simple database connection pooling implementation compatible with Fiber ORM.
|
||||
|
||||
import std/db_postgres, std/sequtils, std/strutils, std/sugar
|
||||
|
||||
import namespaced_logging
|
||||
@ -5,10 +11,21 @@ import namespaced_logging
|
||||
|
||||
type
|
||||
DbConnPoolConfig* = object
|
||||
connect*: () -> DbConn
|
||||
poolSize*: int
|
||||
hardCap*: bool
|
||||
healthCheckQuery*: string
|
||||
connect*: () -> DbConn ## Factory procedure to create a new DBConn
|
||||
poolSize*: int ## The pool capacity.
|
||||
hardCap*: bool ## Is the pool capacity a hard cap?
|
||||
##
|
||||
## When `false`, the pool can grow beyond the configured
|
||||
## capacity, but will release connections down to the its
|
||||
## capacity (no less than `poolSize`).
|
||||
##
|
||||
## When `true` the pool will not create more than its
|
||||
## configured capacity. It a connection is requested, none
|
||||
## are free, and the pool is at capacity, this will result
|
||||
## in an Error being raised.
|
||||
healthCheckQuery*: string ## Should be a simple and fast SQL query that the
|
||||
## pool can use to test the liveliness of pooled
|
||||
## connections.
|
||||
|
||||
PooledDbConn = ref object
|
||||
conn: DbConn
|
||||
@ -16,6 +33,7 @@ type
|
||||
free: bool
|
||||
|
||||
DbConnPool* = ref object
|
||||
## Database connection pool
|
||||
conns: seq[PooledDbConn]
|
||||
cfg: DbConnPoolConfig
|
||||
lastId: int
|
||||
@ -74,6 +92,13 @@ proc maintain(pool: DbConnPool): void =
|
||||
[$toCull.len, $pool.conns.len])
|
||||
|
||||
proc take*(pool: DbConnPool): tuple[id: int, conn: DbConn] =
|
||||
## Request a connection from the pool. Returns a DbConn if the pool has free
|
||||
## connections, or if it has the capacity to create a new connection. If the
|
||||
## pool is configured with a hard capacity limit and is out of free
|
||||
## connections, this will raise an Error.
|
||||
##
|
||||
## Connections taken must be returned via `release` when the caller is
|
||||
## finished using them in order for them to be released back to the pool.
|
||||
pool.maintain
|
||||
let freeConns = pool.conns.filterIt(it.free)
|
||||
|
||||
@ -89,11 +114,17 @@ proc take*(pool: DbConnPool): tuple[id: int, conn: DbConn] =
|
||||
return (id: reserved.id, conn: reserved.conn)
|
||||
|
||||
proc release*(pool: DbConnPool, connId: int): void =
|
||||
## Release a connection back to the pool.
|
||||
log().debug("Reclaiming released connaction $#" % [$connId])
|
||||
let foundConn = pool.conns.filterIt(it.id == connId)
|
||||
if foundConn.len > 0: foundConn[0].free = true
|
||||
|
||||
template withConn*(pool: DbConnPool, stmt: untyped): untyped =
|
||||
## Convenience template to provide a connection from the pool for use in a
|
||||
## statement block, automatically releasing that connnection when done.
|
||||
##
|
||||
## The provided connection is injected as the variable `conn` in the
|
||||
## statement body.
|
||||
let (connId, conn {.inject.}) = take(pool)
|
||||
try: stmt
|
||||
finally: release(pool, connId)
|
||||
|
@ -1,3 +1,8 @@
|
||||
# Fiber ORM
|
||||
#
|
||||
# Copyright 2019 Jonathan Bernard <jonathan@jdbernard.com>
|
||||
|
||||
## Utility methods used internally by Fiber ORM.
|
||||
import json, macros, options, sequtils, strutils, times, timeutils, unicode,
|
||||
uuids
|
||||
|
||||
@ -5,6 +10,10 @@ import nre except toSeq
|
||||
|
||||
type
|
||||
MutateClauses* = object
|
||||
## Data structure to hold information about the clauses that should be
|
||||
## added to a query. How these clauses are used will depend on the query.
|
||||
## This common data structure provides the information needed to create
|
||||
## WHERE clauses, UPDATE clauses, etc.
|
||||
columns*: seq[string]
|
||||
placeholders*: seq[string]
|
||||
values*: seq[string]
|
||||
@ -12,18 +21,23 @@ type
|
||||
# TODO: more complete implementation
|
||||
# see https://github.com/blakeembrey/pluralize
|
||||
proc pluralize*(name: string): string =
|
||||
## Return the plural form of the given name.
|
||||
if name[^2..^1] == "ey": return name[0..^3] & "ies"
|
||||
if name[^1] == 'y': return name[0..^2] & "ies"
|
||||
return name & "s"
|
||||
|
||||
macro modelName*(model: object): string =
|
||||
## For a given concrete record object, return the name of the `model class`_
|
||||
return newStrLitNode($model.getTypeInst)
|
||||
|
||||
macro modelName*(modelType: type): string =
|
||||
## Get the name of a given `model class`_
|
||||
return newStrLitNode($modelType.getType[1])
|
||||
|
||||
|
||||
proc identNameToDb*(name: string): string =
|
||||
## Map a Nim identifier name to a DB name. See the `rules for name mapping`_
|
||||
##
|
||||
## TODO link above
|
||||
const UNDERSCORE_RUNE = "_".toRunes[0]
|
||||
let nameInRunes = name.toRunes
|
||||
var prev: Rune
|
||||
@ -42,28 +56,40 @@ proc identNameToDb*(name: string): string =
|
||||
return $resultRunes
|
||||
|
||||
proc dbNameToIdent*(name: string): string =
|
||||
## Map a DB name to a Nim identifier name. See the `rules for name mapping`_
|
||||
let parts = name.split("_")
|
||||
return @[parts[0]].concat(parts[1..^1].mapIt(capitalize(it))).join("")
|
||||
|
||||
proc tableName*(modelType: type): string =
|
||||
## Get the `table name`_ for a given `model class`_
|
||||
return pluralize(modelName(modelType).identNameToDb)
|
||||
|
||||
proc tableName*[T](rec: T): string =
|
||||
## Get the `table name`_ for a given record.
|
||||
return pluralize(modelName(rec).identNameToDb)
|
||||
|
||||
proc dbFormat*(s: string): string = return s
|
||||
proc dbFormat*(s: string): string =
|
||||
## Format a string for inclusion in a SQL Query.
|
||||
return s
|
||||
|
||||
proc dbFormat*(dt: DateTime): string = return dt.formatIso8601
|
||||
proc dbFormat*(dt: DateTime): string =
|
||||
## Format a DateTime for inclusion in a SQL Query.
|
||||
return dt.formatIso8601
|
||||
|
||||
proc dbFormat*[T](list: seq[T]): string =
|
||||
## Format a `seq` for inclusion in a SQL Query.
|
||||
return "{" & list.mapIt(dbFormat(it)).join(",") & "}"
|
||||
|
||||
proc dbFormat*[T](item: T): string = return $item
|
||||
proc dbFormat*[T](item: T): string =
|
||||
## For all other types, fall back on a defined `$` function to create a
|
||||
## string version of the value we can include in an SQL query>
|
||||
return $item
|
||||
|
||||
type DbArrayParseState = enum
|
||||
expectStart, inQuote, inVal, expectEnd
|
||||
|
||||
proc parsePGDatetime*(val: string): DateTime =
|
||||
## Parse a Postgres datetime value into a Nim DateTime object.
|
||||
|
||||
const PG_TIMESTAMP_FORMATS = [
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
@ -103,6 +129,7 @@ proc parsePGDatetime*(val: string): DateTime =
|
||||
raise newException(ValueError, "Cannot parse PG date. Tried:" & errStr)
|
||||
|
||||
proc parseDbArray*(val: string): seq[string] =
|
||||
## Parse a Postgres array column into a Nim seq[string]
|
||||
result = newSeq[string]()
|
||||
|
||||
var parseState = DbArrayParseState.expectStart
|
||||
@ -161,6 +188,9 @@ proc parseDbArray*(val: string): seq[string] =
|
||||
result.add(curStr)
|
||||
|
||||
proc createParseStmt*(t, value: NimNode): NimNode =
|
||||
## Utility method to create the Nim cod required to parse a value coming from
|
||||
## the a database query. This is used by functions like `rowToModel` to parse
|
||||
## the dataabase columns into the Nim object fields.
|
||||
|
||||
#echo "Creating parse statment for ", t.treeRepr
|
||||
if t.typeKind == ntyObject:
|
||||
@ -219,6 +249,9 @@ proc createParseStmt*(t, value: NimNode): NimNode =
|
||||
error "Unknown value type: " & $t.typeKind
|
||||
|
||||
template walkFieldDefs*(t: NimNode, body: untyped) =
|
||||
## Iterate over every field of the given Nim object, yielding and defining
|
||||
## `fieldIdent` and `fieldType`, the name of the field as a Nim Ident node
|
||||
## and the type of the field as a Nim Type node respectively.
|
||||
let tTypeImpl = t.getTypeImpl
|
||||
|
||||
var nodeToItr: NimNode
|
||||
@ -239,6 +272,8 @@ template walkFieldDefs*(t: NimNode, body: untyped) =
|
||||
body
|
||||
|
||||
macro columnNamesForModel*(modelType: typed): seq[string] =
|
||||
## Return the column names corresponding to the the fields of the given
|
||||
## `model class`_
|
||||
var columnNames = newSeq[string]()
|
||||
|
||||
modelType.walkFieldDefs:
|
||||
@ -247,6 +282,8 @@ macro columnNamesForModel*(modelType: typed): seq[string] =
|
||||
result = newLit(columnNames)
|
||||
|
||||
macro rowToModel*(modelType: typed, row: seq[string]): untyped =
|
||||
## Return a new Nim model object of type `modelType` populated with the
|
||||
## values returned in the given database `row`
|
||||
|
||||
# Create the object constructor AST node
|
||||
result = newNimNode(nnkObjConstr).add(modelType)
|
||||
@ -269,6 +306,7 @@ macro listFields*(t: typed): untyped =
|
||||
result = newLit(fields)
|
||||
|
||||
proc typeOfColumn*(modelType: NimNode, colName: string): NimNode =
|
||||
## Given a model type and a column name, return the Nim type for that column.
|
||||
modelType.walkFieldDefs:
|
||||
if $fieldIdent != colName: continue
|
||||
|
||||
@ -289,6 +327,8 @@ proc isEmpty(val: UUID): bool = return val.isZero
|
||||
proc isEmpty(val: string): bool = return val.isEmptyOrWhitespace
|
||||
|
||||
macro populateMutateClauses*(t: typed, newRecord: bool, mc: var MutateClauses): untyped =
|
||||
## Given a record type, create the datastructure used to generate SQL clauses
|
||||
## for the fields of this record type.
|
||||
|
||||
result = newStmtList()
|
||||
|
||||
@ -326,3 +366,7 @@ macro populateMutateClauses*(t: typed, newRecord: bool, mc: var MutateClauses):
|
||||
`mc`.columns.add(identNameToDb(`fieldName`))
|
||||
`mc`.placeholders.add("?")
|
||||
`mc`.values.add(dbFormat(`t`.`fieldIdent`))
|
||||
|
||||
## .. _model class: ../fiber_orm.html#objectminusrelational-modeling-model-class
|
||||
## .. _rules for name mapping: ../fiber_orm.html
|
||||
## .. _table name: ../fiber_orm.html
|
||||
|
Loading…
x
Reference in New Issue
Block a user