Initial stab at better documentation.

This commit is contained in:
Jonathan Bernard 2022-09-02 23:25:52 -05:00
parent 1d7c955805
commit 9bf3c4f3ec
6 changed files with 223 additions and 9 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
*.sw?
nimcache/
htmdocs/

7
Makefile Normal file
View 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
View 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
===============

View File

@ -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,

View File

@ -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)

View File

@ -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