Compare commits

..
3 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
7 changed files with 290 additions and 48 deletions
+29
View File
@@ -289,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
+3 -1
View File
@@ -1,6 +1,6 @@
# Package # Package
version = "4.3.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"
@@ -18,6 +18,8 @@ requires "namespaced_logging >= 2.0.2"
task unittest, "Runs the unit test suite.": task unittest, "Runs the unit test suite.":
exec "nim c -r --path:src tests/test_datetime" 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.": task integrationtest, "Runs the PostgreSQL integration test suite.":
exec "nim c -r --path:src tests/test_datetime_postgres" exec "nim c -r --path:src tests/test_datetime_postgres"
+29
View File
@@ -288,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
+80 -47
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,6 +25,11 @@ 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",
@@ -225,66 +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(
elif t.typeKind == ntyGenericInst: t, "unknown object type: " & $t.getTypeInst)
if t.kind == nnkBracketExpr and
t.len > 0 and
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 == ntyDistinct: elif t.typeKind == ntyDistinct:
result = quote do: let baseType = t.getTypeImpl[0]
block: let parseStmt = createParseStmt(baseType, value)
let tmp: `t` = `value` result = quote do: `t`(`parseStmt`)
tmp
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`
@@ -303,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]] =
#[ #[
@@ -397,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",
])
+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",
])