Compare commits

..
4 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
10 changed files with 412 additions and 61 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
+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
+12 -1
View File
@@ -1,6 +1,6 @@
# Package # Package
version = "4.2.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"
+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
+99 -57
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,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.
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:
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`
@@ -294,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]] =
#[ #[
@@ -388,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",
])