diff --git a/packages/pg/lib/utils.js b/packages/pg/lib/utils.js index ba51c82c8..fef8245a3 100644 --- a/packages/pg/lib/utils.js +++ b/packages/pg/lib/utils.js @@ -93,7 +93,15 @@ function prepareObject(val, seen) { } function dateToString(date) { - let offset = -date.getTimezoneOffset() + let offset = Math.trunc(-date.getTimezoneOffset()) * 60 + // getTimezoneOffset() omits the seconds in historical offsets. Keep the + // local clock fields and recover those seconds only when they differ from UTC. + if (date.getSeconds() !== date.getUTCSeconds()) { + const localDate = new Date(date.getTime()) + localDate.setUTCFullYear(date.getFullYear(), date.getMonth(), date.getDate()) + localDate.setUTCHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds()) + offset += ((localDate.getTime() - date.getTime()) / 1000) % 60 + } let year = date.getFullYear() const isBCYear = year < 1 @@ -121,7 +129,9 @@ function dateToString(date) { ret += '+' } - ret += String(Math.floor(offset / 60)).padStart(2, '0') + ':' + String(offset % 60).padStart(2, '0') + ret += + String(Math.floor(offset / 3600)).padStart(2, '0') + ':' + String(Math.floor(offset / 60) % 60).padStart(2, '0') + if (offset % 60) ret += ':' + String(offset % 60).padStart(2, '0') if (isBCYear) ret += ' BC' return ret } diff --git a/packages/pg/test/integration/client/historical-timezone-tests.js b/packages/pg/test/integration/client/historical-timezone-tests.js new file mode 100644 index 000000000..14a11407d --- /dev/null +++ b/packages/pg/test/integration/client/historical-timezone-tests.js @@ -0,0 +1,56 @@ +'use strict' +const helper = require('./test-helper') +const assert = require('assert') +const suite = new helper.Suite() + +const cases = [ + ['Europe/Paris', '1890-01-01T12:00:00.123Z'], + ['America/New_York', '1880-01-01T12:00:00.123Z'], + ['Africa/Monrovia', '1970-01-01T00:00:00.000Z'], +] + +for (const [zone, iso] of cases) { + suite.test(`historical timestamptz round trip in ${zone}`, async function () { + const oldTz = process.env.TZ + const client = new helper.pg.Client(helper.config) + try { + process.env.TZ = zone + await client.connect() + const date = new Date(iso) + const { rows } = await client.query( + 'SELECT $1::timestamptz AS val, $2::timestamptz AS control, $3::timestamptz[] AS vals', + [date, iso, [date, date]] + ) + assert.strictEqual(rows[0].control.getTime(), date.getTime()) + assert.strictEqual(rows[0].val.getTime(), date.getTime()) + assert.deepStrictEqual( + rows[0].vals.map((value) => value.getTime()), + [date.getTime(), date.getTime()] + ) + } finally { + await client.end() + if (oldTz === undefined) delete process.env.TZ + else process.env.TZ = oldTz + } + }) +} + +suite.test('historical Date keeps local fields for timestamp and date parameters', async function () { + const oldTz = process.env.TZ + const client = new helper.pg.Client(helper.config) + try { + process.env.TZ = 'Europe/Paris' + await client.connect() + const date = new Date('1890-01-01T12:00:00.123Z') + const { rows } = await client.query( + "SELECT to_char($1::timestamp, 'YYYY-MM-DD HH24:MI:SS.MS') AS val, $2::date::text AS day", + [date, date] + ) + assert.strictEqual(rows[0].val, '1890-01-01 12:09:21.123') + assert.strictEqual(rows[0].day, '1890-01-01') + } finally { + await client.end() + if (oldTz === undefined) delete process.env.TZ + else process.env.TZ = oldTz + } +}) diff --git a/packages/pg/test/unit/historical-date-tests.js b/packages/pg/test/unit/historical-date-tests.js new file mode 100644 index 000000000..dd309e91c --- /dev/null +++ b/packages/pg/test/unit/historical-date-tests.js @@ -0,0 +1,45 @@ +'use strict' +const helper = require('./test-helper') +const utils = require('../../lib/utils') +const defaults = require('../../lib/defaults') +const assert = require('assert') +const suite = new helper.Suite() + +const cases = [ + ['Europe/Paris', '1890-01-01T12:00:00.123Z', '1890-01-01T12:09:21.123+00:09:21'], + ['America/New_York', '1880-01-01T12:00:00.123Z', '1880-01-01T07:03:58.123-04:56:02'], + ['Asia/Kathmandu', '1890-01-01T12:00:00.123Z', '1890-01-01T17:41:16.123+05:41:16'], + ['Africa/Monrovia', '1970-01-01T00:00:00.000Z', '1969-12-31T23:15:30.000-00:44:30'], + ['Europe/Paris', '2014-02-01T11:11:01.007Z', '2014-02-01T12:11:01.007+01:00'], + ['UTC', '1890-01-01T12:00:00.123Z', '1890-01-01T12:00:00.123+00:00'], + ['Europe/Paris', '0000-01-01T12:00:00.123Z', '0001-01-01T12:09:21.123+00:09:21 BC'], +] + +for (const [zone, iso, expected] of cases) { + suite.test(`Date serialization in ${zone} at ${iso}`, function () { + const oldTz = process.env.TZ + try { + process.env.TZ = zone + const date = new Date(iso) + assert.strictEqual(utils.prepareValue(date), expected) + assert.strictEqual(date.toISOString(), iso) + } finally { + if (oldTz === undefined) delete process.env.TZ + else process.env.TZ = oldTz + } + }) +} + +suite.test('UTC Date serialization does not use the historical local offset', function () { + const oldTz = process.env.TZ + const oldParseInputDatesAsUTC = defaults.parseInputDatesAsUTC + try { + process.env.TZ = 'Africa/Monrovia' + defaults.parseInputDatesAsUTC = true + assert.strictEqual(utils.prepareValue(new Date(0)), '1970-01-01T00:00:00.000+00:00') + } finally { + defaults.parseInputDatesAsUTC = oldParseInputDatesAsUTC + if (oldTz === undefined) delete process.env.TZ + else process.env.TZ = oldTz + } +})