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/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 93f1d1d..1b4f295 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, 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,6 +308,9 @@ const findSetKeyword = (sql: string, startFrom = 0): number => { const isDate = (value: unknown): value is Date => Object.prototype.toString.call(value) === '[object Date]'; +const isTemporal = (value: unknown): value is TemporalValue => + Object.prototype.toString.call(value).startsWith('[object Temporal.'); + const hasSqlString = (value: unknown): value is Raw => typeof value === 'object' && value !== null && @@ -453,6 +456,19 @@ export const dateToString = (date: Date, timezone: Timezone): string => { ); }; +export const temporalToString = ( + value: TemporalValue, + timezone?: Timezone +): string => { + if ('epochMilliseconds' in value) + return dateToString(new Date(value.epochMilliseconds), timezone || 'local'); + + if (value[Symbol.toStringTag] === 'Temporal.PlainDateTime') + return escapeString(value.toString().replace('T', ' ')); + + return escapeString(value.toString()); +}; + export const escapeId = ( value: SqlValue, forbidQualified?: boolean @@ -561,6 +577,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..45593a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,12 +2,23 @@ export type Raw = { toSqlString(): string; }; +export type TemporalValue = + | Temporal.Instant + | Temporal.ZonedDateTime + | Temporal.PlainDateTime + | Temporal.PlainDate + | Temporal.PlainTime + | Temporal.PlainYearMonth + | Temporal.PlainMonthDay + | Temporal.Duration; + export type SqlValue = | string | number | bigint | boolean | Date + | TemporalValue | Buffer | Uint8Array | Raw diff --git a/test/temporal.test.ts b/test/temporal.test.ts new file mode 100644 index 0000000..8482fe7 --- /dev/null +++ b/test/temporal.test.ts @@ -0,0 +1,64 @@ +import { Temporal as TemporalPolyfill } from '@js-temporal/polyfill'; +import { assert, test } from 'poku'; +import { escape } from '../src/index.ts'; + +/** + * 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) + */ +const Temporal = globalThis.Temporal ?? TemporalPolyfill; + +test('Temporal.Instant is escaped like a Date', () => { + 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', () => { + 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', () => { + 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', () => { + 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', () => { + assert.strictEqual( + escape(Temporal.PlainDate.from('2012-05-07')), + "'2012-05-07'" + ); +}); + +test('Temporal.PlainTime is escaped as a TIME literal', () => { + 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",