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 0c7c9b7..4ea0d84 100644 --- a/src/dateTime/__tests__/regexParse.test.ts +++ b/src/dateTime/__tests__/regexParse.test.ts @@ -83,6 +83,47 @@ 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', [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 space-separated datetime (%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.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 space-separated datetime 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..4cf705a 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', '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..73e76e8 100644 --- a/src/dateTime/regexParse.ts +++ b/src/dateTime/regexParse.ts @@ -83,6 +83,14 @@ const isoOrdinalWithTimeExtensionRegex = new RegExp( ); const isoTimeFullRegex = new RegExp(`^${isoTimeRegex.source}$`); +// 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 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 = { GMT: 0, @@ -340,6 +348,10 @@ export function parseISODate(s: string) { ); } +export function parseSQLDate(s: string) { + return parse(s, [sqlYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset]); +} + export function parseRFC2822Date(s: string) { return parse(preprocessRFC2822(s), [rfc2822, extractRfc2822]); } @@ -362,6 +374,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;