From 06b568e0711eb552432dd6bdb2c9ea3117cf2a65 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Wed, 2 Sep 2026 12:18:55 -0500 Subject: [PATCH] Add custom database mapping hooks --- README.rst | 25 +++++++ fiber_orm.nimble | 1 + src/fiber_orm.nim | 29 ++++++++ src/fiber_orm/util.nim | 120 ++++++++++++++++++++++------------ tests/custom_db_types.nim | 20 ++++++ tests/test_custom_mapping.nim | 45 +++++++++++++ 6 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 tests/custom_db_types.nim create mode 100644 tests/test_custom_mapping.nim diff --git a/README.rst b/README.rst index b33333f..e1fb908 100644 --- a/README.rst +++ b/README.rst @@ -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 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 as optional using `Option[fieldType]`. Conversely, any fields with non-optional types should also be constrained to be `NOT NULL` in diff --git a/fiber_orm.nimble b/fiber_orm.nimble index ca55deb..f1c58e0 100644 --- a/fiber_orm.nimble +++ b/fiber_orm.nimble @@ -19,6 +19,7 @@ requires "namespaced_logging >= 2.0.2" 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" diff --git a/src/fiber_orm.nim b/src/fiber_orm.nim index 0e0a723..e61bdc5 100644 --- a/src/fiber_orm.nim +++ b/src/fiber_orm.nim @@ -288,6 +288,35 @@ ## `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 ## as optional using `Option[fieldType]`. Conversely, any fields with ## non-optional types should also be constrained to be `NOT NULL` in diff --git a/src/fiber_orm/util.nim b/src/fiber_orm/util.nim index 8b89c7c..0dae8b4 100644 --- a/src/fiber_orm/util.nim +++ b/src/fiber_orm/util.nim @@ -9,6 +9,8 @@ import uuids import std/nre except toSeq type + NoDbHook = object + PaginationParams* = object pageSize*: int offset*: int @@ -23,6 +25,11 @@ type placeholders*: 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 = @[ "yyyy-MM-dd'T'HH:mm:ssz", "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: result.add(curStr) -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. +func createParseStmt*(t, value: NimNode): NimNode +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.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: + if t.getType == UUID.getType: result = quote do: parseUUID(`value`) elif t.getType == DateTime.getType: result = quote do: parsePGDatetime(`value`) - else: error "Cannot parse column with unknown object type: " & $t.getTypeInst - - elif t.typeKind == ntyGenericInst: - - 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 + else: + result = unsupportedParseStmt( + t, "unknown object type: " & $t.getTypeInst) elif t.typeKind == ntyDistinct: let baseType = t.getTypeImpl[0] @@ -271,19 +273,12 @@ func createParseStmt*(t, value: NimNode): NimNode = result = quote do: `t`(`parseStmt`) elif t.typeKind == ntyRef: - if $t.getTypeInst == "JsonNode": result = quote do: parseJson(`value`) else: - error "Cannot parse column with 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`) + result = unsupportedParseStmt( + t, "unknown ref type: " & $t.getTypeInst) elif t.typeKind == ntyString: result = quote do: `value` @@ -302,7 +297,47 @@ func createParseStmt*(t, value: NimNode): NimNode = result = quote do: parseEnum[`innerType`](`value`) 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]] = #[ @@ -396,7 +431,6 @@ macro rowToModel*(modelType: typed, row: seq[string]): untyped = fieldIdent, createParseStmt(fieldType, itemLookup))) idx += 1 - #[ macro listFields*(t: typed): untyped = var fields: seq[tuple[n: string, t: string]] = @[] diff --git a/tests/custom_db_types.nim b/tests/custom_db_types.nim new file mode 100644 index 0000000..7471c3d --- /dev/null +++ b/tests/custom_db_types.nim @@ -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) diff --git a/tests/test_custom_mapping.nim b/tests/test_custom_mapping.nim new file mode 100644 index 0000000..ff2ce28 --- /dev/null +++ b/tests/test_custom_mapping.nim @@ -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", + ])