Compare commits

..
9 Commits
Author SHA1 Message Date
jdb ffcdb42f5e Bump package version. 2026-09-02 12:23:38 -05:00
jdb 06b568e071 Add custom database mapping hooks 2026-09-02 12:21:23 -05:00
jdb 170359d840 Fix distinct type row mapping 2026-09-02 11:50:44 -05:00
jdb f2f41782d4 Preserve DateTime microsecond precision 2026-08-31 17:52:09 -05:00
jdb 2301da8143 Add PostgreSQL FOR UPDATE getters
Add a PostgreSQL-specific getRecordForUpdate helper that appends FOR
UPDATE to the generated SELECT statement so callers can lock a row
inside an explicit transaction.

generateProcsForModels now always emits a direct-connection
get<RecordName>ForUpdate proc that accepts db_postgres.DbConn. There is
intentionally no dbType overload for this API, because reacquiring a
connection via withConnection would defeat the lock's transactional
scope.

The source docs and README now document the new helper and show the
intended usage pattern inside inTransaction:

  db.inTransaction:
    var item = conn.getTodoItemForUpdate(todoId)
    item.priority += 1
    discard conn.updateTodoItem(item)
2026-03-24 22:04:49 -05:00
jdb 71cb5a7cff Update documentation for new signature changes, bump version. 2026-03-24 21:48:51 -05:00
jdb 1a9314fe4f Add connection overloads for generated ORM procs
The generated ORM helpers currently only accept the database wrapper
type as their first argument. That works well for the common case, but
it becomes misleading inside inTransaction blocks because the generated
proc will call withConnection again and may therefore use a different
connection than the one that is participating in the transaction.

Add DbConnType-constrained overloads for the generated model CRUD/query
procs, generated lookups, and generated join-table helpers. This lets
callers explicitly use the transaction connection while keeping the
existing dbType-based API intact for non-transactional call sites.

This makes the intended transactional usage straightforward:

  db.inTransaction:
    var userRecord = conn.getUser("userId1")
    userRecord.visitCount += 1
    discard conn.updateUser(userRecord)

AI-Assisted: yes
AI-Tool: OpenAI Codes / gpt-5.4 xhigh
2026-03-24 21:39:25 -05:00
jdb bb36bba864 Support distinct versions of types we know how to convert. 2025-09-02 00:40:00 -05:00
jdb f54bf6e974 Add tryGet<RecordName> versions of get<Record> calls
`tryGet<RecordName>`  returns Option types rather than raise exceptions.

For example:

    generateProcsForModels(MyDb, [ User ])

will now create both:

    proc getUser*(db: MyDb, id: string): User
    proc tryGetUser*(db: MyDb, id: string): Option[User]
2025-09-02 00:36:11 -05:00
10 changed files with 786 additions and 128 deletions
+8
View File
@@ -3,6 +3,14 @@ SOURCES=$(shell find src -type f)
build: $(shell find src -type f) build: $(shell find src -type f)
nimble build nimble build
unittest:
nimble unittest
.PHONY: unittest
integrationtest:
nimble integrationtest
.PHONY: integrationtest
docs: $(shell find src -type f) docs: $(shell find src -type f)
nim doc --project --index:on --git.url:https://github.com/jdbernard/fiber-orm --outdir:docs src/fiber_orm nim doc --project --index:on --git.url:https://github.com/jdbernard/fiber-orm --outdir:docs src/fiber_orm
nim rst2html --outdir:docs README.rst nim rst2html --outdir:docs README.rst
+101 -12
View File
@@ -83,7 +83,7 @@ Using Fiber ORM we can generate a data access layer with:
.. code-block:: Nim .. code-block:: Nim
# db.nim # db.nim
import std/[options] import std/[options]
import db_connectors/db_postgres import db_connector/db_postgres
import fiber_orm import fiber_orm
import ./models.nim import ./models.nim
@@ -100,32 +100,90 @@ Using Fiber ORM we can generate a data access layer with:
generateLookup(TodoDB, TimeEntry, @["todoItemId"]) generateLookup(TodoDB, TimeEntry, @["todoItemId"])
This will generate the following procedures: This will generate procedures like the following in two flavors:
* a `dbType` flavor that acquires a connection via `withConnection`
* a connection flavor that operates directly on an existing
`conn: D` where `D: DbConnType`
.. code-block:: Nim .. code-block:: Nim
proc getTodoItem*(db: TodoDB, id: UUID): TodoItem; proc getTodoItem*(db: TodoDB, id: UUID): TodoItem;
proc getTodoItem*[D: DbConnType](conn: D, id: UUID): TodoItem;
proc getTodoItemForUpdate*(conn: db_postgres.DbConn, id: UUID): TodoItem;
proc tryGetTodoItem*(db: TodoDB, id: UUID): Option[TodoItem];
proc tryGetTodoItem*[D: DbConnType](conn: D, id: UUID): Option[TodoItem];
proc getTodoItemIfItExists*(db: TodoDB, id: UUID): Option[TodoItem]; proc getTodoItemIfItExists*(db: TodoDB, id: UUID): Option[TodoItem];
proc getAllTodoItems*(db: TodoDB): seq[TodoItem]; proc getTodoItemIfItExists*[D: DbConnType](
conn: D, id: UUID): Option[TodoItem];
proc getAllTodoItems*(db: TodoDB,
pagination = none[PaginationParams]()): PagedRecords[TodoItem];
proc getAllTodoItems*[D: DbConnType](conn: D,
pagination = none[PaginationParams]()): PagedRecords[TodoItem];
proc createTodoItem*(db: TodoDB, rec: TodoItem): TodoItem; proc createTodoItem*(db: TodoDB, rec: TodoItem): TodoItem;
proc createTodoItem*[D: DbConnType](conn: D, rec: TodoItem): TodoItem;
proc updateTodoItem*(db: TodoDB, rec: TodoItem): bool; proc updateTodoItem*(db: TodoDB, rec: TodoItem): bool;
proc updateTodoItem*[D: DbConnType](conn: D, rec: TodoItem): bool;
proc createOrUpdateTodoItem*(db: TodoDB, rec: TodoItem): TodoItem;
proc createOrUpdateTodoItem*[D: DbConnType](
conn: D, rec: TodoItem): TodoItem;
proc deleteTodoItem*(db: TodoDB, rec: TodoItem): bool; proc deleteTodoItem*(db: TodoDB, rec: TodoItem): bool;
proc deleteTodoItem*[D: DbConnType](conn: D, rec: TodoItem): bool;
proc deleteTodoItem*(db: TodoDB, id: UUID): bool; proc deleteTodoItem*(db: TodoDB, id: UUID): bool;
proc deleteTodoItem*[D: DbConnType](conn: D, id: UUID): bool;
proc findTodoItemsWhere*(db: TodoDB, whereClause: string, proc findTodoItemsWhere*(db: TodoDB, whereClause: string,
values: varargs[string, dbFormat]): seq[TodoItem]; values: varargs[string, dbFormat],
pagination = none[PaginationParams]()): PagedRecords[TodoItem];
proc findTodoItemsWhere*[D: DbConnType](conn: D, whereClause: string,
values: varargs[string, dbFormat],
pagination = none[PaginationParams]()): PagedRecords[TodoItem];
proc getTimeEntry*(db: TodoDB, id: UUID): TimeEntry; proc getTimeEntry*(db: TodoDB, id: UUID): TimeEntry;
proc getTimeEntry*[D: DbConnType](conn: D, id: UUID): TimeEntry;
proc getTimeEntryIfItExists*(db: TodoDB, id: UUID): Option[TimeEntry]; proc getTimeEntryIfItExists*(db: TodoDB, id: UUID): Option[TimeEntry];
proc getAllTimeEntries*(db: TodoDB): seq[TimeEntry]; proc getTimeEntryIfItExists*[D: DbConnType](
conn: D, id: UUID): Option[TimeEntry];
proc getAllTimeEntries*(db: TodoDB,
pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
proc getAllTimeEntries*[D: DbConnType](conn: D,
pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
proc createTimeEntry*(db: TodoDB, rec: TimeEntry): TimeEntry; proc createTimeEntry*(db: TodoDB, rec: TimeEntry): TimeEntry;
proc createTimeEntry*[D: DbConnType](conn: D, rec: TimeEntry): TimeEntry;
proc updateTimeEntry*(db: TodoDB, rec: TimeEntry): bool; proc updateTimeEntry*(db: TodoDB, rec: TimeEntry): bool;
proc updateTimeEntry*[D: DbConnType](conn: D, rec: TimeEntry): bool;
proc deleteTimeEntry*(db: TodoDB, rec: TimeEntry): bool; proc deleteTimeEntry*(db: TodoDB, rec: TimeEntry): bool;
proc deleteTimeEntry*[D: DbConnType](conn: D, rec: TimeEntry): bool;
proc deleteTimeEntry*(db: TodoDB, id: UUID): bool; proc deleteTimeEntry*(db: TodoDB, id: UUID): bool;
proc deleteTimeEntry*[D: DbConnType](conn: D, id: UUID): bool;
proc findTimeEntriesWhere*(db: TodoDB, whereClause: string, proc findTimeEntriesWhere*(db: TodoDB, whereClause: string,
values: varargs[string, dbFormat]): seq[TimeEntry]; values: varargs[string, dbFormat],
pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
proc findTimeEntriesWhere*[D: DbConnType](conn: D, whereClause: string,
values: varargs[string, dbFormat],
pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
proc findTimeEntriesByTodoItemId(db: TodoDB, todoItemId: UUID): seq[TimeEntry]; proc findTimeEntriesByTodoItemId*(db: TodoDB, todoItemId: UUID,
pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
proc findTimeEntriesByTodoItemId*[D: DbConnType](
conn: D, todoItemId: UUID,
pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
Use the `dbType` flavor when the caller does not already have a connection.
Use the connection flavor inside `withConnection` or `inTransaction`.
The generated `get<RecordName>ForUpdate` helper is PostgreSQL-specific and
is only available for direct PostgreSQL connections.
Warning: do not call the `dbType` flavor from inside `inTransaction`.
Those overloads call `withConnection` and may acquire a different
connection, causing the statements to execute outside the active
transaction.
.. code-block:: Nim
db.inTransaction:
var item = conn.getTodoItemForUpdate(todoId)
item.priority += 1
discard conn.updateTodoItem(item)
Object-Relational Modeling Object-Relational Modeling
========================== ==========================
@@ -133,11 +191,11 @@ Object-Relational Modeling
Model Class Model Class
----------- -----------
Fiber ORM uses simple Nim `object`s and `ref object`s as model classes. Fiber ORM uses simple Nim objects and ref objects as model classes.
Fiber ORM expects there to be one table for each model class. Fiber ORM expects there to be one table for each model class.
Name Mapping Name Mapping
```````````` ^^^^^^^^^^^^
Fiber ORM uses `snake_case` for database identifiers (column names, table Fiber ORM uses `snake_case` for database identifiers (column names, table
names, etc.) and `camelCase` for Nim identifiers. We automatically convert names, etc.) and `camelCase` for Nim identifiers. We automatically convert
model names to and from table names (`TodoItem` <-> `todo_items`), as well model names to and from table names (`TodoItem` <-> `todo_items`), as well
@@ -168,7 +226,7 @@ procedures in the `fiber_orm/util`_ module for details.
.. _util: fiber_orm/util.html .. _util: fiber_orm/util.html
ID Field ID Field
```````` ^^^^^^^^
Fiber ORM expects every model class to have a field named `id`, with a Fiber ORM expects every model class to have a field named `id`, with a
corresponding `id` column in the model table. This field must be either a corresponding `id` column in the model table. This field must be either a
@@ -231,6 +289,35 @@ Nim Type Postgres Type SQLite Type
`JsonNode`_ `jsonb`_ `JsonNode`_ `jsonb`_
=============== ====================== ================= =============== ====================== =================
Distinct types backed by a supported type are parsed through their backing
type and then converted to the distinct type. This also applies when the
distinct type is nested in an `Option`_ or `seq`.
Custom Database Mappings
------------------------
A model module can customize conversion from the database's string
representation by exporting a ``fromDbHook`` overload:
.. code-block:: Nim
import std/strutils
type Money* = object
cents*: int64
proc fromDbHook*(_: typedesc[Money], value: string): Money =
Money(cents: value.parseBiggestInt)
The overload must take ``typedesc[TargetType]`` and ``string``, and return
``TargetType``. It must be visible where Fiber ORM's generated query code is
declared, so export it when the type lives in another module.
Fiber ORM uses a custom mapping before its built-in conversion for a value.
``Option`` and ``seq`` remain structural: an absent optional value does not
call the hook, while present optional values and individual sequence values
do. Exceptions raised by the hook propagate to the caller.
.. [#f1] Note that this implies that all `NULL`-able fields should be typed .. [#f1] Note that this implies that all `NULL`-able fields should be typed
as optional using `Option[fieldType]`. Conversely, any fields with as optional using `Option[fieldType]`. Conversely, any fields with
non-optional types should also be constrained to be `NOT NULL` in non-optional types should also be constrained to be `NOT NULL` in
@@ -257,8 +344,10 @@ Many of the Fiber ORM macros expect a database object type to be passed.
In the example above the `pool.DbConnPool`_ object is used as database In the example above the `pool.DbConnPool`_ object is used as database
object type (aliased as `TodoDB`). This is the intended usage pattern, but object type (aliased as `TodoDB`). This is the intended usage pattern, but
anything can be passed as the database object type so long as there is a anything can be passed as the database object type so long as there is a
defined `withConn` template that provides an injected `conn: DbConn` object defined `withConnection` template that provides an injected `conn: DbConn` object
to the provided statement body. to the provided statement body.
The generated connection-flavor procedures are intended to work directly
with that `conn` value.
For example, a valid database object implementation that opens a new For example, a valid database object implementation that opens a new
connection for every request might look like this: connection for every request might look like this:
@@ -269,7 +358,7 @@ connection for every request might look like this:
type TodoDB* = object type TodoDB* = object
connString: string connString: string
template withConn*(db: TodoDB, stmt: untyped): untyped = template withConnection*(db: TodoDB, stmt: untyped): untyped =
let conn {.inject.} = open("", "", "", db.connString) let conn {.inject.} = open("", "", "", db.connString)
try: stmt try: stmt
finally: close(conn) finally: close(conn)
+12 -1
View File
@@ -1,6 +1,6 @@
# Package # Package
version = "4.0.0" version = "4.4.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "Lightweight Postgres ORM for Nim." description = "Lightweight Postgres ORM for Nim."
license = "GPL-3.0" license = "GPL-3.0"
@@ -12,3 +12,14 @@ srcDir = "src"
requires @["nim >= 1.4.0", "uuids"] requires @["nim >= 1.4.0", "uuids"]
requires "namespaced_logging >= 2.0.2" requires "namespaced_logging >= 2.0.2"
# Tasks
task unittest, "Runs the unit test suite.":
exec "nim c -r --path:src tests/test_datetime"
exec "nim c -r --path:src tests/test_distinct"
exec "nim c -r --path:src tests/test_custom_mapping"
task integrationtest, "Runs the PostgreSQL integration test suite.":
exec "nim c -r --path:src tests/test_datetime_postgres"
+330 -60
View File
@@ -100,39 +100,89 @@
## ##
## generateLookup(TodoDB, TimeEntry, @["todoItemId"]) ## generateLookup(TodoDB, TimeEntry, @["todoItemId"])
## ##
## This will generate the following procedures: ## This will generate procedures like the following in two flavors:
##
## * a `dbType` flavor that acquires a connection via `withConnection`
## * a connection flavor that operates directly on an existing
## `conn: D` where `D: DbConnType`
## ##
## .. code-block:: Nim ## .. code-block:: Nim
## proc getTodoItem*(db: TodoDB, id: UUID): TodoItem; ## proc getTodoItem*(db: TodoDB, id: UUID): TodoItem;
## ## proc getTodoItem*[D: DbConnType](conn: D, id: UUID): TodoItem;
## proc getTodoItemForUpdate*(conn: db_postgres.DbConn, id: UUID): TodoItem;
## proc tryGetTodoItem*(db: TodoDB, id: UUID): Option[TodoItem];
## proc tryGetTodoItem*[D: DbConnType](conn: D, id: UUID): Option[TodoItem];
## proc getTodoItemIfItExists*(db: TodoDB, id: UUID): Option[TodoItem];
## proc getTodoItemIfItExists*[D: DbConnType](
## conn: D, id: UUID): Option[TodoItem];
## proc createTodoItem*(db: TodoDB, rec: TodoItem): TodoItem; ## proc createTodoItem*(db: TodoDB, rec: TodoItem): TodoItem;
## proc createTodoItem*[D: DbConnType](conn: D, rec: TodoItem): TodoItem;
## proc updateTodoItem*(db: TodoDB, rec: TodoItem): bool; ## proc updateTodoItem*(db: TodoDB, rec: TodoItem): bool;
## proc createOrUpdateTodoItem*(db: TodoDB, rec: TodoItem): bool; ## proc updateTodoItem*[D: DbConnType](conn: D, rec: TodoItem): bool;
## proc createOrUpdateTodoItem*(db: TodoDB, rec: TodoItem): TodoItem;
## proc createOrUpdateTodoItem*[D: DbConnType](
## conn: D, rec: TodoItem): TodoItem;
## proc deleteTodoItem*(db: TodoDB, rec: TodoItem): bool; ## proc deleteTodoItem*(db: TodoDB, rec: TodoItem): bool;
## proc deleteTodoItem*[D: DbConnType](conn: D, rec: TodoItem): bool;
## proc deleteTodoItem*(db: TodoDB, id: UUID): bool; ## proc deleteTodoItem*(db: TodoDB, id: UUID): bool;
## proc deleteTodoItem*[D: DbConnType](conn: D, id: UUID): bool;
## ##
## proc getAllTodoItems*(db: TodoDB, ## proc getAllTodoItems*(db: TodoDB,
## pagination = none[PaginationParams]()): seq[TodoItem]; ## pagination = none[PaginationParams]()): PagedRecords[TodoItem];
## proc getAllTodoItems*[D: DbConnType](conn: D,
## pagination = none[PaginationParams]()): PagedRecords[TodoItem];
## ##
## proc findTodoItemsWhere*(db: TodoDB, whereClause: string, ## proc findTodoItemsWhere*(db: TodoDB, whereClause: string,
## values: varargs[string, dbFormat], pagination = none[PaginationParams]() ## values: varargs[string, dbFormat], pagination = none[PaginationParams]()
## ): seq[TodoItem]; ## ): PagedRecords[TodoItem];
## proc findTodoItemsWhere*[D: DbConnType](conn: D, whereClause: string,
## values: varargs[string, dbFormat], pagination = none[PaginationParams]()
## ): PagedRecords[TodoItem];
## ##
## proc getTimeEntry*(db: TodoDB, id: UUID): TimeEntry; ## proc getTimeEntry*(db: TodoDB, id: UUID): TimeEntry;
## proc getTimeEntry*[D: DbConnType](conn: D, id: UUID): TimeEntry;
## proc createTimeEntry*(db: TodoDB, rec: TimeEntry): TimeEntry; ## proc createTimeEntry*(db: TodoDB, rec: TimeEntry): TimeEntry;
## proc createTimeEntry*[D: DbConnType](conn: D, rec: TimeEntry): TimeEntry;
## proc updateTimeEntry*(db: TodoDB, rec: TimeEntry): bool; ## proc updateTimeEntry*(db: TodoDB, rec: TimeEntry): bool;
## proc updateTimeEntry*[D: DbConnType](conn: D, rec: TimeEntry): bool;
## proc deleteTimeEntry*(db: TodoDB, rec: TimeEntry): bool; ## proc deleteTimeEntry*(db: TodoDB, rec: TimeEntry): bool;
## proc deleteTimeEntry*[D: DbConnType](conn: D, rec: TimeEntry): bool;
## proc deleteTimeEntry*(db: TodoDB, id: UUID): bool; ## proc deleteTimeEntry*(db: TodoDB, id: UUID): bool;
## proc deleteTimeEntry*[D: DbConnType](conn: D, id: UUID): bool;
## ##
## proc getAllTimeEntries*(db: TodoDB, ## proc getAllTimeEntries*(db: TodoDB,
## pagination = none[PaginationParams]()): seq[TimeEntry]; ## pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
## proc getAllTimeEntries*[D: DbConnType](conn: D,
## pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
## ##
## proc findTimeEntriesWhere*(db: TodoDB, whereClause: string, ## proc findTimeEntriesWhere*(db: TodoDB, whereClause: string,
## values: varargs[string, dbFormat], pagination = none[PaginationParams]() ## values: varargs[string, dbFormat], pagination = none[PaginationParams]()
## ): seq[TimeEntry]; ## ): PagedRecords[TimeEntry];
## proc findTimeEntriesWhere*[D: DbConnType](conn: D, whereClause: string,
## values: varargs[string, dbFormat], pagination = none[PaginationParams]()
## ): PagedRecords[TimeEntry];
## ##
## proc findTimeEntriesByTodoItemId(db: TodoDB, todoItemId: UUID, ## proc findTimeEntriesByTodoItemId*(db: TodoDB, todoItemId: UUID,
## pagination = none[PaginationParams]()): seq[TimeEntry]; ## pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
## proc findTimeEntriesByTodoItemId*[D: DbConnType](
## conn: D, todoItemId: UUID,
## pagination = none[PaginationParams]()): PagedRecords[TimeEntry];
##
## Use the `dbType` flavor when the caller does not already have a connection.
## Use the connection flavor inside `withConnection` or `inTransaction`.
## The generated `get<RecordName>ForUpdate` helper is PostgreSQL-specific and
## is only available for direct PostgreSQL connections.
##
## Warning: do not call the `dbType` flavor from inside `inTransaction`.
## Those overloads call `withConnection` and may acquire a different
## connection, causing the statements to execute outside the active
## transaction.
##
## .. code-block:: Nim
## db.inTransaction:
## var item = conn.getTodoItemForUpdate(todoId)
## item.priority += 1
## discard conn.updateTodoItem(item)
## ##
## Object-Relational Modeling ## Object-Relational Modeling
## ========================== ## ==========================
@@ -140,11 +190,11 @@
## Model Class ## Model Class
## ----------- ## -----------
## ##
## Fiber ORM uses simple Nim `object`s and `ref object`s as model classes. ## Fiber ORM uses simple Nim objects and ref objects as model classes.
## Fiber ORM expects there to be one table for each model class. ## Fiber ORM expects there to be one table for each model class.
## ##
## Name Mapping ## Name Mapping
## ```````````` ## ^^^^^^^^^^^^
## Fiber ORM uses `snake_case` for database identifiers (column names, table ## Fiber ORM uses `snake_case` for database identifiers (column names, table
## names, etc.) and `camelCase` for Nim identifiers. We automatically convert ## names, etc.) and `camelCase` for Nim identifiers. We automatically convert
## model names to and from table names (`TodoItem` <-> `todo_items`), as well ## model names to and from table names (`TodoItem` <-> `todo_items`), as well
@@ -175,7 +225,7 @@
## .. _util: fiber_orm/util.html ## .. _util: fiber_orm/util.html
## ##
## ID Field ## ID Field
## ```````` ## ^^^^^^^^
## ##
## Fiber ORM expects every model class to have a field named `id`, with a ## Fiber ORM expects every model class to have a field named `id`, with a
## corresponding `id` column in the model table. This field must be either a ## corresponding `id` column in the model table. This field must be either a
@@ -238,6 +288,35 @@
## `JsonNode`_ `jsonb`_ ## `JsonNode`_ `jsonb`_
## =============== ====================== ================= ## =============== ====================== =================
## ##
## Distinct types backed by a supported type are parsed through their backing
## type and then converted to the distinct type. This also applies when the
## distinct type is nested in an `Option`_ or `seq`.
##
## Custom Database Mappings
## ------------------------
##
## A model module can customize conversion from the database's string
## representation by exporting a ``fromDbHook`` overload:
##
## .. code-block:: Nim
##
## import std/strutils
##
## type Money* = object
## cents*: int64
##
## proc fromDbHook*(_: typedesc[Money], value: string): Money =
## Money(cents: value.parseBiggestInt)
##
## The overload must take ``typedesc[TargetType]`` and ``string``, and return
## ``TargetType``. It must be visible where Fiber ORM's generated query code is
## declared, so export it when the type lives in another module.
##
## Fiber ORM uses a custom mapping before its built-in conversion for a value.
## ``Option`` and ``seq`` remain structural: an absent optional value does not
## call the hook, while present optional values and individual sequence values
## do. Exceptions raised by the hook propagate to the caller.
##
## .. [#f1] Note that this implies that all `NULL`-able fields should be typed ## .. [#f1] Note that this implies that all `NULL`-able fields should be typed
## as optional using `Option[fieldType]`. Conversely, any fields with ## as optional using `Option[fieldType]`. Conversely, any fields with
## non-optional types should also be constrained to be `NOT NULL` in ## non-optional types should also be constrained to be `NOT NULL` in
@@ -266,6 +345,8 @@
## anything can be passed as the database object type so long as there is a ## anything can be passed as the database object type so long as there is a
## defined `withConnection` template that provides a `conn: DbConn` object ## defined `withConnection` template that provides a `conn: DbConn` object
## to the provided statement body. ## to the provided statement body.
## The generated connection-flavor procedures are intended to work directly
## with that `conn` value.
## ##
## For example, a valid database object implementation that opens a new ## For example, a valid database object implementation that opens a new
## connection for every request might look like this: ## connection for every request might look like this:
@@ -285,7 +366,7 @@
## .. _pool.DbConnPool: fiber_orm/pool.html#DbConnPool ## .. _pool.DbConnPool: fiber_orm/pool.html#DbConnPool
## ##
import std/[json, macros, options, sequtils, strutils] import std/[json, macros, options, sequtils, strutils]
import db_connector/db_common import db_connector/[db_common, db_postgres]
import uuids import uuids
from std/unicode import capitalize from std/unicode import capitalize
@@ -334,7 +415,6 @@ proc createRecord*[D: DbConnType, T](db: D, rec: T): T =
" RETURNING " & columnNamesForModel(rec).join(",") " RETURNING " & columnNamesForModel(rec).join(",")
logQuery("createRecord", sqlStmt) logQuery("createRecord", sqlStmt)
debug(getLogger("query"), %*{ "values": mc.values })
let newRow = db.getRow(sql(sqlStmt), mc.values) let newRow = db.getRow(sql(sqlStmt), mc.values)
@@ -403,6 +483,36 @@ template getRecord*[D: DbConnType](db: D, modelType: type, id: typed): untyped =
rowToModel(modelType, row) rowToModel(modelType, row)
template getRecordForUpdate*(db: db_postgres.DbConn, modelType: type, id: typed): untyped =
## Fetch a record by id and lock it with `FOR UPDATE`.
##
## This is PostgreSQL-specific and should only be used inside a transaction.
let sqlStmt =
"SELECT " & columnNamesForModel(modelType).join(",") &
" FROM " & tableName(modelType) &
" WHERE id = ? FOR UPDATE"
logQuery("getRecordForUpdate", sqlStmt, [("id", $id)])
let row = db.getRow(sql(sqlStmt), @[$id])
if allIt(row, it.len == 0):
raise newException(NotFoundError, "no " & modelName(modelType) & " record for id " & $id)
rowToModel(modelType, row)
template tryGetRecord*[D: DbConnType](db: D, modelType: type, id: typed): untyped =
## Fetch a record by id.
let sqlStmt =
"SELECT " & columnNamesForModel(modelType).join(",") &
" FROM " & tableName(modelType) &
" WHERE id = ?"
logQuery("tryGetRecord", sqlStmt, [("id", $id)])
let row = db.getRow(sql(sqlStmt), @[$id])
if allIt(row, it.len == 0): none[modelType]()
else: some(rowToModel(modelType, row))
template findRecordsWhere*[D: DbConnType]( template findRecordsWhere*[D: DbConnType](
db: D, db: D,
modelType: type, modelType: type,
@@ -575,23 +685,50 @@ template findViaJoinTable*[D: DbConnType](
macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untyped = macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untyped =
## Generate all standard access procedures for the given model types. For a ## Generate all standard access procedures for the given model types. For a
## `model class`_ named `TodoItem`, this will generate the following ## `model class`_ named `TodoItem`, this will generate `dbType` and
## procedures: ## connection overloads for procedures like the following:
## ##
## .. code-block:: Nim ## .. code-block:: Nim
## proc getTodoItem*(db: TodoDB, id: idType): TodoItem; ## proc getTodoItem*(db: TodoDB, id: idType): TodoItem;
## proc getAllTodoItems*(db: TodoDB): TodoItem; ## proc getTodoItem*[D: DbConnType](conn: D, id: idType): TodoItem;
## proc getTodoItemForUpdate*(conn: db_postgres.DbConn, id: idType): TodoItem;
## proc tryGetTodoItem*(db: TodoDB, id: idType): Option[TodoItem];
## proc tryGetTodoItem*[D: DbConnType](conn: D, id: idType): Option[TodoItem];
## proc getTodoItemIfItExists*(db: TodoDB, id: idType): Option[TodoItem];
## proc getTodoItemIfItExists*[D: DbConnType](
## conn: D, id: idType): Option[TodoItem];
## proc getAllTodoItems*(db: TodoDB): PagedRecords[TodoItem];
## proc getAllTodoItems*[D: DbConnType](conn: D): PagedRecords[TodoItem];
## proc createTodoItem*(db: TodoDB, rec: TodoItem): TodoItem; ## proc createTodoItem*(db: TodoDB, rec: TodoItem): TodoItem;
## proc createTodoItem*[D: DbConnType](conn: D, rec: TodoItem): TodoItem;
## proc deleteTodoItem*(db: TodoDB, rec: TodoItem): bool; ## proc deleteTodoItem*(db: TodoDB, rec: TodoItem): bool;
## proc deleteTodoItem*[D: DbConnType](conn: D, rec: TodoItem): bool;
## proc deleteTodoItem*(db: TodoDB, id: idType): bool; ## proc deleteTodoItem*(db: TodoDB, id: idType): bool;
## proc deleteTodoItem*[D: DbConnType](conn: D, id: idType): bool;
## proc updateTodoItem*(db: TodoDB, rec: TodoItem): bool; ## proc updateTodoItem*(db: TodoDB, rec: TodoItem): bool;
## proc createOrUpdateTodoItem*(db: TodoDB, rec: TodoItem): bool; ## proc updateTodoItem*[D: DbConnType](conn: D, rec: TodoItem): bool;
## proc createOrUpdateTodoItem*(db: TodoDB, rec: TodoItem): TodoItem;
## proc createOrUpdateTodoItem*[D: DbConnType](
## conn: D, rec: TodoItem): TodoItem;
## ##
## proc findTodoItemsWhere*( ## proc findTodoItemsWhere*(
## db: TodoDB, whereClause: string, values: varargs[string]): TodoItem; ## db: TodoDB,
## whereClause: string,
## values: varargs[string, dbFormat],
## pagination = none[PaginationParams]()): PagedRecords[TodoItem];
## proc findTodoItemsWhere*[D: DbConnType](
## conn: D,
## whereClause: string,
## values: varargs[string, dbFormat],
## pagination = none[PaginationParams]()): PagedRecords[TodoItem];
## ##
## `dbType` is expected to be some type that has a defined `withConnection` ## `dbType` is expected to be some type that has a defined `withConnection`
## procedure (see `Database Object`_ for details). ## procedure (see `Database Object`_ for details).
## The `dbType` overloads are convenience wrappers around `withConnection`.
## Inside `inTransaction`, prefer the overloads that take `conn: D` where
## `D: DbConnType` so all operations use the transaction connection.
## The generated `get<RecordName>ForUpdate` helper is PostgreSQL-specific and
## is only available for direct PostgreSQL connections.
## ##
## .. _Database Object: #database-object ## .. _Database Object: #database-object
result = newStmtList() result = newStmtList()
@@ -603,6 +740,8 @@ macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untype
let modelName = $(t.getType[1]) let modelName = $(t.getType[1])
let getName = ident("get" & modelName) let getName = ident("get" & modelName)
let getForUpdateName = ident("get" & modelName & "ForUpdate")
let tryGetName = ident("tryGet" & modelName)
let getIfExistsName = ident("get" & modelName & "IfItExists") let getIfExistsName = ident("get" & modelName & "IfItExists")
let getAllName = ident("getAll" & pluralize(modelName)) let getAllName = ident("getAll" & pluralize(modelName))
let findWhereName = ident("find" & pluralize(modelName) & "Where") let findWhereName = ident("find" & pluralize(modelName) & "Where")
@@ -615,14 +754,35 @@ macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untype
proc `getName`*(db: `dbType`, id: `idType`): `t` = proc `getName`*(db: `dbType`, id: `idType`): `t` =
db.withConnection conn: result = getRecord(conn, `t`, id) db.withConnection conn: result = getRecord(conn, `t`, id)
proc `getName`*[D: DbConnType](conn: D, id: `idType`): `t` =
result = getRecord(conn, `t`, id)
proc `getForUpdateName`*(conn: db_postgres.DbConn, id: `idType`): `t` =
result = getRecordForUpdate(conn, `t`, id)
proc `tryGetName`*(db: `dbType`, id: `idType`): Option[`t`] =
db.withConnection conn: result = tryGetRecord(conn, `t`, id)
proc `tryGetName`*[D: DbConnType](conn: D, id: `idType`): Option[`t`] =
result = tryGetRecord(conn, `t`, id)
proc `getIfExistsName`*(db: `dbType`, id: `idType`): Option[`t`] = proc `getIfExistsName`*(db: `dbType`, id: `idType`): Option[`t`] =
db.withConnection conn: db.withConnection conn:
try: result = some(getRecord(conn, `t`, id)) try: result = some(getRecord(conn, `t`, id))
except NotFoundError: result = none[`t`]() except NotFoundError: result = none[`t`]()
proc `getIfExistsName`*[D: DbConnType](conn: D, id: `idType`): Option[`t`] =
try: result = some(getRecord(conn, `t`, id))
except NotFoundError: result = none[`t`]()
proc `getAllName`*(db: `dbType`, pagination = none[PaginationParams]()): PagedRecords[`t`] = proc `getAllName`*(db: `dbType`, pagination = none[PaginationParams]()): PagedRecords[`t`] =
db.withConnection conn: result = getAllRecords(conn, `t`, pagination) db.withConnection conn: result = getAllRecords(conn, `t`, pagination)
proc `getAllName`*[D: DbConnType](
conn: D,
pagination = none[PaginationParams]()): PagedRecords[`t`] =
result = getAllRecords(conn, `t`, pagination)
proc `findWhereName`*( proc `findWhereName`*(
db: `dbType`, db: `dbType`,
whereClause: string, whereClause: string,
@@ -631,21 +791,43 @@ macro generateProcsForModels*(dbType: type, modelTypes: openarray[type]): untype
db.withConnection conn: db.withConnection conn:
result = findRecordsWhere(conn, `t`, whereClause, values, pagination) result = findRecordsWhere(conn, `t`, whereClause, values, pagination)
proc `findWhereName`*[D: DbConnType](
conn: D,
whereClause: string,
values: varargs[string, dbFormat],
pagination = none[PaginationParams]()): PagedRecords[`t`] =
result = findRecordsWhere(conn, `t`, whereClause, values, pagination)
proc `createName`*(db: `dbType`, rec: `t`): `t` = proc `createName`*(db: `dbType`, rec: `t`): `t` =
db.withConnection conn: result = createRecord(conn, rec) db.withConnection conn: result = createRecord(conn, rec)
proc `createName`*[D: DbConnType](conn: D, rec: `t`): `t` =
result = createRecord(conn, rec)
proc `updateName`*(db: `dbType`, rec: `t`): bool = proc `updateName`*(db: `dbType`, rec: `t`): bool =
db.withConnection conn: result = updateRecord(conn, rec) db.withConnection conn: result = updateRecord(conn, rec)
proc `updateName`*[D: DbConnType](conn: D, rec: `t`): bool =
result = updateRecord(conn, rec)
proc `createOrUpdateName`*(db: `dbType`, rec: `t`): `t` = proc `createOrUpdateName`*(db: `dbType`, rec: `t`): `t` =
db.inTransaction: result = createOrUpdateRecord(conn, rec) db.inTransaction: result = createOrUpdateRecord(conn, rec)
proc `createOrUpdateName`*[D: DbConnType](conn: D, rec: `t`): `t` =
result = createOrUpdateRecord(conn, rec)
proc `deleteName`*(db: `dbType`, rec: `t`): bool = proc `deleteName`*(db: `dbType`, rec: `t`): bool =
db.withConnection conn: result = deleteRecord(conn, rec) db.withConnection conn: result = deleteRecord(conn, rec)
proc `deleteName`*[D: DbConnType](conn: D, rec: `t`): bool =
result = deleteRecord(conn, rec)
proc `deleteName`*(db: `dbType`, id: `idType`): bool = proc `deleteName`*(db: `dbType`, id: `idType`): bool =
db.withConnection conn: result = deleteRecord(conn, `t`, id) db.withConnection conn: result = deleteRecord(conn, `t`, id)
proc `deleteName`*[D: DbConnType](conn: D, id: `idType`): bool =
result = deleteRecord(conn, `t`, id)
macro generateLookup*(dbType: type, modelType: type, fields: seq[string]): untyped = macro generateLookup*(dbType: type, modelType: type, fields: seq[string]): untyped =
## Create a lookup procedure for a given set of field names. For example, ## Create a lookup procedure for a given set of field names. For example,
## given the TODO database demostrated above, ## given the TODO database demostrated above,
@@ -657,42 +839,49 @@ macro generateLookup*(dbType: type, modelType: type, fields: seq[string]): untyp
## ##
## .. code-block:: Nim ## .. code-block:: Nim
## proc findTodoItemsByOwnerAndPriority*(db: SampleDB, ## proc findTodoItemsByOwnerAndPriority*(db: SampleDB,
## owner: string, priority: int): seq[TodoItem] ## owner: string, priority: int,
## pagination = none[PaginationParams]()): PagedRecords[TodoItem]
## proc findTodoItemsByOwnerAndPriority*[D: DbConnType](conn: D,
## owner: string, priority: int,
## pagination = none[PaginationParams]()): PagedRecords[TodoItem]
##
## Use the `db` overload for standalone calls and the `conn` overload inside
## `withConnection` or `inTransaction`.
let fieldNames = fields[1].mapIt($it) let fieldNames = fields[1].mapIt($it)
let procName = ident("find" & pluralize($modelType.getType[1]) & "By" & fieldNames.mapIt(it.capitalize).join("And")) let procName = ident("find" & pluralize($modelType.getType[1]) & "By" & fieldNames.mapIt(it.capitalize).join("And"))
# Create proc skeleton
result = quote do:
proc `procName`*(db: `dbType`): PagedRecords[`modelType`] =
db.withConnection conn: result = findRecordsBy(conn, `modelType`)
var callParams = quote do: @[] var callParams = quote do: @[]
# Add dynamic parameters for the proc definition and inner proc call # Add dynamic parameters for the generated proc and inner proc call.
for n in fieldNames: for n in fieldNames:
let paramTuple = newNimNode(nnkPar) let paramTuple = newNimNode(nnkPar)
paramTuple.add(newColonExpr(ident("field"), newLit(identNameToDb(n)))) paramTuple.add(newColonExpr(ident("field"), newLit(identNameToDb(n))))
paramTuple.add(newColonExpr(ident("value"), ident(n))) paramTuple.add(newColonExpr(ident("value"), ident(n)))
# Add the parameter to the outer call (the generated proc)
# result[3] is ProcDef -> [3]: FormalParams
result[3].add(newIdentDefs(ident(n), ident("string")))
# Build up the AST for the inner procedure call
callParams[1].add(paramTuple) callParams[1].add(paramTuple)
# Add the optional pagination parameters to the generated proc definition let dbProcDefAST = quote do:
result[3].add(newIdentDefs( proc `procName`*(db: `dbType`): PagedRecords[`modelType`] =
db.withConnection conn:
result = findRecordsBy(conn, `modelType`, `callParams`, pagination)
let connProcDefAST = quote do:
proc `procName`*[D: DbConnType](conn: D): PagedRecords[`modelType`] =
result = findRecordsBy(conn, `modelType`, `callParams.copyNimTree`, pagination)
for n in fieldNames:
dbProcDefAST[3].add(newIdentDefs(ident(n), ident("string")))
connProcDefAST[3].add(newIdentDefs(ident(n), ident("string")))
dbProcDefAST[3].add(newIdentDefs(
ident("pagination"), newEmptyNode(), ident("pagination"), newEmptyNode(),
quote do: none[PaginationParams]())) quote do: none[PaginationParams]()))
# Add the call params to the inner procedure call connProcDefAST[3].add(newIdentDefs(
# result[6][0][1][0][1] is ident("pagination"), newEmptyNode(),
# ProcDef -> [6]: StmtList (body) -> [0]: Command -> quote do: none[PaginationParams]()))
# [2]: StmtList (withConnection body) -> [0]: Asgn (result =) ->
# [1]: Call (inner findRecords invocation) result = newStmtList()
result[6][0][2][0][1].add(callParams) result.add dbProcDefAST
result[6][0][2][0][1].add(quote do: pagination) result.add connProcDefAST
macro generateProcsForFieldLookups*(dbType: type, modelsAndFields: openarray[tuple[t: type, fields: seq[string]]]): untyped = macro generateProcsForFieldLookups*(dbType: type, modelsAndFields: openarray[tuple[t: type, fields: seq[string]]]): untyped =
result = newStmtList() result = newStmtList()
@@ -702,32 +891,38 @@ macro generateProcsForFieldLookups*(dbType: type, modelsAndFields: openarray[tup
let fieldNames = i[1][1][1].mapIt($it) let fieldNames = i[1][1][1].mapIt($it)
let procName = ident("find" & $modelType & "sBy" & fieldNames.mapIt(it.capitalize).join("And")) let procName = ident("find" & $modelType & "sBy" & fieldNames.mapIt(it.capitalize).join("And"))
# Create proc skeleton
let procDefAST = quote do:
proc `procName`*(db: `dbType`): PagedRecords[`modelType`] =
db.withConnection conn: result = findRecordsBy(conn, `modelType`)
var callParams = quote do: @[] var callParams = quote do: @[]
# Add dynamic parameters for the proc definition and inner proc call # Add dynamic parameters for the generated proc and inner proc call.
for n in fieldNames: for n in fieldNames:
let paramTuple = newNimNode(nnkPar) let paramTuple = newNimNode(nnkPar)
paramTuple.add(newColonExpr(ident("field"), newLit(identNameToDb(n)))) paramTuple.add(newColonExpr(ident("field"), newLit(identNameToDb(n))))
paramTuple.add(newColonExpr(ident("value"), ident(n))) paramTuple.add(newColonExpr(ident("value"), ident(n)))
procDefAST[3].add(newIdentDefs(ident(n), ident("string")))
callParams[1].add(paramTuple) callParams[1].add(paramTuple)
# Add the optional pagination parameters to the generated proc definition let dbProcDefAST = quote do:
procDefAST[3].add(newIdentDefs( proc `procName`*(db: `dbType`): PagedRecords[`modelType`] =
db.withConnection conn:
result = findRecordsBy(conn, `modelType`, `callParams`, pagination)
let connProcDefAST = quote do:
proc `procName`*[D: DbConnType](conn: D): PagedRecords[`modelType`] =
result = findRecordsBy(conn, `modelType`, `callParams.copyNimTree`, pagination)
for n in fieldNames:
dbProcDefAST[3].add(newIdentDefs(ident(n), ident("string")))
connProcDefAST[3].add(newIdentDefs(ident(n), ident("string")))
dbProcDefAST[3].add(newIdentDefs(
ident("pagination"), newEmptyNode(), ident("pagination"), newEmptyNode(),
quote do: none[PaginationParams]())) quote do: none[PaginationParams]()))
procDefAST[6][0][1][0][1].add(callParams) connProcDefAST[3].add(newIdentDefs(
procDefAST[6][0][1][0][1].add(quote do: pagination) ident("pagination"), newEmptyNode(),
quote do: none[PaginationParams]()))
result.add procDefAST result.add dbProcDefAST
result.add connProcDefAST
macro generateJoinTableProcs*( macro generateJoinTableProcs*(
dbType, model1Type, model2Type: type, dbType, model1Type, model2Type: type,
@@ -739,11 +934,19 @@ macro generateJoinTableProcs*(
## This macro will generate the following procedures: ## This macro will generate the following procedures:
## ##
## .. code-block:: Nim ## .. code-block:: Nim
## proc findTodoItemsByTimeEntry*(db: SampleDB, timeEntry: TimeEntry): seq[TodoItem] ## proc getTodoItemsByTimeEntry*(db: SampleDB, timeEntry: TimeEntry,
## proc findTimeEntriesByTodoItem*(db: SampleDB, todoItem: TodoItem): seq[TimeEntry] ## pagination = none[PaginationParams]()): PagedRecords[TodoItem]
## proc getTodoItemsByTimeEntry*[D: DbConnType](conn: D, timeEntry: TimeEntry,
## pagination = none[PaginationParams]()): PagedRecords[TodoItem]
## proc getTimeEntriesByTodoItem*(db: SampleDB, todoItem: TodoItem,
## pagination = none[PaginationParams]()): PagedRecords[TimeEntry]
## proc getTimeEntriesByTodoItem*[D: DbConnType](conn: D, todoItem: TodoItem,
## pagination = none[PaginationParams]()): PagedRecords[TimeEntry]
## ##
## `dbType` is expected to be some type that has a defined `withConnection` ## `dbType` is expected to be some type that has a defined `withConnection`
## procedure (see `Database Object`_ for details). ## procedure (see `Database Object`_ for details).
## As with the other generated helpers, use the connection overloads when
## you are already inside `withConnection` or `inTransaction`.
## ##
## .. _Database Object: #database-object ## .. _Database Object: #database-object
result = newStmtList() result = newStmtList()
@@ -775,6 +978,18 @@ macro generateJoinTableProcs*(
id, id,
pagination) pagination)
proc `getModel1Name`*[D: DbConnType](
conn: D,
id: `id2Type`,
pagination = none[PaginationParams]()): PagedRecords[`model1Type`] =
result = findViaJoinTable(
conn,
`joinTableNameNode`,
`model1Type`,
`model2Type`,
id,
pagination)
proc `getModel1Name`*( proc `getModel1Name`*(
db: `dbType`, db: `dbType`,
rec: `model2Type`, rec: `model2Type`,
@@ -787,10 +1002,21 @@ macro generateJoinTableProcs*(
rec, rec,
pagination) pagination)
proc `getModel1Name`*[D: DbConnType](
conn: D,
rec: `model2Type`,
pagination = none[PaginationParams]()): PagedRecords[`model1Type`] =
result = findViaJoinTable(
conn,
`joinTableNameNode`,
`model1Type`,
rec,
pagination)
proc `getModel2Name`*( proc `getModel2Name`*(
db: `dbType`, db: `dbType`,
id: `id1Type`, id: `id1Type`,
pagination = none[PaginationParams]()): Pagedrecords[`model2Type`] = pagination = none[PaginationParams]()): PagedRecords[`model2Type`] =
db.withConnection conn: db.withConnection conn:
result = findViaJoinTable( result = findViaJoinTable(
conn, conn,
@@ -800,10 +1026,22 @@ macro generateJoinTableProcs*(
id, id,
pagination) pagination)
proc `getModel2Name`*[D: DbConnType](
conn: D,
id: `id1Type`,
pagination = none[PaginationParams]()): PagedRecords[`model2Type`] =
result = findViaJoinTable(
conn,
`joinTableNameNode`,
`model2Type`,
`model1Type`,
id,
pagination)
proc `getModel2Name`*( proc `getModel2Name`*(
db: `dbType`, db: `dbType`,
rec: `model1Type`, rec: `model1Type`,
pagination = none[PaginationParams]()): Pagedrecords[`model2Type`] = pagination = none[PaginationParams]()): PagedRecords[`model2Type`] =
db.withConnection conn: db.withConnection conn:
result = findViaJoinTable( result = findViaJoinTable(
conn, conn,
@@ -812,6 +1050,17 @@ macro generateJoinTableProcs*(
rec, rec,
pagination) pagination)
proc `getModel2Name`*[D: DbConnType](
conn: D,
rec: `model1Type`,
pagination = none[PaginationParams]()): PagedRecords[`model2Type`] =
result = findViaJoinTable(
conn,
`joinTableNameNode`,
`model2Type`,
rec,
pagination)
proc associate*( proc associate*(
db: `dbType`, db: `dbType`,
rec1: `model1Type`, rec1: `model1Type`,
@@ -819,6 +1068,12 @@ macro generateJoinTableProcs*(
db.withConnection conn: db.withConnection conn:
associate(conn, `joinTableNameNode`, rec1, rec2) associate(conn, `joinTableNameNode`, rec1, rec2)
proc associate*[D: DbConnType](
conn: D,
rec1: `model1Type`,
rec2: `model2Type`): void =
associate(conn, `joinTableNameNode`, rec1, rec2)
proc associate*( proc associate*(
db: `dbType`, db: `dbType`,
rec2: `model2Type`, rec2: `model2Type`,
@@ -826,7 +1081,22 @@ macro generateJoinTableProcs*(
db.withConnection conn: db.withConnection conn:
associate(conn, `joinTableNameNode`, rec1, rec2) associate(conn, `joinTableNameNode`, rec1, rec2)
proc associate*[D: DbConnType](
conn: D,
rec2: `model2Type`,
rec1: `model1Type`): void =
associate(conn, `joinTableNameNode`, rec1, rec2)
template inTransaction*(db, body: untyped) = template inTransaction*(db, body: untyped) =
## Execute `body` inside a transaction using a single connection bound to
## `conn`.
##
## When calling generated Fiber ORM helpers inside this block, use the
## overloads that take `conn: D` where `D: DbConnType`. Do not call the
## overloads that take the outer database object, because those call
## `withConnection` again and may acquire a different connection.
## If you need to lock a PostgreSQL row before modifying it, use the
## generated `get<RecordName>ForUpdate` helper.
db.withConnection conn: db.withConnection conn:
conn.exec(sql"BEGIN TRANSACTION") conn.exec(sql"BEGIN TRANSACTION")
try: try:
+103 -55
View File
@@ -9,6 +9,8 @@ import uuids
import std/nre except toSeq import std/nre except toSeq
type type
NoDbHook = object
PaginationParams* = object PaginationParams* = object
pageSize*: int pageSize*: int
offset*: int offset*: int
@@ -23,15 +25,20 @@ type
placeholders*: seq[string] placeholders*: seq[string]
values*: seq[string] values*: seq[string]
proc fromDbHook[T](_: typedesc[T], _: string): NoDbHook =
## Default overload used to detect that a consumer has not supplied a
## database mapping for `T`.
NoDbHook()
const ISO_8601_FORMATS = @[ const ISO_8601_FORMATS = @[
"yyyy-MM-dd'T'HH:mm:ssz", "yyyy-MM-dd'T'HH:mm:ssz",
"yyyy-MM-dd'T'HH:mm:sszzz", "yyyy-MM-dd'T'HH:mm:sszzz",
"yyyy-MM-dd'T'HH:mm:ss'.'ffffffzzz",
"yyyy-MM-dd'T'HH:mm:ss'.'fffzzz", "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz",
"yyyy-MM-dd'T'HH:mm:ss'.'ffffzzz",
"yyyy-MM-dd HH:mm:ssz", "yyyy-MM-dd HH:mm:ssz",
"yyyy-MM-dd HH:mm:sszzz", "yyyy-MM-dd HH:mm:sszzz",
"yyyy-MM-dd HH:mm:ss'.'fffzzz", "yyyy-MM-dd HH:mm:ss'.'ffffffzzz",
"yyyy-MM-dd HH:mm:ss'.'ffffzzz" "yyyy-MM-dd HH:mm:ss'.'fffzzz"
] ]
proc parseIso8601(val: string): DateTime = proc parseIso8601(val: string): DateTime =
@@ -121,32 +128,41 @@ proc parsePGDatetime*(val: string): DateTime =
const PG_TIMESTAMP_FORMATS = [ const PG_TIMESTAMP_FORMATS = [
"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ssz",
"yyyy-MM-dd'T'HH:mm:ssz",
"yyyy-MM-dd HH:mm:sszz", "yyyy-MM-dd HH:mm:sszz",
"yyyy-MM-dd'T'HH:mm:sszz", "yyyy-MM-dd'T'HH:mm:sszz",
"yyyy-MM-dd HH:mm:sszzz",
"yyyy-MM-dd'T'HH:mm:sszzz",
"yyyy-MM-dd HH:mm:ss'.'fff", "yyyy-MM-dd HH:mm:ss'.'fff",
"yyyy-MM-dd'T'HH:mm:ss'.'fff", "yyyy-MM-dd'T'HH:mm:ss'.'fff",
"yyyy-MM-dd HH:mm:ss'.'fffzz", "yyyy-MM-dd HH:mm:ss'.'fffzz",
"yyyy-MM-dd'T'HH:mm:ss'.'fffzz", "yyyy-MM-dd'T'HH:mm:ss'.'fffzz",
"yyyy-MM-dd HH:mm:ss'.'fffzzz", "yyyy-MM-dd HH:mm:ss'.'fffzzz",
"yyyy-MM-dd'T'HH:mm:ss'.'fffzzz", "yyyy-MM-dd'T'HH:mm:ss'.'fffzzz",
"yyyy-MM-dd HH:mm:ss'.'ffffff",
"yyyy-MM-dd'T'HH:mm:ss'.'ffffff",
"yyyy-MM-dd HH:mm:ss'.'ffffffz",
"yyyy-MM-dd'T'HH:mm:ss'.'ffffffz",
"yyyy-MM-dd HH:mm:ss'.'ffffffzz",
"yyyy-MM-dd'T'HH:mm:ss'.'ffffffzz",
"yyyy-MM-dd HH:mm:ss'.'ffffffzzz",
"yyyy-MM-dd'T'HH:mm:ss'.'ffffffzzz",
] ]
var correctedVal = val; var correctedVal = val;
# The Nim `times#format` function only recognizes 3-digit millisecond values # Nim's time parser requires a fixed number of fractional digits for each
# but PostgreSQL will sometimes send 1-2 digits, truncating any trailing 0's, # format pattern. PostgreSQL emits between one and six digits, omitting
# or sometimes provide more than three digits of preceision in the millisecond value leading # trailing zeroes. Normalize the fraction to six digits so parsing retains
# to values like `2020-01-01 16:42.3+00` or `2025-01-06 00:56:00.9007+00`. # PostgreSQL's full microsecond precision.
# This cannot currently be parsed by the standard times format as it expects let PG_PARTIAL_FORMAT_REGEX = re"(\d{4}-\d{2}-\d{2}( |T)\d{2}:\d{2}:\d{2}\.)(\d+)(\S+)?"
# exactly three digits for millisecond values. So we have to detect this and
# coerce the millisecond value to exactly 3 digits.
let PG_PARTIAL_FORMAT_REGEX = re"(\d{4}-\d{2}-\d{2}( |'T')\d{2}:\d{2}:\d{2}\.)(\d+)(\S+)?"
let match = val.match(PG_PARTIAL_FORMAT_REGEX) let match = val.match(PG_PARTIAL_FORMAT_REGEX)
if match.isSome: if match.isSome:
let c = match.get.captures let c = match.get.captures
if c.toSeq.len == 2: correctedVal = c[0] & alignLeft(c[2], 3, '0')[0..2] correctedVal = c[0] & alignLeft(c[2], 6, '0')[0..5]
else: correctedVal = c[0] & alignLeft(c[2], 3, '0')[0..2] & c[3] if 3 in c: correctedVal &= c[3]
var errStr = "" var errStr = ""
@@ -216,60 +232,53 @@ proc parseDbArray*(val: string): seq[string] =
if not (parseState == inQuote) and curStr.len > 0: if not (parseState == inQuote) and curStr.len > 0:
result.add(curStr) result.add(curStr)
func createParseStmt*(t, value: NimNode): NimNode = func createParseStmt*(t, value: NimNode): NimNode
## Utility method to create the Nim code 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.
template parseDbValueWithHook(
targetType,
value,
automaticParse: untyped): untyped =
block:
var target: targetType
mixin fromDbHook
when typeof(fromDbHook(type(target), value)) is targetType:
fromDbHook(type(target), value)
else:
automaticParse
func unsupportedParseStmt(t: NimNode, description: string): NimNode =
let message = newLit("Cannot parse column with " & description)
result = quote do:
block:
{.error: `message`.}
default(`t`)
func createAutomaticParseStmt(t, value: NimNode): NimNode =
if t.typeKind == ntyObject: if t.typeKind == ntyObject:
if t.getType == UUID.getType:
if t.getTypeInst == Option.getType:
var innerType = t.getTypeImpl[2][0] # start at the first RecList
# If the value is a non-pointer type, there is another inner RecList
if innerType.kind == nnkRecList: innerType = innerType[0]
innerType = innerType[1] # now we can take the field type from the first symbol
let parseStmt = createParseStmt(innerType, value)
result = quote do:
if `value`.len == 0: none[`innerType`]()
else: some(`parseStmt`)
elif t.getType == UUID.getType:
result = quote do: parseUUID(`value`) result = quote do: parseUUID(`value`)
elif t.getType == DateTime.getType: elif t.getType == DateTime.getType:
result = quote do: parsePGDatetime(`value`) result = quote do: parsePGDatetime(`value`)
else: error "Cannot parse column with unknown object type: " & $t.getTypeInst else:
result = unsupportedParseStmt(
t, "unknown object type: " & $t.getTypeInst)
elif t.typeKind == ntyGenericInst: elif t.typeKind == ntyDistinct:
let baseType = t.getTypeImpl[0]
if t.kind == nnkBracketExpr and let parseStmt = createParseStmt(baseType, value)
t.len > 0 and result = quote do: `t`(`parseStmt`)
t[0] == Option.getType:
var innerType = t.getTypeInst[1]
let parseStmt = createParseStmt(innerType, value)
result = quote do:
if `value`.len == 0: none[`innerType`]()
else: some(`parseStmt`)
else: error "Cannot parse column with unknown generic instance type: " & $t.getTypeInst
elif t.typeKind == ntyRef: elif t.typeKind == ntyRef:
if $t.getTypeInst == "JsonNode": if $t.getTypeInst == "JsonNode":
result = quote do: parseJson(`value`) result = quote do: parseJson(`value`)
else: else:
error "Cannot parse column with unknown ref type: " & $t.getTypeInst result = unsupportedParseStmt(
t, "unknown ref type: " & $t.getTypeInst)
elif t.typeKind == ntySequence:
let innerType = t[1]
let parseStmts = createParseStmt(innerType, ident("it"))
result = quote do: parseDbArray(`value`).mapIt(`parseStmts`)
elif t.typeKind == ntyString: elif t.typeKind == ntyString:
result = quote do: `value` result = quote do: `value`
@@ -288,7 +297,47 @@ func createParseStmt*(t, value: NimNode): NimNode =
result = quote do: parseEnum[`innerType`](`value`) result = quote do: parseEnum[`innerType`](`value`)
else: else:
error "Cannot parse column with unknown value type: " & $t.typeKind result = unsupportedParseStmt(
t, "unknown value type: " & $t.typeKind)
func createParseStmt*(t, value: NimNode): NimNode =
## Create code to parse a value returned by a database query.
##
## `Option` and `seq` remain structural mappings owned by Fiber ORM. Their
## contained values are parsed recursively and may use `fromDbHook`.
if t.typeKind == ntyObject and t.getTypeInst == Option.getType:
var innerType = t.getTypeImpl[2][0] # start at the first RecList
# If the value is a non-pointer type, there is another inner RecList.
if innerType.kind == nnkRecList: innerType = innerType[0]
innerType = innerType[1] # take the field type from the first symbol
let parseStmt = createParseStmt(innerType, value)
result = quote do:
if `value`.len == 0: none[`innerType`]()
else: some(`parseStmt`)
return
if t.typeKind == ntyGenericInst and
t.kind == nnkBracketExpr and
t.len > 0 and
t[0] == Option.getType:
let innerType = t.getTypeInst[1]
let parseStmt = createParseStmt(innerType, value)
result = quote do:
if `value`.len == 0: none[`innerType`]()
else: some(`parseStmt`)
return
if t.typeKind == ntySequence:
let innerType = t[1]
let parseStmt = createParseStmt(innerType, ident("it"))
result = quote do: parseDbArray(`value`).mapIt(`parseStmt`)
return
let automaticParseStmt = createAutomaticParseStmt(t, value)
result = quote do:
parseDbValueWithHook(`t`, `value`, `automaticParseStmt`)
func fields(t: NimNode): seq[tuple[fieldIdent: NimNode, fieldType: NimNode]] = func fields(t: NimNode): seq[tuple[fieldIdent: NimNode, fieldType: NimNode]] =
#[ #[
@@ -382,7 +431,6 @@ macro rowToModel*(modelType: typed, row: seq[string]): untyped =
fieldIdent, fieldIdent,
createParseStmt(fieldType, itemLookup))) createParseStmt(fieldType, itemLookup)))
idx += 1 idx += 1
#[ #[
macro listFields*(t: typed): untyped = macro listFields*(t: typed): untyped =
var fields: seq[tuple[n: string, t: string]] = @[] var fields: seq[tuple[n: string, t: string]] = @[]
+20
View File
@@ -0,0 +1,20 @@
import std/[strutils]
type
ValidatedId* = distinct string
Money* = object
cents*: int64
proc fromDbHook*(
_: typedesc[ValidatedId],
value: string): ValidatedId =
if not value.startsWith("id_"):
raise newException(ValueError, "validated ID must begin with id_")
ValidatedId(value.toUpperAscii)
proc fromDbHook*(_: typedesc[Money], value: string): Money =
Money(cents: value.parseBiggestInt)
+45
View File
@@ -0,0 +1,45 @@
import std/[options, unittest]
import fiber_orm/util
import ./custom_db_types
type CustomModel = object
id: ValidatedId
optionalId: Option[ValidatedId]
absentId: Option[ValidatedId]
relatedIds: seq[ValidatedId]
balance: Money
suite "custom database mappings":
test "uses custom mappings for values nested in structural types":
let model = rowToModel(CustomModel, @[
"id_primary",
"id_optional",
"",
"{id_first,id_second}",
"1250",
])
check:
string(model.id) == "ID_PRIMARY"
model.optionalId.isSome
string(model.optionalId.get) == "ID_OPTIONAL"
model.absentId.isNone
model.relatedIds.len == 2
string(model.relatedIds[0]) == "ID_FIRST"
string(model.relatedIds[1]) == "ID_SECOND"
model.balance.cents == 1_250
test "propagates errors from custom mappings without falling back":
expect ValueError:
discard rowToModel(CustomModel, @[
"invalid",
"",
"",
"{}",
"1250",
])
+53
View File
@@ -0,0 +1,53 @@
import std/[times, unittest]
import fiber_orm/util
proc utcDateTime(nanosecond: NanosecondRange): DateTime =
dateTime(
2026, mAug, 26, 14, 32, 10, nanosecond,
utc())
suite "PostgreSQL DateTime conversion":
test "formats DateTime values with microsecond precision":
check dbFormat(utcDateTime(123_457_000)) ==
"2026-08-26T14:32:10.123457Z"
test "parses whole-second, millisecond, and microsecond values":
let cases = [
("2026-08-26 14:32:10+00", 0),
("2026-08-26 14:32:10.123+00", 123_000_000),
("2026-08-26 14:32:10.9007+00", 900_700_000),
("2026-08-26 14:32:10.12345+00", 123_450_000),
("2026-08-26 14:32:10.123457+00", 123_457_000),
]
for (value, expectedNanosecond) in cases:
let parsed = parsePGDatetime(value)
check parsed.nanosecond == expectedNanosecond
check parsed.utc.format("yyyy-MM-dd'T'HH:mm:ss") ==
"2026-08-26T14:32:10"
test "preserves supported separators and timezone representations":
let timezoneCases = [
"2026-08-26 14:32:10+00",
"2026-08-26T14:32:10+00",
"2026-08-26T14:32:10+00:00",
"2026-08-26 14:32:10.123+00",
"2026-08-26T14:32:10.123457+00:00",
"2026-08-26T14:32:10.123457Z",
]
for value in timezoneCases:
check parsePGDatetime(value).utc.format("yyyy-MM-dd'T'HH:mm:ss") ==
"2026-08-26T14:32:10"
let localCases = [
"2026-08-26 14:32:10",
"2026-08-26T14:32:10",
"2026-08-26 14:32:10.1",
"2026-08-26T14:32:10.12",
]
for value in localCases:
check parsePGDatetime(value).format("yyyy-MM-dd'T'HH:mm:ss") ==
"2026-08-26T14:32:10"
+30
View File
@@ -0,0 +1,30 @@
import std/[os, times, unittest]
import db_connector/[db_common, db_postgres]
import fiber_orm/util
proc utcDateTime(nanosecond: NanosecondRange): DateTime =
dateTime(
2026, mAug, 26, 14, 32, 10, nanosecond,
utc())
let connectionString = getEnv("FIBER_ORM_TEST_DB")
if connectionString.len == 0:
quit "FIBER_ORM_TEST_DB must contain a PostgreSQL connection string"
suite "PostgreSQL DateTime round trips":
test "preserves whole-second, millisecond, and microsecond values":
let db = db_postgres.open("", "", "", connectionString)
defer: db.close()
db.exec(sql"SET TIME ZONE 'UTC'")
for expected in [
utcDateTime(0),
utcDateTime(123_000_000),
utcDateTime(123_457_000),
]:
let dbValue = db.getValue(
sql"SELECT ?::timestamp with time zone",
dbFormat(expected))
let actual = parsePGDatetime(dbValue)
check actual.toTime == expected.toTime
+84
View File
@@ -0,0 +1,84 @@
import std/[options, times, unittest]
import uuids
import fiber_orm/util
type
StringId = distinct string
NestedStringId = distinct StringId
UuidId = distinct UUID
Count = distinct int
Ratio = distinct float
Enabled = distinct bool
State = enum
pending
complete
DistinctState = distinct State
DistinctModel = object
id: StringId
nestedId: NestedStringId
userId: UuidId
count: Count
ratio: Ratio
enabled: Enabled
state: DistinctState
optionalId: Option[StringId]
absentId: Option[StringId]
relatedIds: seq[StringId]
occurredAt: DateTime
suite "distinct type row mapping":
test "parses each distinct type through its backing type":
let model = rowToModel(DistinctModel, @[
"item_123",
"nested_123",
"07e268ed-a3c1-4952-bc45-778b3000b76c",
"42",
"1.25",
"true",
"complete",
"optional_123",
"",
"{related_1,related_2}",
"2026-08-26 14:32:10.123457+00",
])
check:
string(model.id) == "item_123"
string(StringId(model.nestedId)) == "nested_123"
UUID(model.userId) ==
parseUUID("07e268ed-a3c1-4952-bc45-778b3000b76c")
int(model.count) == 42
float(model.ratio) == 1.25
bool(model.enabled)
State(model.state) == complete
model.optionalId.isSome
string(model.optionalId.get) == "optional_123"
model.absentId.isNone
model.relatedIds.len == 2
string(model.relatedIds[0]) == "related_1"
string(model.relatedIds[1]) == "related_2"
model.occurredAt.nanosecond == 123_457_000
test "preserves backing-type parse failures":
expect ValueError:
discard rowToModel(DistinctModel, @[
"item_123",
"nested_123",
"not-a-uuid",
"42",
"1.25",
"true",
"complete",
"",
"",
"{}",
"2026-08-26 14:32:10+00",
])