Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/connectors/__tests__/postgres-type-parsers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import pg from 'pg';
import { postgresTypeParsers, VERBATIM_DATE_TIME_OIDS } from '../postgres/type-parsers.js';

const OID_DATE = 1082;
const OID_TIMESTAMP = 1114;
const OID_TIMESTAMPTZ = 1184;
const OID_TIME = 1083;
const OID_INT4 = 23;
const OID_TIMESTAMP_ARRAY = 1115;

// Regression test for https://github.com/bytebase/dbhub/issues/416:
// `timestamp without time zone` and `date` values were parsed into JavaScript
// Dates in the host's local timezone, so the JSON output was shifted by the
// host's UTC offset. The parsers below are pure functions, so the behavior can
// be checked without a database.
describe('PostgreSQL date/time type parsers', () => {
it('returns timestamp without time zone verbatim, including microseconds', () => {
const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP, 'text');
expect(parse('2026-01-01 12:00:00')).toBe('2026-01-01 12:00:00');
expect(parse('2026-01-01 12:00:00.123456')).toBe('2026-01-01 12:00:00.123456');
});

it('returns date verbatim instead of a host-local midnight instant', () => {
const parse = postgresTypeParsers.getTypeParser(OID_DATE, 'text');
expect(parse('2026-01-01')).toBe('2026-01-01');
});

it('returns timestamp with time zone verbatim, preserving the session offset', () => {
const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMPTZ, 'text');
expect(parse('2026-01-01 12:00:00+00')).toBe('2026-01-01 12:00:00+00');
});

it('returns array element text verbatim for timestamp[]', () => {
const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP_ARRAY, 'text');
expect(parse('{"2026-01-01 12:00:00"}')).toBe('{"2026-01-01 12:00:00"}');
});

it('is independent of the host timezone', () => {
const originalTz = process.env.TZ;
process.env.TZ = 'America/Chicago';
try {
const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP, 'text');
// JSON.stringify is what the response formatter applies to row values.
expect(JSON.stringify(parse('2026-01-01 12:00:00'))).toBe('"2026-01-01 12:00:00"');
} finally {
if (originalTz === undefined) delete process.env.TZ;
else process.env.TZ = originalTz;
}
});

it('defaults to the text format when none is given', () => {
const parse = postgresTypeParsers.getTypeParser(OID_TIMESTAMP);
expect(parse('2026-01-01 12:00:00')).toBe('2026-01-01 12:00:00');
});

it('delegates every other type to the default pg-types parsers', () => {
expect(postgresTypeParsers.getTypeParser(OID_INT4, 'text')('42')).toBe(42);
expect(postgresTypeParsers.getTypeParser(OID_TIME, 'text')('12:00:00.123456')).toBe('12:00:00.123456');
expect(postgresTypeParsers.getTypeParser(OID_INT4, 'binary')).toBe(pg.types.getTypeParser(OID_INT4, 'binary'));
});

it('does not mutate the process-wide pg.types registry', () => {
for (const oid of VERBATIM_DATE_TIME_OIDS) {
expect(pg.types.getTypeParser(oid, 'text')).not.toBe(postgresTypeParsers.getTypeParser(oid, 'text'));
}
expect(pg.types.getTypeParser(OID_TIMESTAMP, 'text')('2026-01-01 12:00:00')).toBeInstanceOf(Date);
});
});
19 changes: 19 additions & 0 deletions src/connectors/__tests__/postgres.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,25 @@ describe('PostgreSQL Connector Integration Tests', () => {
expect(result.resultSets[0].rows[0].array_val).toBeDefined();
});

it('should return date/timestamp values verbatim, not shifted by the host timezone', async () => {
// Regression test for https://github.com/bytebase/dbhub/issues/416
const result = await postgresTest.connector.executeSQL(
`SELECT
'2026-01-01 12:00:00.123456'::timestamp AS naive,
'2026-01-01 12:00:00+00'::timestamptz AS aware,
'2026-01-01'::date AS day,
ARRAY['2026-01-01 12:00:00'::timestamp] AS naive_arr`,
{}
);

const row = result.resultSets[0].rows[0];
expect(row.naive).toBe('2026-01-01 12:00:00.123456');
expect(row.day).toBe('2026-01-01');
expect(typeof row.aware).toBe('string');
expect(row.aware).toMatch(/^2026-01-01 /);
expect(row.naive_arr).toBe('{"2026-01-01 12:00:00"}');
});

it('should return comment for views via getTableComment', async () => {
const comment = await postgresTest.connector.getTableComment!('active_users');
expect(comment).toBe('Users aged 25 or older');
Expand Down
5 changes: 5 additions & 0 deletions src/connectors/postgres/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
import { quoteIdentifier } from "../../utils/identifier-quoter.js";
import { splitSQLStatements } from "../../utils/sql-parser.js";
import { FailedToReadCertificate } from "./failed-to-read-certificate.js";
import { postgresTypeParsers } from "./type-parsers.js";
import { closeQuietly } from "../../utils/resource-cleanup.js";

const POSTGRES_CLIENT_QUERY_TIMEOUT_GRACE_MS = 5_000;
Expand Down Expand Up @@ -182,6 +183,10 @@ export class PostgresConnector implements Connector {
try {
const poolConfig = await this.dsnParser.parse(dsn, config);

// Return date/timestamp values as the server's verbatim text so they are
// not shifted by the host's local timezone (see type-parsers.ts).
poolConfig.types = postgresTypeParsers;

// SDK-level readonly enforcement: Set default_transaction_read_only for the entire connection
if (config?.readonly) {
poolConfig.options = (poolConfig.options || '') + ' -c default_transaction_read_only=on';
Expand Down
43 changes: 43 additions & 0 deletions src/connectors/postgres/type-parsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import pg from "pg";

/**
* PostgreSQL type OIDs whose values DBHub returns as the server's verbatim
* text instead of letting node-postgres convert them to JavaScript Dates.
*
* pg-types parses `timestamp without time zone` and `date` with the
* multi-argument Date constructor, i.e. in the DBHub host's local timezone.
* The JSON serializer then renders that Date via toISOString(), so a stored
* wall-clock value of `2026-01-01 12:00:00` comes back as
* `2026-01-01T18:00:00.000Z` on a host running in America/Chicago: the host
* offset is baked into the output, a `Z` suffix makes it look authoritative,
* and sub-millisecond precision is dropped. `timestamptz` is parsed via
* Date.UTC and so was never shifted, but it is included here so all three
* date/time types render consistently as the text psql would show.
*
* See https://github.com/bytebase/dbhub/issues/416
*/
export const VERBATIM_DATE_TIME_OIDS: ReadonlySet<number> = new Set([
1082, // date
1114, // timestamp without time zone
1184, // timestamp with time zone
1182, // date[]
1115, // timestamp[]
1185, // timestamptz[]
]);

const passthrough = (value: string): string => value;

/**
* Type parser configuration for the connection pool. Scoped to DBHub's pool
* via `PoolConfig.types` rather than mutating the process-wide `pg.types`
* registry, so other consumers of node-postgres in the same process keep the
* default behavior.
*/
export const postgresTypeParsers: pg.CustomTypesConfig = {
getTypeParser(oid: number, format?: "text" | "binary"): any {
if (VERBATIM_DATE_TIME_OIDS.has(oid) && (format === undefined || format === "text")) {
return passthrough;
}
return pg.types.getTypeParser(oid, format as any);
},
};
Loading