From f2f41782d4f9a16129efd7fd5683bca8d126011f Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Mon, 31 Aug 2026 17:51:37 -0500 Subject: [PATCH] Preserve DateTime microsecond precision --- Makefile | 8 +++++ fiber_orm.nimble | 11 ++++++- src/fiber_orm/util.nim | 35 +++++++++++++-------- tests/test_datetime.nim | 53 ++++++++++++++++++++++++++++++++ tests/test_datetime_postgres.nim | 30 ++++++++++++++++++ 5 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 tests/test_datetime.nim create mode 100644 tests/test_datetime_postgres.nim diff --git a/Makefile b/Makefile index f2837ef..5d9e79d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/fiber_orm.nimble b/fiber_orm.nimble index 6b9d764..482244c 100644 --- a/fiber_orm.nimble +++ b/fiber_orm.nimble @@ -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" diff --git a/src/fiber_orm/util.nim b/src/fiber_orm/util.nim index 4e71a94..0a1030e 100644 --- a/src/fiber_orm/util.nim +++ b/src/fiber_orm/util.nim @@ -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 = "" diff --git a/tests/test_datetime.nim b/tests/test_datetime.nim new file mode 100644 index 0000000..d81aa64 --- /dev/null +++ b/tests/test_datetime.nim @@ -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" diff --git a/tests/test_datetime_postgres.nim b/tests/test_datetime_postgres.nim new file mode 100644 index 0000000..f056db6 --- /dev/null +++ b/tests/test_datetime_postgres.nim @@ -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