mirror of
https://github.com/oven-sh/bun
synced 2026-02-09 10:28:47 +00:00
Fix PostgreSQL TIME and TIMETZ binary format handling (#22354)
## Summary - Fixes binary format handling for PostgreSQL TIME and TIMETZ data types - Resolves issue where time values were returned as garbled binary data with null bytes ## Problem When PostgreSQL returns TIME or TIMETZ columns in binary format, Bun.sql was not properly converting them from their binary representation (microseconds since midnight) to readable time strings. This resulted in corrupted output like `\u0000\u0000\u0000\u0000\u0076` instead of proper time values like `09:00:00`. ## Solution Added proper binary format decoding for: - **TIME (OID 1083)**: Converts 8 bytes of microseconds since midnight to `HH:MM:SS.ffffff` format - **TIMETZ (OID 1266)**: Converts 8 bytes of microseconds + 4 bytes of timezone offset to `HH:MM:SS.ffffff±HH:MM` format ## Changes - Added binary format handling in `src/sql/postgres/DataCell.zig` for TIME and TIMETZ types - Added `InvalidTimeFormat` error to `AnyPostgresError` error set - Properly formats microseconds with trailing zero removal - Handles timezone offsets correctly (PostgreSQL uses negative values for positive UTC offsets) ## Test plan Added comprehensive tests in `test/js/bun/sql/postgres-time.test.ts`: - [x] TIME and TIMETZ column values with various formats - [x] NULL handling - [x] Array types (TIME[] and TIMETZ[]) - [x] JSONB structures containing time strings - [x] Verification that no binary/null bytes appear in output All tests pass locally with PostgreSQL. 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -486,4 +486,56 @@ extern "C" void JSC__putDirectOffset(JSC::VM* vm, JSC::EncodedJSValue object, ui
|
||||
JSValue::decode(object).getObject()->putDirectOffset(*vm, offset, JSValue::decode(value));
|
||||
}
|
||||
extern "C" uint32_t JSC__JSObject__maxInlineCapacity = JSC::JSFinalObject::maxInlineCapacity;
|
||||
|
||||
// PostgreSQL time formatting helpers - following WebKit's pattern
|
||||
extern "C" size_t Postgres__formatTime(int64_t microseconds, char* buffer, size_t bufferSize)
|
||||
{
|
||||
// Convert microseconds since midnight to time components
|
||||
int64_t totalSeconds = microseconds / 1000000;
|
||||
|
||||
int hours = static_cast<int>(totalSeconds / 3600);
|
||||
int minutes = static_cast<int>((totalSeconds % 3600) / 60);
|
||||
int seconds = static_cast<int>(totalSeconds % 60);
|
||||
|
||||
// Format following SQL standard time format
|
||||
int charactersWritten = snprintf(buffer, bufferSize, "%02d:%02d:%02d", hours, minutes, seconds);
|
||||
|
||||
// Add fractional seconds if present (PostgreSQL supports microsecond precision)
|
||||
if (microseconds % 1000000 != 0) {
|
||||
// PostgreSQL displays fractional seconds only when non-zero
|
||||
int us = microseconds % 1000000;
|
||||
charactersWritten = snprintf(buffer, bufferSize, "%02d:%02d:%02d.%06d",
|
||||
hours, minutes, seconds, us);
|
||||
// Trim trailing zeros for cleaner output
|
||||
while (buffer[charactersWritten - 1] == '0')
|
||||
charactersWritten--;
|
||||
if (buffer[charactersWritten - 1] == '.')
|
||||
charactersWritten--;
|
||||
buffer[charactersWritten] = '\0';
|
||||
}
|
||||
|
||||
ASSERT(charactersWritten > 0 && static_cast<unsigned>(charactersWritten) < bufferSize);
|
||||
return charactersWritten;
|
||||
}
|
||||
|
||||
extern "C" size_t Postgres__formatTimeTz(int64_t microseconds, int32_t tzOffsetSeconds, char* buffer, size_t bufferSize)
|
||||
{
|
||||
// Format time part first
|
||||
size_t timeLen = Postgres__formatTime(microseconds, buffer, bufferSize);
|
||||
|
||||
// PostgreSQL convention: negative offset means positive UTC offset
|
||||
// Add timezone in ±HH or ±HH:MM format
|
||||
int tzHours = abs(tzOffsetSeconds) / 3600;
|
||||
int tzMinutes = (abs(tzOffsetSeconds) % 3600) / 60;
|
||||
|
||||
int tzLen = snprintf(buffer + timeLen, bufferSize - timeLen, "%c%02d",
|
||||
tzOffsetSeconds <= 0 ? '+' : '-', tzHours);
|
||||
|
||||
if (tzMinutes != 0) {
|
||||
tzLen = snprintf(buffer + timeLen, bufferSize - timeLen, "%c%02d:%02d",
|
||||
tzOffsetSeconds <= 0 ? '+' : '-', tzHours, tzMinutes);
|
||||
}
|
||||
|
||||
return timeLen + tzLen;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub const AnyPostgresError = error{
|
||||
InvalidQueryBinding,
|
||||
InvalidServerKey,
|
||||
InvalidServerSignature,
|
||||
InvalidTimeFormat,
|
||||
JSError,
|
||||
MultidimensionalArrayNotSupportedYet,
|
||||
NullsInArrayNotSupportedYet,
|
||||
@@ -90,6 +91,7 @@ pub fn postgresErrorToJS(globalObject: *jsc.JSGlobalObject, message: ?[]const u8
|
||||
error.InvalidQueryBinding => "ERR_POSTGRES_INVALID_QUERY_BINDING",
|
||||
error.InvalidServerKey => "ERR_POSTGRES_INVALID_SERVER_KEY",
|
||||
error.InvalidServerSignature => "ERR_POSTGRES_INVALID_SERVER_SIGNATURE",
|
||||
error.InvalidTimeFormat => "ERR_POSTGRES_INVALID_TIME_FORMAT",
|
||||
error.MultidimensionalArrayNotSupportedYet => "ERR_POSTGRES_MULTIDIMENSIONAL_ARRAY_NOT_SUPPORTED_YET",
|
||||
error.NullsInArrayNotSupportedYet => "ERR_POSTGRES_NULLS_IN_ARRAY_NOT_SUPPORTED_YET",
|
||||
error.Overflow => "ERR_POSTGRES_OVERFLOW",
|
||||
|
||||
@@ -601,6 +601,38 @@ pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8,
|
||||
return SQLDataCell{ .tag = .date, .value = .{ .date = try str.parseDate(globalObject) } };
|
||||
}
|
||||
},
|
||||
.time, .timetz => |tag| {
|
||||
if (bytes.len == 0) {
|
||||
return SQLDataCell{ .tag = .null, .value = .{ .null = 0 } };
|
||||
}
|
||||
if (binary) {
|
||||
if (tag == .time and bytes.len == 8) {
|
||||
// PostgreSQL sends time as microseconds since midnight in binary format
|
||||
const microseconds = @byteSwap(@as(i64, @bitCast(bytes[0..8].*)));
|
||||
|
||||
// Use C++ helper for formatting
|
||||
var buffer: [32]u8 = undefined;
|
||||
const len = Postgres__formatTime(microseconds, &buffer, buffer.len);
|
||||
|
||||
return SQLDataCell{ .tag = .string, .value = .{ .string = bun.String.cloneUTF8(buffer[0..len]).value.WTFStringImpl }, .free_value = 1 };
|
||||
} else if (tag == .timetz and bytes.len == 12) {
|
||||
// PostgreSQL sends timetz as microseconds since midnight (8 bytes) + timezone offset in seconds (4 bytes)
|
||||
const microseconds = @byteSwap(@as(i64, @bitCast(bytes[0..8].*)));
|
||||
const tz_offset_seconds = @byteSwap(@as(i32, @bitCast(bytes[8..12].*)));
|
||||
|
||||
// Use C++ helper for formatting with timezone
|
||||
var buffer: [48]u8 = undefined;
|
||||
const len = Postgres__formatTimeTz(microseconds, tz_offset_seconds, &buffer, buffer.len);
|
||||
|
||||
return SQLDataCell{ .tag = .string, .value = .{ .string = bun.String.cloneUTF8(buffer[0..len]).value.WTFStringImpl }, .free_value = 1 };
|
||||
} else {
|
||||
return error.InvalidBinaryData;
|
||||
}
|
||||
} else {
|
||||
// Text format - just return as string
|
||||
return SQLDataCell{ .tag = .string, .value = .{ .string = if (bytes.len > 0) bun.String.cloneUTF8(bytes).value.WTFStringImpl else null }, .free_value = 1 };
|
||||
}
|
||||
},
|
||||
|
||||
.bytea => {
|
||||
if (binary) {
|
||||
@@ -951,6 +983,10 @@ pub const Putter = struct {
|
||||
|
||||
const debug = bun.Output.scoped(.Postgres, .visible);
|
||||
|
||||
// External C++ formatting functions
|
||||
extern fn Postgres__formatTime(microseconds: i64, buffer: [*]u8, bufferSize: usize) usize;
|
||||
extern fn Postgres__formatTimeTz(microseconds: i64, tzOffsetSeconds: i32, buffer: [*]u8, bufferSize: usize) usize;
|
||||
|
||||
const PostgresCachedStructure = @import("../shared/CachedStructure.zig");
|
||||
const protocol = @import("./PostgresProtocol.zig");
|
||||
const std = @import("std");
|
||||
|
||||
199
test/js/bun/sql/postgres-time.test.ts
Normal file
199
test/js/bun/sql/postgres-time.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { SQL } from "bun";
|
||||
import { expect, test } from "bun:test";
|
||||
import { bunEnv } from "harness";
|
||||
|
||||
// Skip test if PostgreSQL is not available
|
||||
const isPostgresAvailable = () => {
|
||||
try {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ["pg_isready", "-h", "localhost"],
|
||||
env: bunEnv,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
});
|
||||
return result.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
test.skipIf(!isPostgresAvailable())("PostgreSQL TIME and TIMETZ types are handled correctly", async () => {
|
||||
const db = new SQL("postgres://postgres@localhost/postgres");
|
||||
|
||||
try {
|
||||
// Create test table with time and timetz columns
|
||||
await db`DROP TABLE IF EXISTS bun_time_test`;
|
||||
await db`
|
||||
CREATE TABLE bun_time_test (
|
||||
id SERIAL PRIMARY KEY,
|
||||
regular_time TIME,
|
||||
time_with_tz TIMETZ
|
||||
)
|
||||
`;
|
||||
|
||||
// Insert test data with various time values
|
||||
await db`
|
||||
INSERT INTO bun_time_test (regular_time, time_with_tz) VALUES
|
||||
('09:00:00', '09:00:00+00'),
|
||||
('10:30:45.123456', '10:30:45.123456-05'),
|
||||
('23:59:59.999999', '23:59:59.999999+08:30'),
|
||||
('00:00:00', '00:00:00-12:00'),
|
||||
(NULL, NULL)
|
||||
`;
|
||||
|
||||
// Query the data
|
||||
const result = await db`
|
||||
SELECT
|
||||
id,
|
||||
regular_time,
|
||||
time_with_tz
|
||||
FROM bun_time_test
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
// Verify that time values are returned as strings, not binary data
|
||||
expect(result[0].regular_time).toBe("09:00:00");
|
||||
expect(result[0].time_with_tz).toBe("09:00:00+00");
|
||||
|
||||
expect(result[1].regular_time).toBe("10:30:45.123456");
|
||||
expect(result[1].time_with_tz).toBe("10:30:45.123456-05");
|
||||
|
||||
expect(result[2].regular_time).toBe("23:59:59.999999");
|
||||
expect(result[2].time_with_tz).toBe("23:59:59.999999+08:30");
|
||||
|
||||
expect(result[3].regular_time).toBe("00:00:00");
|
||||
expect(result[3].time_with_tz).toBe("00:00:00-12");
|
||||
|
||||
// NULL values
|
||||
expect(result[4].regular_time).toBeNull();
|
||||
expect(result[4].time_with_tz).toBeNull();
|
||||
|
||||
// None of the values should contain null bytes
|
||||
for (const row of result) {
|
||||
if (row.regular_time) {
|
||||
expect(row.regular_time).not.toContain("\u0000");
|
||||
expect(typeof row.regular_time).toBe("string");
|
||||
}
|
||||
if (row.time_with_tz) {
|
||||
expect(row.time_with_tz).not.toContain("\u0000");
|
||||
expect(typeof row.time_with_tz).toBe("string");
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await db`DROP TABLE bun_time_test`;
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!isPostgresAvailable())("PostgreSQL TIME array types are handled correctly", async () => {
|
||||
const db = new SQL("postgres://postgres@localhost/postgres");
|
||||
|
||||
try {
|
||||
// Create test table with time array
|
||||
await db`DROP TABLE IF EXISTS bun_time_array_test`;
|
||||
await db`
|
||||
CREATE TABLE bun_time_array_test (
|
||||
id SERIAL PRIMARY KEY,
|
||||
time_values TIME[],
|
||||
timetz_values TIMETZ[]
|
||||
)
|
||||
`;
|
||||
|
||||
// Insert test data
|
||||
await db`
|
||||
INSERT INTO bun_time_array_test (time_values, timetz_values) VALUES
|
||||
(ARRAY['09:00:00'::time, '17:00:00'::time], ARRAY['09:00:00+00'::timetz, '17:00:00-05'::timetz]),
|
||||
(ARRAY['10:30:00'::time, '18:30:00'::time, '20:00:00'::time], ARRAY['10:30:00+02'::timetz]),
|
||||
(NULL, NULL),
|
||||
(ARRAY[]::time[], ARRAY[]::timetz[])
|
||||
`;
|
||||
|
||||
const result = await db`
|
||||
SELECT
|
||||
id,
|
||||
time_values,
|
||||
timetz_values
|
||||
FROM bun_time_array_test
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
// Verify array values
|
||||
expect(result[0].time_values).toEqual(["09:00:00", "17:00:00"]);
|
||||
expect(result[0].timetz_values).toEqual(["09:00:00+00", "17:00:00-05"]);
|
||||
|
||||
expect(result[1].time_values).toEqual(["10:30:00", "18:30:00", "20:00:00"]);
|
||||
expect(result[1].timetz_values).toEqual(["10:30:00+02"]);
|
||||
|
||||
expect(result[2].time_values).toBeNull();
|
||||
expect(result[2].timetz_values).toBeNull();
|
||||
|
||||
expect(result[3].time_values).toEqual([]);
|
||||
expect(result[3].timetz_values).toEqual([]);
|
||||
|
||||
// Ensure no binary data in arrays
|
||||
for (const row of result) {
|
||||
if (row.time_values && Array.isArray(row.time_values)) {
|
||||
for (const time of row.time_values) {
|
||||
expect(typeof time).toBe("string");
|
||||
expect(time).not.toContain("\u0000");
|
||||
}
|
||||
}
|
||||
if (row.timetz_values && Array.isArray(row.timetz_values)) {
|
||||
for (const time of row.timetz_values) {
|
||||
expect(typeof time).toBe("string");
|
||||
expect(time).not.toContain("\u0000");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await db`DROP TABLE bun_time_array_test`;
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!isPostgresAvailable())("PostgreSQL TIME in nested structures (JSONB) works correctly", async () => {
|
||||
const db = new SQL("postgres://postgres@localhost/postgres");
|
||||
|
||||
try {
|
||||
await db`DROP TABLE IF EXISTS bun_time_json_test`;
|
||||
await db`
|
||||
CREATE TABLE bun_time_json_test (
|
||||
id SERIAL PRIMARY KEY,
|
||||
schedule JSONB
|
||||
)
|
||||
`;
|
||||
|
||||
// Insert test data with times in JSONB
|
||||
await db`
|
||||
INSERT INTO bun_time_json_test (schedule) VALUES
|
||||
('{"dayOfWeek": 1, "timeBlocks": [{"startTime": "09:00:00", "endTime": "17:00:00"}]}'::jsonb),
|
||||
('{"dayOfWeek": 2, "timeBlocks": [{"startTime": "10:30:00", "endTime": "18:30:00"}]}'::jsonb)
|
||||
`;
|
||||
|
||||
const result = await db`
|
||||
SELECT
|
||||
id,
|
||||
schedule
|
||||
FROM bun_time_json_test
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
// Verify JSONB with time strings
|
||||
expect(result[0].schedule.dayOfWeek).toBe(1);
|
||||
expect(result[0].schedule.timeBlocks[0].startTime).toBe("09:00:00");
|
||||
expect(result[0].schedule.timeBlocks[0].endTime).toBe("17:00:00");
|
||||
|
||||
expect(result[1].schedule.dayOfWeek).toBe(2);
|
||||
expect(result[1].schedule.timeBlocks[0].startTime).toBe("10:30:00");
|
||||
expect(result[1].schedule.timeBlocks[0].endTime).toBe("18:30:00");
|
||||
|
||||
// Clean up
|
||||
await db`DROP TABLE bun_time_json_test`;
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user