Add custom database mapping hooks

This commit is contained in:
jdb
2026-09-02 12:21:23 -05:00
parent 170359d840
commit 06b568e071
6 changed files with 197 additions and 43 deletions
+25
View File
@@ -293,6 +293,31 @@ 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 type and then converted to the distinct type. This also applies when the
distinct type is nested in an `Option`_ or `seq`. 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
+1
View File
@@ -19,6 +19,7 @@ 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_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
+74 -40
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,45 +232,40 @@ 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.
if t.typeKind == ntyObject: 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
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) func unsupportedParseStmt(t: NimNode, description: string): NimNode =
let message = newLit("Cannot parse column with " & description)
result = quote do: result = quote do:
if `value`.len == 0: none[`innerType`]() block:
else: some(`parseStmt`) {.error: `message`.}
default(`t`)
elif t.getType == UUID.getType:
func createAutomaticParseStmt(t, value: NimNode): NimNode =
if t.typeKind == ntyObject:
if 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:
let baseType = t.getTypeImpl[0] let baseType = t.getTypeImpl[0]
@@ -271,19 +273,12 @@ func createParseStmt*(t, value: NimNode): NimNode =
result = quote do: `t`(`parseStmt`) result = quote do: `t`(`parseStmt`)
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`
@@ -302,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]] =
#[ #[
@@ -396,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",
])