From ae78ec831313f8a9b9a3a00c07eb8e75ac0c8106 Mon Sep 17 00:00:00 2001 From: roberthovsepyan Date: Fri, 11 Sep 2026 16:13:00 +0300 Subject: [PATCH 1/3] fix: parse SQL-style datetime strings with a space separator --- src/dateTime/__tests__/regexParse.test.ts | 36 +++++++++++++++++++++++ src/dateTime/dateTimeUtc.test.ts | 4 +++ src/dateTime/regexParse.ts | 12 ++++++++ 3 files changed, 52 insertions(+) diff --git a/src/dateTime/__tests__/regexParse.test.ts b/src/dateTime/__tests__/regexParse.test.ts index 0c7c9b7..1fcbe5f 100644 --- a/src/dateTime/__tests__/regexParse.test.ts +++ b/src/dateTime/__tests__/regexParse.test.ts @@ -83,6 +83,42 @@ test.each<[string, [number, number, number, number, number, number, number]]>([ ]).toEqual(expected); }); +test.each<[string, [number, number, number, number, number, number, number]]>([ + ['2016-05-25 09:08', [2016, 4, 25, 9, 8, 0, 0]], + ['2016-05-25 09:08:34', [2016, 4, 25, 9, 8, 34, 0]], + ['2016-05-25 09:08:34.123', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25 09:08:34.123456', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25 09:08:34.000000', [2016, 4, 25, 9, 8, 34, 0]], + ['2016-05-25 09:08:34,123', [2016, 4, 25, 9, 8, 34, 123]], +])('DateTime from SQL (%p)', (input, expected) => { + const dt = dateTime({input}); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual(expected); +}); + +test.each<[string, [number, number, number, number, number, number, number]]>([ + ['2016-05-25 09:08:34.123+06:00', [2016, 4, 25, 3, 8, 34, 123]], + ['2016-05-25 09:08:34.123Z', [2016, 4, 25, 9, 8, 34, 123]], +])('DateTime from SQL with offset (%p)', (input, expected) => { + const dt = dateTime({input}).utc(); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual(expected); +}); + test("DateTime from ISO doesn't accept 24:23", () => { expect(dateTime({input: '2018-05-25T24:23'}).isValid()).toBe(false); }); diff --git a/src/dateTime/dateTimeUtc.test.ts b/src/dateTime/dateTimeUtc.test.ts index 21840cb..fafa84b 100644 --- a/src/dateTime/dateTimeUtc.test.ts +++ b/src/dateTime/dateTimeUtc.test.ts @@ -47,6 +47,10 @@ describe('DateTimeUtc', () => { ['2023-12-31T01:00', '2023-12-31T01:00:00.000Z'], ['2023-12-31T01:00Z', '2023-12-31T01:00:00.000Z'], ['2023-12-31T03:00+02:00', '2023-12-31T01:00:00.000Z'], + ['2023-12-31 01:00', '2023-12-31T01:00:00.000Z'], + ['2023-12-31 01:00:00', '2023-12-31T01:00:00.000Z'], + ['2023-12-31 01:00:00.000000', '2023-12-31T01:00:00.000Z'], + ['2023-12-31 03:00:00+02:00', '2023-12-31T01:00:00.000Z'], ])('input option (%p)', (input, expected) => { const date = dateTimeUtc({input}).toISOString(); expect(date).toEqual(expected); diff --git a/src/dateTime/regexParse.ts b/src/dateTime/regexParse.ts index d0c97a7..dca6b8c 100644 --- a/src/dateTime/regexParse.ts +++ b/src/dateTime/regexParse.ts @@ -83,6 +83,10 @@ const isoOrdinalWithTimeExtensionRegex = new RegExp( ); const isoTimeFullRegex = new RegExp(`^${isoTimeRegex.source}$`); +// SQL-style datetime: YYYY-MM-DD hh:mm:ss.sss±00:00 (a space instead of T) +const sqlYmdRegex = /(\d{4})-(\d\d)-(\d\d)/; +const sqlYmdWithTimeRegex = new RegExp(`^${sqlYmdRegex.source} ${isoTimeRegex.source}$`); + // https://datatracker.ietf.org/doc/html/rfc2822#section-4.3 const obsOffsets = { GMT: 0, @@ -340,6 +344,10 @@ export function parseISODate(s: string) { ); } +export function parseSQLDate(s: string) { + return parse(s, [sqlYmdWithTimeRegex, extractISOYmdTimeAndOffset]); +} + export function parseRFC2822Date(s: string) { return parse(preprocessRFC2822(s), [rfc2822, extractRfc2822]); } @@ -362,6 +370,10 @@ export function parseDateString(input: string) { if (obj !== null) { return [obj, offset] as const; } + [obj, offset] = parseSQLDate(input); + if (obj !== null) { + return [obj, offset] as const; + } [obj, offset] = parseRFC2822Date(input); if (obj !== null) { return [obj, offset] as const; From 4ccdaa5ad53c238cff4fa17f4b49520dda1900c6 Mon Sep 17 00:00:00 2001 From: roberthovsepyan Date: Fri, 11 Sep 2026 17:31:08 +0300 Subject: [PATCH 2/3] test: pin a non-UTC time zone and cover more space-separated form --- jest.config.js | 1 + jest.global-setup.js | 6 ++++++ src/dateTime/__tests__/regexParse.test.ts | 11 +++++++++-- src/dateTime/dateTimeUtc.test.ts | 2 +- src/dateTime/regexParse.ts | 6 +++++- 5 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 jest.global-setup.js diff --git a/jest.config.js b/jest.config.js index 652c902..168811e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,5 +4,6 @@ module.exports = { transform: { '^.+\\.ts$': ['ts-jest'], }, + globalSetup: '/jest.global-setup.js', setupFilesAfterEnv: ['/setup-tests.ts'], }; diff --git a/jest.global-setup.js b/jest.global-setup.js new file mode 100644 index 0000000..198cb8d --- /dev/null +++ b/jest.global-setup.js @@ -0,0 +1,6 @@ +// Pin a non-UTC time zone for the whole suite. Parsing bugs that resolve a string +// in the system zone instead of the requested one are invisible under UTC, +// which is what CI runners default to. +module.exports = () => { + process.env.TZ = 'Europe/Moscow'; +}; diff --git a/src/dateTime/__tests__/regexParse.test.ts b/src/dateTime/__tests__/regexParse.test.ts index 1fcbe5f..9d289b5 100644 --- a/src/dateTime/__tests__/regexParse.test.ts +++ b/src/dateTime/__tests__/regexParse.test.ts @@ -84,13 +84,17 @@ test.each<[string, [number, number, number, number, number, number, number]]>([ }); test.each<[string, [number, number, number, number, number, number, number]]>([ + ['2016-05-25 09', [2016, 4, 25, 9, 0, 0, 0]], ['2016-05-25 09:08', [2016, 4, 25, 9, 8, 0, 0]], + ['2016-05-25 0908', [2016, 4, 25, 9, 8, 0, 0]], ['2016-05-25 09:08:34', [2016, 4, 25, 9, 8, 34, 0]], + ['2016-05-25 090834', [2016, 4, 25, 9, 8, 34, 0]], ['2016-05-25 09:08:34.123', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25 090834.123', [2016, 4, 25, 9, 8, 34, 123]], ['2016-05-25 09:08:34.123456', [2016, 4, 25, 9, 8, 34, 123]], ['2016-05-25 09:08:34.000000', [2016, 4, 25, 9, 8, 34, 0]], ['2016-05-25 09:08:34,123', [2016, 4, 25, 9, 8, 34, 123]], -])('DateTime from SQL (%p)', (input, expected) => { +])('DateTime from space-separated datetime (%p)', (input, expected) => { const dt = dateTime({input}); expect([ dt.year(), @@ -105,8 +109,11 @@ test.each<[string, [number, number, number, number, number, number, number]]>([ test.each<[string, [number, number, number, number, number, number, number]]>([ ['2016-05-25 09:08:34.123+06:00', [2016, 4, 25, 3, 8, 34, 123]], + ['2016-05-25 09:08:34.123+06', [2016, 4, 25, 3, 8, 34, 123]], ['2016-05-25 09:08:34.123Z', [2016, 4, 25, 9, 8, 34, 123]], -])('DateTime from SQL with offset (%p)', (input, expected) => { + ['2016-05-25 09:08:34[Europe/Paris]', [2016, 4, 25, 7, 8, 34, 0]], + ['2016-05-25 09:08:34.123[Europe/Paris]', [2016, 4, 25, 7, 8, 34, 123]], +])('DateTime from space-separated datetime with zone (%p)', (input, expected) => { const dt = dateTime({input}).utc(); expect([ dt.year(), diff --git a/src/dateTime/dateTimeUtc.test.ts b/src/dateTime/dateTimeUtc.test.ts index fafa84b..4cf705a 100644 --- a/src/dateTime/dateTimeUtc.test.ts +++ b/src/dateTime/dateTimeUtc.test.ts @@ -50,7 +50,7 @@ describe('DateTimeUtc', () => { ['2023-12-31 01:00', '2023-12-31T01:00:00.000Z'], ['2023-12-31 01:00:00', '2023-12-31T01:00:00.000Z'], ['2023-12-31 01:00:00.000000', '2023-12-31T01:00:00.000Z'], - ['2023-12-31 03:00:00+02:00', '2023-12-31T01:00:00.000Z'], + ['2023-12-31 03:00:00+02', '2023-12-31T01:00:00.000Z'], ])('input option (%p)', (input, expected) => { const date = dateTimeUtc({input}).toISOString(); expect(date).toEqual(expected); diff --git a/src/dateTime/regexParse.ts b/src/dateTime/regexParse.ts index dca6b8c..e38f0d3 100644 --- a/src/dateTime/regexParse.ts +++ b/src/dateTime/regexParse.ts @@ -83,7 +83,11 @@ const isoOrdinalWithTimeExtensionRegex = new RegExp( ); const isoTimeFullRegex = new RegExp(`^${isoTimeRegex.source}$`); -// SQL-style datetime: YYYY-MM-DD hh:mm:ss.sss±00:00 (a space instead of T) +// Datetime with a space instead of T — the shape SQL databases emit +// (ClickHouse DateTime64, Postgres timestamptz, MySQL DATETIME). +// Grammar: YYYY-MM-DD + the ISO time part, so compact/partial forms, +// a comma fraction and a trailing [IANA] zone are accepted too. +// https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-OUTPUT const sqlYmdRegex = /(\d{4})-(\d\d)-(\d\d)/; const sqlYmdWithTimeRegex = new RegExp(`^${sqlYmdRegex.source} ${isoTimeRegex.source}$`); From b66fa0e05e065a507280c2dd30aec6ff16dfc383 Mon Sep 17 00:00:00 2001 From: roberthovsepyan Date: Mon, 14 Sep 2026 11:23:46 +0300 Subject: [PATCH 3/3] fix: restrict the SQL datetime pattern to a numeric offset --- src/dateTime/__tests__/regexParse.test.ts | 4 +--- src/dateTime/regexParse.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/dateTime/__tests__/regexParse.test.ts b/src/dateTime/__tests__/regexParse.test.ts index 9d289b5..4ea0d84 100644 --- a/src/dateTime/__tests__/regexParse.test.ts +++ b/src/dateTime/__tests__/regexParse.test.ts @@ -111,9 +111,7 @@ test.each<[string, [number, number, number, number, number, number, number]]>([ ['2016-05-25 09:08:34.123+06:00', [2016, 4, 25, 3, 8, 34, 123]], ['2016-05-25 09:08:34.123+06', [2016, 4, 25, 3, 8, 34, 123]], ['2016-05-25 09:08:34.123Z', [2016, 4, 25, 9, 8, 34, 123]], - ['2016-05-25 09:08:34[Europe/Paris]', [2016, 4, 25, 7, 8, 34, 0]], - ['2016-05-25 09:08:34.123[Europe/Paris]', [2016, 4, 25, 7, 8, 34, 123]], -])('DateTime from space-separated datetime with zone (%p)', (input, expected) => { +])('DateTime from space-separated datetime with offset (%p)', (input, expected) => { const dt = dateTime({input}).utc(); expect([ dt.year(), diff --git a/src/dateTime/regexParse.ts b/src/dateTime/regexParse.ts index e38f0d3..73e76e8 100644 --- a/src/dateTime/regexParse.ts +++ b/src/dateTime/regexParse.ts @@ -83,13 +83,13 @@ const isoOrdinalWithTimeExtensionRegex = new RegExp( ); const isoTimeFullRegex = new RegExp(`^${isoTimeRegex.source}$`); -// Datetime with a space instead of T — the shape SQL databases emit -// (ClickHouse DateTime64, Postgres timestamptz, MySQL DATETIME). -// Grammar: YYYY-MM-DD + the ISO time part, so compact/partial forms, -// a comma fraction and a trailing [IANA] zone are accepted too. +// ISO 8601 specifies the use of uppercase letter T to separate the date and time. +// PostgreSQL accepts that format on input, but on output it uses a space rather than T. +// In the ISO style, the time zone is always shown as a signed numeric offset from UTC. // https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-OUTPUT const sqlYmdRegex = /(\d{4})-(\d\d)-(\d\d)/; -const sqlYmdWithTimeRegex = new RegExp(`^${sqlYmdRegex.source} ${isoTimeRegex.source}$`); +const sqlTimeRegex = RegExp(`${isoTimeBaseRegex.source}(?:${offsetRegex.source})?`); +const sqlYmdWithTimeExtensionRegex = new RegExp(`^${sqlYmdRegex.source} ${sqlTimeRegex.source}$`); // https://datatracker.ietf.org/doc/html/rfc2822#section-4.3 const obsOffsets = { @@ -349,7 +349,7 @@ export function parseISODate(s: string) { } export function parseSQLDate(s: string) { - return parse(s, [sqlYmdWithTimeRegex, extractISOYmdTimeAndOffset]); + return parse(s, [sqlYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset]); } export function parseRFC2822Date(s: string) {