From 720fd2676feb9857de290a08cfc9a51c3e5e7f2d Mon Sep 17 00:00:00 2001 From: Pieter Develtere Date: Sat, 11 Jul 2026 13:27:10 +0200 Subject: [PATCH 1/4] Add Temporal support to escape Escaping a Temporal value (e.g. Temporal.Instant) currently falls through to the generic object handling and produces broken SQL. Teach escape() about Temporal, mirroring the existing Date handling. - Instant / ZonedDateTime: absolute times, delegated to dateToString so the timezone argument behaves exactly as it does for Date (millisecond precision). - PlainDateTime / PlainDate / PlainTime: wall-clock values, emitted verbatim as DATETIME / DATE / TIME literals (timezone ignored). - Other Temporal types fall back to their ISO string. Temporal types are detected via Object.prototype.toString (Symbol.toStringTag), so the Temporal global is never referenced and no Temporal lib types are needed; the new TemporalLike type is declared structurally. Adds tests (skipped when Temporal is unavailable) and README docs. Co-Authored-By: Claude Opus 4.8 --- README.md | 22 +++++++++++++ src/index.ts | 35 ++++++++++++++++++++- src/types.ts | 13 ++++++++ test/temporal.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 test/temporal.test.ts diff --git a/README.md b/README.md index c54ba3c..4f26470 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,28 @@ escape(date, true, '+01'); // "'2012-05-07 12:42:03.002'" escape(date, true, '-05:00'); // "'2012-05-07 06:42:03.002'" ``` +#### Temporal + +[Temporal](https://tc39.es/proposal-temporal/) values are supported too. +`Temporal.Instant` and `Temporal.ZonedDateTime` are absolute points in time and +honor the `timezone` argument exactly like `Date` (millisecond precision): + +```js +const instant = Temporal.Instant.from('2012-05-07T11:42:03.002Z'); + +escape(instant, true, 'Z'); // "'2012-05-07 11:42:03.002'" +escape(instant, true, '+0200'); // "'2012-05-07 13:42:03.002'" +``` + +`Temporal.PlainDateTime`, `Temporal.PlainDate` and `Temporal.PlainTime` are +wall-clock values and are emitted verbatim as `DATETIME` / `DATE` / `TIME` +literals, ignoring `timezone`: + +```js +escape(Temporal.PlainDate.from('2012-05-07')); // "'2012-05-07'" +escape(Temporal.PlainTime.from('11:42:03')); // "'11:42:03'" +``` + #### Buffers Buffers are converted to hex strings: diff --git a/src/index.ts b/src/index.ts index 93f1d1d..8a0120b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ * MIT LICENSE: https://github.com/mysqljs/sqlstring/blob/cd528556b4b6bcf300c3db515026935dedf7cfa1/LICENSE */ -import type { Raw, SqlValue, Timezone } from './types.js'; +import type { Raw, SqlValue, TemporalLike, Timezone } from './types.js'; import { Buffer } from 'node:buffer'; export type { Raw, SqlValue, Timezone } from './types.js'; @@ -308,6 +308,12 @@ const findSetKeyword = (sql: string, startFrom = 0): number => { const isDate = (value: unknown): value is Date => Object.prototype.toString.call(value) === '[object Date]'; +// Detects Temporal values via their Symbol.toStringTag ('[object Temporal.*]'). +// The Temporal global is never referenced, so this stays inert on runtimes +// without Temporal support. +const isTemporal = (value: unknown): value is TemporalLike => + Object.prototype.toString.call(value).startsWith('[object Temporal.'); + const hasSqlString = (value: unknown): value is Raw => typeof value === 'object' && value !== null && @@ -453,6 +459,32 @@ export const dateToString = (date: Date, timezone: Timezone): string => { ); }; +export const temporalToString = ( + value: TemporalLike, + timezone?: Timezone +): string => { + switch (Object.prototype.toString.call(value)) { + // Absolute points in time. Handled like a Date so the `timezone` argument + // keeps the same meaning (millisecond precision, matching Date). + case '[object Temporal.Instant]': + case '[object Temporal.ZonedDateTime]': + return dateToString( + new Date(value.epochMilliseconds as number), + timezone || 'local' + ); + // Wall-clock values without a time zone. Emitted verbatim as the matching + // MySQL DATE / TIME / DATETIME literal, ignoring `timezone`. + case '[object Temporal.PlainDateTime]': + case '[object Temporal.PlainDate]': + case '[object Temporal.PlainTime]': + return escapeString(value.toString().replace('T', ' ')); + // Any other Temporal type (Duration, PlainYearMonth, ...) has no dedicated + // MySQL type; fall back to its ISO string. + default: + return escapeString(value.toString()); + } +}; + export const escapeId = ( value: SqlValue, forbidQualified?: boolean @@ -561,6 +593,7 @@ export const escape = ( case 'object': { if (isDate(value)) return dateToString(value, timezone || 'local'); + if (isTemporal(value)) return temporalToString(value, timezone); if (Array.isArray(value)) return arrayToList(value, timezone); if (value instanceof Set) return arrayToList(Array.from(value as Set), timezone); diff --git a/src/types.ts b/src/types.ts index cc69de8..dd9ad68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,25 @@ export type Raw = { toSqlString(): string; }; +/** + * Structural shape of a Temporal value (`Temporal.Instant`, `Temporal.PlainDate`, + * `Temporal.PlainDateTime`, ...). Declared structurally so the package does not + * depend on the `Temporal` lib types being present. `epochMilliseconds` is only + * populated on the absolute types (`Instant` / `ZonedDateTime`). + */ +export type TemporalLike = { + readonly [Symbol.toStringTag]: string; + toString(): string; + readonly epochMilliseconds?: number; +}; + export type SqlValue = | string | number | bigint | boolean | Date + | TemporalLike | Buffer | Uint8Array | Raw diff --git a/test/temporal.test.ts b/test/temporal.test.ts new file mode 100644 index 0000000..0d00855 --- /dev/null +++ b/test/temporal.test.ts @@ -0,0 +1,73 @@ +import type { SqlValue } from '../src/types.ts'; +import { assert, test } from 'poku'; +import { escape } from '../src/index.ts'; + +/** + * Temporal's lib types (`esnext.temporal`) are opt-in and are not part of the + * lib set this package builds against (`target`/`lib` ES2018), so Temporal is + * declared structurally here rather than pulling that lib in. The tests are + * skipped on runtimes that do not expose the Temporal global. + */ +type TemporalNamespace = { + Instant: { + from(iso: string): SqlValue & { + toZonedDateTimeISO(timeZone: string): SqlValue; + }; + }; + PlainDateTime: { from(iso: string): SqlValue }; + PlainDate: { from(iso: string): SqlValue }; + PlainTime: { from(iso: string): SqlValue }; +}; + +const Temporal = (globalThis as { Temporal?: TemporalNamespace }).Temporal; + +test('Temporal.Instant is escaped like a Date', () => { + if (!Temporal) return; + const instant = Temporal.Instant.from('2012-05-07T11:42:03.002Z'); + + assert.strictEqual(escape(instant, false, 'Z'), "'2012-05-07 11:42:03.002'"); +}); + +test('Temporal.Instant honors the time zone argument', () => { + if (!Temporal) return; + const instant = Temporal.Instant.from('2012-05-07T11:42:03.002Z'); + + assert.strictEqual( + escape(instant, false, '+0200'), + "'2012-05-07 13:42:03.002'" + ); +}); + +test('Temporal.ZonedDateTime is escaped as an absolute time', () => { + if (!Temporal) return; + const zoned = Temporal.Instant.from( + '2012-05-07T11:42:03.002Z' + ).toZonedDateTimeISO('+02:00'); + + assert.strictEqual(escape(zoned, false, 'Z'), "'2012-05-07 11:42:03.002'"); +}); + +test('Temporal.PlainDateTime is escaped ignoring the time zone', () => { + if (!Temporal) return; + const plain = Temporal.PlainDateTime.from('2012-05-07T11:42:03.002'); + + assert.strictEqual( + escape(plain, false, '+0200'), + "'2012-05-07 11:42:03.002'" + ); +}); + +test('Temporal.PlainDate is escaped as a DATE literal', () => { + if (!Temporal) return; + + assert.strictEqual( + escape(Temporal.PlainDate.from('2012-05-07')), + "'2012-05-07'" + ); +}); + +test('Temporal.PlainTime is escaped as a TIME literal', () => { + if (!Temporal) return; + + assert.strictEqual(escape(Temporal.PlainTime.from('11:42:03')), "'11:42:03'"); +}); From 0345f374e36d8e28b8227ce31ffbfc0128c7f296 Mon Sep 17 00:00:00 2001 From: Pieter Develtere Date: Sun, 12 Jul 2026 12:08:52 +0200 Subject: [PATCH 2/4] refactor: use native Temporal types and cover the ISO fallback - type Temporal values via the esnext.temporal lib (unblocked by #44) instead of the structural TemporalLike shape - add tests for the ISO-string fallback (Duration, PlainYearMonth, PlainMonthDay) - run Temporal tests on every runtime via @js-temporal/polyfill (dev-only) so coverage no longer depends on the CI Node version - trim comments to decision notes per review --- package-lock.json | 23 ++++++++++++++++++++++- package.json | 1 + src/index.ts | 21 +++++++-------------- src/types.ts | 23 +++++++++++------------ test/temporal.test.ts | 41 ++++++++++++++++------------------------- tsconfig.json | 2 +- 6 files changed, 58 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8af5be5..1e0bccb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@js-temporal/polyfill": "^0.5.1", "@types/node": "^25.2.0", "esbuild": "^0.28.1", "monocart-coverage-reports": "^2.12.9", @@ -18,7 +19,7 @@ "poku": "^4.3.2", "prettier": "^3.8.1", "tsx": "^4.21.0", - "typescript": "6.0.3" + "typescript": "^6.0.3" }, "engines": { "bun": ">=1.0.0", @@ -836,6 +837,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@types/node": { "version": "25.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", @@ -1086,6 +1100,13 @@ "dev": true, "license": "MIT" }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", diff --git a/package.json b/package.json index 2a3488d..46069dc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@js-temporal/polyfill": "^0.5.1", "@types/node": "^25.2.0", "esbuild": "^0.28.1", "monocart-coverage-reports": "^2.12.9", diff --git a/src/index.ts b/src/index.ts index 8a0120b..14a2210 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,10 +3,10 @@ * MIT LICENSE: https://github.com/mysqljs/sqlstring/blob/cd528556b4b6bcf300c3db515026935dedf7cfa1/LICENSE */ -import type { Raw, SqlValue, TemporalLike, Timezone } from './types.js'; +import type { Raw, SqlValue, TemporalValue, Timezone } from './types.js'; import { Buffer } from 'node:buffer'; -export type { Raw, SqlValue, Timezone } from './types.js'; +export type { Raw, SqlValue, TemporalValue, Timezone } from './types.js'; const CONTEXT_TRIGGER = new Uint8Array(128); @@ -308,10 +308,7 @@ const findSetKeyword = (sql: string, startFrom = 0): number => { const isDate = (value: unknown): value is Date => Object.prototype.toString.call(value) === '[object Date]'; -// Detects Temporal values via their Symbol.toStringTag ('[object Temporal.*]'). -// The Temporal global is never referenced, so this stays inert on runtimes -// without Temporal support. -const isTemporal = (value: unknown): value is TemporalLike => +const isTemporal = (value: unknown): value is TemporalValue => Object.prototype.toString.call(value).startsWith('[object Temporal.'); const hasSqlString = (value: unknown): value is Raw => @@ -460,26 +457,22 @@ export const dateToString = (date: Date, timezone: Timezone): string => { }; export const temporalToString = ( - value: TemporalLike, + value: TemporalValue, timezone?: Timezone ): string => { switch (Object.prototype.toString.call(value)) { - // Absolute points in time. Handled like a Date so the `timezone` argument - // keeps the same meaning (millisecond precision, matching Date). case '[object Temporal.Instant]': case '[object Temporal.ZonedDateTime]': return dateToString( - new Date(value.epochMilliseconds as number), + new Date( + (value as Temporal.Instant | Temporal.ZonedDateTime).epochMilliseconds + ), timezone || 'local' ); - // Wall-clock values without a time zone. Emitted verbatim as the matching - // MySQL DATE / TIME / DATETIME literal, ignoring `timezone`. case '[object Temporal.PlainDateTime]': case '[object Temporal.PlainDate]': case '[object Temporal.PlainTime]': return escapeString(value.toString().replace('T', ' ')); - // Any other Temporal type (Duration, PlainYearMonth, ...) has no dedicated - // MySQL type; fall back to its ISO string. default: return escapeString(value.toString()); } diff --git a/src/types.ts b/src/types.ts index dd9ad68..57887c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,17 +2,16 @@ export type Raw = { toSqlString(): string; }; -/** - * Structural shape of a Temporal value (`Temporal.Instant`, `Temporal.PlainDate`, - * `Temporal.PlainDateTime`, ...). Declared structurally so the package does not - * depend on the `Temporal` lib types being present. `epochMilliseconds` is only - * populated on the absolute types (`Instant` / `ZonedDateTime`). - */ -export type TemporalLike = { - readonly [Symbol.toStringTag]: string; - toString(): string; - readonly epochMilliseconds?: number; -}; +/** Temporal values accepted by `escape()` */ +export type TemporalValue = + | Temporal.Instant + | Temporal.ZonedDateTime + | Temporal.PlainDateTime + | Temporal.PlainDate + | Temporal.PlainTime + | Temporal.PlainYearMonth + | Temporal.PlainMonthDay + | Temporal.Duration; export type SqlValue = | string @@ -20,7 +19,7 @@ export type SqlValue = | bigint | boolean | Date - | TemporalLike + | TemporalValue | Buffer | Uint8Array | Raw diff --git a/test/temporal.test.ts b/test/temporal.test.ts index 0d00855..8482fe7 100644 --- a/test/temporal.test.ts +++ b/test/temporal.test.ts @@ -1,35 +1,20 @@ -import type { SqlValue } from '../src/types.ts'; +import { Temporal as TemporalPolyfill } from '@js-temporal/polyfill'; import { assert, test } from 'poku'; import { escape } from '../src/index.ts'; /** - * Temporal's lib types (`esnext.temporal`) are opt-in and are not part of the - * lib set this package builds against (`target`/`lib` ES2018), so Temporal is - * declared structurally here rather than pulling that lib in. The tests are - * skipped on runtimes that do not expose the Temporal global. + * Uses the polyfill on runtimes without a Temporal global so these always run + * @todo remove this, and the @js-temporal/polyfill library, once node version 26 is lts (it is "current" for now) */ -type TemporalNamespace = { - Instant: { - from(iso: string): SqlValue & { - toZonedDateTimeISO(timeZone: string): SqlValue; - }; - }; - PlainDateTime: { from(iso: string): SqlValue }; - PlainDate: { from(iso: string): SqlValue }; - PlainTime: { from(iso: string): SqlValue }; -}; - -const Temporal = (globalThis as { Temporal?: TemporalNamespace }).Temporal; +const Temporal = globalThis.Temporal ?? TemporalPolyfill; test('Temporal.Instant is escaped like a Date', () => { - if (!Temporal) return; const instant = Temporal.Instant.from('2012-05-07T11:42:03.002Z'); assert.strictEqual(escape(instant, false, 'Z'), "'2012-05-07 11:42:03.002'"); }); test('Temporal.Instant honors the time zone argument', () => { - if (!Temporal) return; const instant = Temporal.Instant.from('2012-05-07T11:42:03.002Z'); assert.strictEqual( @@ -39,7 +24,6 @@ test('Temporal.Instant honors the time zone argument', () => { }); test('Temporal.ZonedDateTime is escaped as an absolute time', () => { - if (!Temporal) return; const zoned = Temporal.Instant.from( '2012-05-07T11:42:03.002Z' ).toZonedDateTimeISO('+02:00'); @@ -48,7 +32,6 @@ test('Temporal.ZonedDateTime is escaped as an absolute time', () => { }); test('Temporal.PlainDateTime is escaped ignoring the time zone', () => { - if (!Temporal) return; const plain = Temporal.PlainDateTime.from('2012-05-07T11:42:03.002'); assert.strictEqual( @@ -58,8 +41,6 @@ test('Temporal.PlainDateTime is escaped ignoring the time zone', () => { }); test('Temporal.PlainDate is escaped as a DATE literal', () => { - if (!Temporal) return; - assert.strictEqual( escape(Temporal.PlainDate.from('2012-05-07')), "'2012-05-07'" @@ -67,7 +48,17 @@ test('Temporal.PlainDate is escaped as a DATE literal', () => { }); test('Temporal.PlainTime is escaped as a TIME literal', () => { - if (!Temporal) return; - assert.strictEqual(escape(Temporal.PlainTime.from('11:42:03')), "'11:42:03'"); }); + +test('Temporal types without a MySQL equivalent fall back to their ISO string', () => { + assert.strictEqual( + escape(Temporal.Duration.from({ hours: 2, minutes: 30 })), + "'PT2H30M'" + ); + assert.strictEqual( + escape(Temporal.PlainYearMonth.from('2012-05')), + "'2012-05'" + ); + assert.strictEqual(escape(Temporal.PlainMonthDay.from('05-07')), "'05-07'"); +}); diff --git a/tsconfig.json b/tsconfig.json index 800eae4..1b8d8a9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "include": ["src"], "compilerOptions": { "target": "ES2018", - "lib": ["ES2018"], + "lib": ["ES2018", "ESNext.Temporal"], "module": "CommonJS", "moduleResolution": "Node", "ignoreDeprecations": "6.0", From 9053242bc8f10a999a43bfb62fcd144cdebf8192 Mon Sep 17 00:00:00 2001 From: Pieter Develtere Date: Sun, 12 Jul 2026 12:16:51 +0200 Subject: [PATCH 3/4] refactor: simplify temporalToString with structural narrowing --- src/index.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index 14a2210..1bed451 100644 --- a/src/index.ts +++ b/src/index.ts @@ -460,22 +460,16 @@ export const temporalToString = ( value: TemporalValue, timezone?: Timezone ): string => { - switch (Object.prototype.toString.call(value)) { - case '[object Temporal.Instant]': - case '[object Temporal.ZonedDateTime]': - return dateToString( - new Date( - (value as Temporal.Instant | Temporal.ZonedDateTime).epochMilliseconds - ), - timezone || 'local' - ); - case '[object Temporal.PlainDateTime]': - case '[object Temporal.PlainDate]': - case '[object Temporal.PlainTime]': - return escapeString(value.toString().replace('T', ' ')); - default: - return escapeString(value.toString()); - } + /** Absolute times share the `Date` path so `timezone` keeps its meaning */ + if ('epochMilliseconds' in value) + return dateToString(new Date(value.epochMilliseconds), timezone || 'local'); + + /** PlainDateTime is the only ISO form using the `T` separator; MySQL wants a space */ + if (value[Symbol.toStringTag] === 'Temporal.PlainDateTime') + return escapeString(value.toString().replace('T', ' ')); + + /** Remaining ISO forms are already valid literals (DATE, TIME) or have no MySQL equivalent */ + return escapeString(value.toString()); }; export const escapeId = ( From b82ca06f6dce25837fae8477d783b184db24895d Mon Sep 17 00:00:00 2001 From: Pieter Develtere Date: Sun, 12 Jul 2026 12:21:15 +0200 Subject: [PATCH 4/4] refactor: remove comments entirely, in line with codebase --- src/index.ts | 3 --- src/types.ts | 1 - 2 files changed, 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1bed451..1b4f295 100644 --- a/src/index.ts +++ b/src/index.ts @@ -460,15 +460,12 @@ export const temporalToString = ( value: TemporalValue, timezone?: Timezone ): string => { - /** Absolute times share the `Date` path so `timezone` keeps its meaning */ if ('epochMilliseconds' in value) return dateToString(new Date(value.epochMilliseconds), timezone || 'local'); - /** PlainDateTime is the only ISO form using the `T` separator; MySQL wants a space */ if (value[Symbol.toStringTag] === 'Temporal.PlainDateTime') return escapeString(value.toString().replace('T', ' ')); - /** Remaining ISO forms are already valid literals (DATE, TIME) or have no MySQL equivalent */ return escapeString(value.toString()); }; diff --git a/src/types.ts b/src/types.ts index 57887c6..45593a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,7 +2,6 @@ export type Raw = { toSqlString(): string; }; -/** Temporal values accepted by `escape()` */ export type TemporalValue = | Temporal.Instant | Temporal.ZonedDateTime