Repository navigation
fix: parse SQL-style datetime strings with a space separator #99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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'], | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 AI generated 🟠 should-fix I don't think these four cases will actually guard the regression on CI. I checked by reverting
That's expected: the bug is that the native Would you mind pinning a non-UTC zone for the suite? "test": "TZ=Europe/Moscow jest"(or a |
||||||||
| ['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); | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. // 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.
// However, PostgreSQL accepts the full time zone name as input, not enclosed in square brackets and separated from the time by a space.
// 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}| (${ianaRegex.source}))?`,
);
const sqlYmdWithTimeExtensionRegex = new RegExp(`^${sqlYmdRegex.source} ${sqlTimeRegex.source}$`);
const sqlTimeFullRegex = new RegExp(`^${sqlTimeRegex.source}$`);
export function parseSQLDate(s: string) {
return parse(
s,
[sqlYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset],
[sqlTimeFullRegex, extractISOTimeAndOffset],
);
}or // 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}$`);
export function parseSQLDate(s: string) {
return parse(s, [sqlYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset]);
}
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed |
||
| 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 AI generated
🔵 nit
Would it be worth adding a few boundary cases? Several forms flipped from
isValid() === falsetotruethrough the public API, and nothing names them:The ISO block above does exactly this for its own grammar — lines 50–60 spell out
T09,T0908,T090834— so it'd be nice for the SQL block to read the same way.The bracketed one is the case I'd most like to see covered.
grepfor[Europe/,[America/,[Asia/acrosssrc/returns nothing, so that branch is untested for the ISO path too — and it carries a capture group thatextractISOYmdTimeAndOffset's cursor arithmetic depends on. It's not an exotic shape either:Temporalaccepts'2016-05-25 09:08:34[Europe/Paris]'(I checked on Node 24 with--harmony-temporal), and RFC 9557 standardises the bracket notation — so it seems worth locking in rather than leaving implicit.Whichever way you want these to behave is fine by me; the value is in having a test that says so.