mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
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>
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user