Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
5d661a4487 fix(sql): return JS number for PostgreSQL bigint values within safe integer range
When the `bigint` option is not set (the default), PostgreSQL int8/bigint
values that fit within Number.MAX_SAFE_INTEGER (±2^53-1) are now returned
as JavaScript numbers instead of strings. Values outside that range continue
to be returned as strings to avoid precision loss. This makes common
operations like COUNT(*) return numbers as users expect.

Fixes #22188

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-14 23:48:15 +00:00
2 changed files with 56 additions and 7 deletions

View File

@@ -402,10 +402,11 @@ fn parseArray(bytes: []const u8, bigint: bool, comptime arrayType: types.Tag, gl
}
switch (arrayType) {
.int8_array => {
const int8_val = std.fmt.parseInt(i64, element, 0) catch return error.UnsupportedArrayFormat;
if (bigint) {
try array.append(bun.default_allocator, SQLDataCell{ .tag = .int8, .value = .{ .int8 = std.fmt.parseInt(i64, element, 0) catch return error.UnsupportedArrayFormat } });
try array.append(bun.default_allocator, SQLDataCell{ .tag = .int8, .value = .{ .int8 = int8_val } });
} else {
try array.append(bun.default_allocator, SQLDataCell{ .tag = .string, .value = .{ .string = if (element.len > 0) bun.String.cloneUTF8(element).value.WTFStringImpl else null }, .free_value = 1 });
try array.append(bun.default_allocator, int8ToSafeCell(int8_val));
}
slice = trySlice(slice, current_idx);
continue;
@@ -456,6 +457,25 @@ fn parseArray(bytes: []const u8, bigint: bool, comptime arrayType: types.Tag, gl
return SQLDataCell{ .tag = .array, .value = .{ .array = .{ .ptr = array.items.ptr, .len = @truncate(array.items.len), .cap = @truncate(array.capacity) } } };
}
/// Returns an int8 value as a JS-safe number when possible, or falls back to string.
/// Values within Number.MAX_SAFE_INTEGER range (±2^53 - 1) are returned as numbers;
/// values outside that range are returned as strings to avoid precision loss.
fn int8ToSafeCell(value: i64) SQLDataCell {
const max_safe_int = (1 << 53) - 1;
const min_safe_int = -max_safe_int;
if (value >= min_safe_int and value <= max_safe_int) {
// Value fits safely in a JS number (f64 without precision loss)
if (value >= std.math.minInt(i32) and value <= std.math.maxInt(i32)) {
return SQLDataCell{ .tag = .int4, .value = .{ .int4 = @intCast(value) } };
}
return SQLDataCell{ .tag = .float8, .value = .{ .float8 = @floatFromInt(value) } };
}
// Outside safe integer range: return as string to avoid precision loss
var buf: [21]u8 = undefined;
const str = std.fmt.bufPrint(&buf, "{d}", .{value}) catch unreachable;
return SQLDataCell{ .tag = .string, .value = .{ .string = bun.String.cloneUTF8(str).value.WTFStringImpl }, .free_value = 1 };
}
pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8, globalObject: *jsc.JSGlobalObject) !SQLDataCell {
switch (oid) {
// TODO: .int2_array, .float8_array
@@ -530,13 +550,15 @@ pub fn fromBytes(binary: bool, bigint: bool, oid: types.Tag, bytes: []const u8,
return SQLDataCell{ .tag = .int4, .value = .{ .int4 = std.fmt.parseInt(i32, bytes, 0) catch 0 } };
}
},
// postgres when reading bigint as int8 it returns a string unless type: { bigint: postgres.BigInt is set
.int8 => {
const value: i64 = if (binary)
try parseBinary(.int8, i64, bytes)
else
std.fmt.parseInt(i64, bytes, 0) catch 0;
if (bigint) {
// .int8 is a 64-bit integer always string
return SQLDataCell{ .tag = .int8, .value = .{ .int8 = std.fmt.parseInt(i64, bytes, 0) catch 0 } };
return SQLDataCell{ .tag = .int8, .value = .{ .int8 = value } };
} else {
return SQLDataCell{ .tag = .string, .value = .{ .string = if (bytes.len > 0) bun.String.cloneUTF8(bytes).value.WTFStringImpl else null }, .free_value = 1 };
return int8ToSafeCell(value);
}
},
.float8 => {

View File

@@ -2775,8 +2775,35 @@ if (isDockerEnabled()) {
// return ['select 1', result]
// })
test("bigint is returned as String", async () => {
test("bigint outside safe integer range is returned as String", async () => {
expect(typeof (await sql`select 9223372036854777 as x`)[0].x).toBe("string");
expect((await sql`select 9223372036854777 as x`)[0].x).toBe("9223372036854777");
});
test("bigint within safe integer range is returned as Number", async () => {
// Values within Number.MAX_SAFE_INTEGER should be numbers, not strings
const result = (await sql`select 9007199254740991::int8 as x`)[0].x;
expect(typeof result).toBe("number");
expect(result).toBe(9007199254740991);
// Negative safe integer boundary
const result2 = (await sql`select (-9007199254740991)::int8 as x`)[0].x;
expect(typeof result2).toBe("number");
expect(result2).toBe(-9007199254740991);
// COUNT(*) returns bigint - should be a number for typical counts
const result3 = (await sql`select count(*) from information_schema.tables`)[0].count;
expect(typeof result3).toBe("number");
});
test("bigint just outside safe integer range is returned as String", async () => {
const result = (await sql`select 9007199254740992::int8 as x`)[0].x;
expect(typeof result).toBe("string");
expect(result).toBe("9007199254740992");
const result2 = (await sql`select (-9007199254740992)::int8 as x`)[0].x;
expect(typeof result2).toBe("string");
expect(result2).toBe("-9007199254740992");
});
test("bigint is returned as BigInt", async () => {