Compare commits

1 Commits
Author SHA1 Message Date
jdb f2f41782d4 Preserve DateTime microsecond precision 2026-08-31 17:52:09 -05:00
5 changed files with 123 additions and 14 deletions
+8
View File
@@ -3,6 +3,14 @@ SOURCES=$(shell find src -type f)
build: $(shell find src -type f)
nimble build
unittest:
nimble unittest
.PHONY: unittest
integrationtest:
nimble integrationtest
.PHONY: integrationtest
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 rst2html --outdir:docs README.rst
+10 -1
View File
@@ -1,6 +1,6 @@
# Package
version = "4.2.0"
version = "4.3.0"
author = "Jonathan Bernard"
description = "Lightweight Postgres ORM for Nim."
license = "GPL-3.0"
@@ -12,3 +12,12 @@ srcDir = "src"
requires @["nim >= 1.4.0", "uuids"]
requires "namespaced_logging >= 2.0.2"
# Tasks
task unittest, "Runs the unit test suite.":
exec "nim c -r --path:src tests/test_datetime"
task integrationtest, "Runs the PostgreSQL integration test suite.":
exec "nim c -r --path:src tests/test_datetime_postgres"
+22 -13
View File
@@ -26,12 +26,12 @@ type
const ISO_8601_FORMATS = @[
"yyyy-MM-dd'T'HH:mm:ssz",
"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'.'ffffzzz",
"yyyy-MM-dd HH:mm:ssz",
"yyyy-MM-dd HH:mm:sszzz",
"yyyy-MM-dd HH:mm:ss'.'fffzzz",
"yyyy-MM-dd HH:mm:ss'.'ffffzzz"
"yyyy-MM-dd HH:mm:ss'.'ffffffzzz",
"yyyy-MM-dd HH:mm:ss'.'fffzzz"
]
proc parseIso8601(val: string): DateTime =
@@ -121,32 +121,41 @@ proc parsePGDatetime*(val: string): DateTime =
const PG_TIMESTAMP_FORMATS = [
"yyyy-MM-dd 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'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'T'HH:mm:ss'.'fff",
"yyyy-MM-dd HH:mm:ss'.'fffzz",
"yyyy-MM-dd'T'HH:mm:ss'.'fffzz",
"yyyy-MM-dd 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;
# The Nim `times#format` function only recognizes 3-digit millisecond values
# but PostgreSQL will sometimes send 1-2 digits, truncating any trailing 0's,
# or sometimes provide more than three digits of preceision in the millisecond value leading
# to values like `2020-01-01 16:42.3+00` or `2025-01-06 00:56:00.9007+00`.
# This cannot currently be parsed by the standard times format as it expects
# 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+)?"
# Nim's time parser requires a fixed number of fractional digits for each
# format pattern. PostgreSQL emits between one and six digits, omitting
# trailing zeroes. Normalize the fraction to six digits so parsing retains
# PostgreSQL's full microsecond precision.
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)
if match.isSome:
let c = match.get.captures
if c.toSeq.len == 2: correctedVal = c[0] & alignLeft(c[2], 3, '0')[0..2]
else: correctedVal = c[0] & alignLeft(c[2], 3, '0')[0..2] & c[3]
correctedVal = c[0] & alignLeft(c[2], 6, '0')[0..5]
if 3 in c: correctedVal &= c[3]
var errStr = ""
+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