diff --git a/src/bun.js/bindings/SQLClient.cpp b/src/bun.js/bindings/SQLClient.cpp index 012bd68a77..a1f881876b 100644 --- a/src/bun.js/bindings/SQLClient.cpp +++ b/src/bun.js/bindings/SQLClient.cpp @@ -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(totalSeconds / 3600); + int minutes = static_cast((totalSeconds % 3600) / 60); + int seconds = static_cast(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(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; +} } diff --git a/src/sql/postgres/AnyPostgresError.zig b/src/sql/postgres/AnyPostgresError.zig index f2044b732e..e76fd4c02c 100644 --- a/src/sql/postgres/AnyPostgresError.zig +++ b/src/sql/postgres/AnyPostgresError.zig @@ -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", diff --git a/src/sql/postgres/DataCell.zig b/src/sql/postgres/DataCell.zig index e4d51ddacf..4ca56895c0 100644 --- a/src/sql/postgres/DataCell.zig +++ b/src/sql/postgres/DataCell.zig @@ -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"); diff --git a/test/js/bun/sql/postgres-time.test.ts b/test/js/bun/sql/postgres-time.test.ts new file mode 100644 index 0000000000..8d92d85ce2 --- /dev/null +++ b/test/js/bun/sql/postgres-time.test.ts @@ -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(); + } +});