mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 19:08:50 +00:00
## Summary - Fixed MySQL VARCHAR/CHAR/TEXT columns with binary collations (like `utf8mb4_bin`) being incorrectly returned as `Buffer` instead of `string` - The fix checks for `character_set == 63` (binary collation) in addition to the BINARY flag to properly distinguish true binary types Fixes #26063 ## Root Cause PR #26011 introduced a fix for binary column handling that checked `column.flags.BINARY` to determine if data should be returned as `Buffer`. However, MySQL sets the BINARY flag on VARCHAR/CHAR/TEXT columns with binary collations (like `utf8mb4_bin`) even though they should return strings. The proper way to detect true binary types (BINARY, VARBINARY, BLOB) is to check if `character_set == 63` (the "binary" collation), not just the BINARY flag. ## Changes 1. **Text Protocol** (`ResultSet.zig:143-148`): Updated binary check to `column.flags.BINARY and column.character_set == 63` 2. **Binary Protocol** (`DecodeBinaryValue.zig:154-156`): Added `character_set` parameter and updated binary check ## Test plan - [ ] Added regression test `test/regression/issue/26063.test.ts` that tests VARCHAR, CHAR, and TEXT columns with `utf8mb4_bin` collation return strings - [ ] Test verifies that true BINARY/VARBINARY/BLOB columns still return Buffers 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Bot <claude-bot@bun.sh> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
277 lines
12 KiB
Zig
277 lines
12 KiB
Zig
pub const Header = @import("./ResultSetHeader.zig");
|
|
|
|
pub const Row = struct {
|
|
values: []SQLDataCell = &[_]SQLDataCell{},
|
|
columns: []const ColumnDefinition41,
|
|
binary: bool = false,
|
|
raw: bool = false,
|
|
bigint: bool = false,
|
|
globalObject: *jsc.JSGlobalObject,
|
|
|
|
pub fn toJS(this: *Row, globalObject: *jsc.JSGlobalObject, array: JSValue, structure: JSValue, flags: SQLDataCell.Flags, result_mode: SQLQueryResultMode, cached_structure: ?CachedStructure) !JSValue {
|
|
var names: ?[*]jsc.JSObject.ExternColumnIdentifier = null;
|
|
var names_count: u32 = 0;
|
|
if (cached_structure) |c| {
|
|
if (c.fields) |f| {
|
|
names = f.ptr;
|
|
names_count = @truncate(f.len);
|
|
}
|
|
}
|
|
|
|
return SQLDataCell.constructObjectFromDataCell(
|
|
globalObject,
|
|
array,
|
|
structure,
|
|
this.values.ptr,
|
|
@truncate(this.values.len),
|
|
flags,
|
|
@intFromEnum(result_mode),
|
|
names,
|
|
names_count,
|
|
);
|
|
}
|
|
|
|
pub fn deinit(this: *Row, allocator: std.mem.Allocator) void {
|
|
for (this.values) |*value| {
|
|
value.deinit();
|
|
}
|
|
allocator.free(this.values);
|
|
|
|
// this.columns is intentionally left out.
|
|
}
|
|
|
|
pub fn decodeInternal(this: *Row, allocator: std.mem.Allocator, comptime Context: type, reader: NewReader(Context)) AnyMySQLError.Error!void {
|
|
if (this.binary) {
|
|
try this.decodeBinary(allocator, Context, reader);
|
|
} else {
|
|
try this.decodeText(allocator, Context, reader);
|
|
}
|
|
}
|
|
|
|
fn parseValueAndSetCell(this: *Row, cell: *SQLDataCell, column: *const ColumnDefinition41, value: *const Data) void {
|
|
debug("parseValueAndSetCell: {s} {s}", .{ @tagName(column.column_type), value.slice() });
|
|
return switch (column.column_type) {
|
|
.MYSQL_TYPE_FLOAT, .MYSQL_TYPE_DOUBLE => {
|
|
const val: f64 = bun.parseDouble(value.slice()) catch std.math.nan(f64);
|
|
cell.* = SQLDataCell{ .tag = .float8, .value = .{ .float8 = val } };
|
|
},
|
|
.MYSQL_TYPE_TINY, .MYSQL_TYPE_SHORT => {
|
|
if (column.flags.UNSIGNED) {
|
|
const val: u16 = std.fmt.parseInt(u16, value.slice(), 10) catch 0;
|
|
cell.* = SQLDataCell{ .tag = .uint4, .value = .{ .uint4 = val } };
|
|
} else {
|
|
const val: i16 = std.fmt.parseInt(i16, value.slice(), 10) catch 0;
|
|
cell.* = SQLDataCell{ .tag = .int4, .value = .{ .int4 = val } };
|
|
}
|
|
},
|
|
.MYSQL_TYPE_LONG => {
|
|
if (column.flags.UNSIGNED) {
|
|
const val: u32 = std.fmt.parseInt(u32, value.slice(), 10) catch 0;
|
|
cell.* = SQLDataCell{ .tag = .uint4, .value = .{ .uint4 = val } };
|
|
} else {
|
|
const val: i32 = std.fmt.parseInt(i32, value.slice(), 10) catch std.math.minInt(i32);
|
|
cell.* = SQLDataCell{ .tag = .int4, .value = .{ .int4 = val } };
|
|
}
|
|
},
|
|
.MYSQL_TYPE_INT24 => {
|
|
if (column.flags.UNSIGNED) {
|
|
const val: u24 = std.fmt.parseInt(u24, value.slice(), 10) catch 0;
|
|
cell.* = SQLDataCell{ .tag = .uint4, .value = .{ .uint4 = val } };
|
|
} else {
|
|
const val: i24 = std.fmt.parseInt(i24, value.slice(), 10) catch std.math.minInt(i24);
|
|
cell.* = SQLDataCell{ .tag = .int4, .value = .{ .int4 = val } };
|
|
}
|
|
},
|
|
.MYSQL_TYPE_LONGLONG => {
|
|
if (column.flags.UNSIGNED) {
|
|
const val: u64 = std.fmt.parseInt(u64, value.slice(), 10) catch 0;
|
|
if (val <= std.math.maxInt(u32)) {
|
|
cell.* = SQLDataCell{ .tag = .uint4, .value = .{ .uint4 = @intCast(val) } };
|
|
return;
|
|
}
|
|
if (this.bigint) {
|
|
cell.* = SQLDataCell{ .tag = .uint8, .value = .{ .uint8 = val } };
|
|
return;
|
|
}
|
|
} else {
|
|
const val: i64 = std.fmt.parseInt(i64, value.slice(), 10) catch 0;
|
|
if (val >= std.math.minInt(i32) and val <= std.math.maxInt(i32)) {
|
|
cell.* = SQLDataCell{ .tag = .int4, .value = .{ .int4 = @intCast(val) } };
|
|
return;
|
|
}
|
|
if (this.bigint) {
|
|
cell.* = SQLDataCell{ .tag = .int8, .value = .{ .int8 = val } };
|
|
return;
|
|
}
|
|
}
|
|
|
|
const slice = value.slice();
|
|
cell.* = SQLDataCell{ .tag = .string, .value = .{ .string = if (slice.len > 0) bun.String.cloneUTF8(slice).value.WTFStringImpl else null }, .free_value = 1 };
|
|
},
|
|
.MYSQL_TYPE_JSON => {
|
|
const slice = value.slice();
|
|
cell.* = SQLDataCell{ .tag = .json, .value = .{ .json = if (slice.len > 0) bun.String.cloneUTF8(slice).value.WTFStringImpl else null }, .free_value = 1 };
|
|
},
|
|
|
|
.MYSQL_TYPE_TIME => {
|
|
// lets handle TIME special case as string
|
|
// -838:59:50 to 838:59:59 is valid
|
|
const slice = value.slice();
|
|
cell.* = SQLDataCell{ .tag = .string, .value = .{ .string = if (slice.len > 0) bun.String.cloneUTF8(slice).value.WTFStringImpl else null }, .free_value = 1 };
|
|
},
|
|
.MYSQL_TYPE_DATE, .MYSQL_TYPE_DATETIME, .MYSQL_TYPE_TIMESTAMP => {
|
|
var str = bun.String.init(value.slice());
|
|
defer str.deref();
|
|
const date = brk: {
|
|
break :brk str.parseDate(this.globalObject) catch |err| {
|
|
_ = this.globalObject.takeException(err);
|
|
break :brk std.math.nan(f64);
|
|
};
|
|
};
|
|
cell.* = SQLDataCell{ .tag = .date, .value = .{ .date = date } };
|
|
},
|
|
.MYSQL_TYPE_BIT => {
|
|
// BIT(1) is a special case, it's a boolean
|
|
if (column.column_length == 1) {
|
|
const slice = value.slice();
|
|
cell.* = SQLDataCell{ .tag = .bool, .value = .{ .bool = if (slice.len > 0 and slice[0] == 1) 1 else 0 } };
|
|
} else {
|
|
cell.* = SQLDataCell.raw(value);
|
|
}
|
|
},
|
|
else => {
|
|
// Only treat as binary if character_set indicates the binary pseudo-charset.
|
|
// The BINARY flag alone is insufficient because VARCHAR/CHAR columns
|
|
// with _bin collations (e.g., utf8mb4_bin) also have the BINARY flag set,
|
|
// but should return strings, not buffers.
|
|
if (column.flags.BINARY and column.character_set == DecodeBinaryValue.binary_charset) {
|
|
cell.* = SQLDataCell.raw(value);
|
|
} else {
|
|
const slice = value.slice();
|
|
cell.* = SQLDataCell{ .tag = .string, .value = .{ .string = if (slice.len > 0) bun.String.cloneUTF8(slice).value.WTFStringImpl else null }, .free_value = 1 };
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
fn decodeText(this: *Row, allocator: std.mem.Allocator, comptime Context: type, reader: NewReader(Context)) AnyMySQLError.Error!void {
|
|
const cells = try allocator.alloc(SQLDataCell, this.columns.len);
|
|
@memset(cells, SQLDataCell{ .tag = .null, .value = .{ .null = 0 } });
|
|
errdefer {
|
|
for (cells) |*value| {
|
|
value.deinit();
|
|
}
|
|
allocator.free(cells);
|
|
}
|
|
|
|
for (cells, 0..) |*value, index| {
|
|
if (decodeLengthInt(reader.peek())) |result| {
|
|
const column = this.columns[index];
|
|
if (result.value == 0xfb) {
|
|
// NULL value
|
|
reader.skip(result.bytes_read);
|
|
// this dont matter if is raw because we will sent as null too like in postgres
|
|
value.* = SQLDataCell{ .tag = .null, .value = .{ .null = 0 } };
|
|
} else {
|
|
if (this.raw) {
|
|
var data = try reader.rawEncodeLenData();
|
|
defer data.deinit();
|
|
value.* = SQLDataCell.raw(&data);
|
|
} else {
|
|
reader.skip(result.bytes_read);
|
|
var string_data = try reader.read(@intCast(result.value));
|
|
defer string_data.deinit();
|
|
this.parseValueAndSetCell(value, &column, &string_data);
|
|
}
|
|
}
|
|
value.index = switch (column.name_or_index) {
|
|
// The indexed columns can be out of order.
|
|
.index => |i| i,
|
|
|
|
else => @intCast(index),
|
|
};
|
|
value.isIndexedColumn = switch (column.name_or_index) {
|
|
.duplicate => 2,
|
|
.index => 1,
|
|
.name => 0,
|
|
};
|
|
} else {
|
|
return error.InvalidResultRow;
|
|
}
|
|
}
|
|
|
|
this.values = cells;
|
|
}
|
|
|
|
fn decodeBinary(this: *Row, allocator: std.mem.Allocator, comptime Context: type, reader: NewReader(Context)) AnyMySQLError.Error!void {
|
|
// Header
|
|
_ = try reader.int(u8);
|
|
|
|
// Null bitmap
|
|
const bitmap_bytes = (this.columns.len + 7 + 2) / 8;
|
|
var null_bitmap = try reader.read(bitmap_bytes);
|
|
defer null_bitmap.deinit();
|
|
|
|
const cells = try allocator.alloc(SQLDataCell, this.columns.len);
|
|
@memset(cells, SQLDataCell{ .tag = .null, .value = .{ .null = 0 } });
|
|
errdefer {
|
|
for (cells) |*value| {
|
|
value.deinit();
|
|
}
|
|
allocator.free(cells);
|
|
}
|
|
// Skip first 2 bits of null bitmap (reserved)
|
|
const bitmap_offset: usize = 2;
|
|
|
|
for (cells, 0..) |*value, i| {
|
|
const byte_pos = (bitmap_offset + i) >> 3;
|
|
const bit_pos = @as(u3, @truncate((bitmap_offset + i) & 7));
|
|
const is_null = (null_bitmap.slice()[byte_pos] & (@as(u8, 1) << bit_pos)) != 0;
|
|
|
|
if (is_null) {
|
|
value.* = SQLDataCell{ .tag = .null, .value = .{ .null = 0 } };
|
|
continue;
|
|
}
|
|
|
|
const column = this.columns[i];
|
|
value.* = try decodeBinaryValue(this.globalObject, column.column_type, column.column_length, this.raw, this.bigint, column.flags.UNSIGNED, column.flags.BINARY, column.character_set, Context, reader);
|
|
value.index = switch (column.name_or_index) {
|
|
// The indexed columns can be out of order.
|
|
.index => |idx| idx,
|
|
|
|
else => @intCast(i),
|
|
};
|
|
value.isIndexedColumn = switch (column.name_or_index) {
|
|
.duplicate => 2,
|
|
.index => 1,
|
|
.name => 0,
|
|
};
|
|
}
|
|
|
|
this.values = cells;
|
|
}
|
|
|
|
pub const decode = decoderWrap(Row, decodeInternal).decodeAllocator;
|
|
};
|
|
|
|
const debug = bun.Output.scoped(.MySQLResultSet, .visible);
|
|
|
|
const AnyMySQLError = @import("./AnyMySQLError.zig");
|
|
const CachedStructure = @import("../../shared/CachedStructure.zig");
|
|
const ColumnDefinition41 = @import("./ColumnDefinition41.zig");
|
|
const bun = @import("bun");
|
|
const std = @import("std");
|
|
const Data = @import("../../shared/Data.zig").Data;
|
|
const SQLDataCell = @import("../../shared/SQLDataCell.zig").SQLDataCell;
|
|
const SQLQueryResultMode = @import("../../shared/SQLQueryResultMode.zig").SQLQueryResultMode;
|
|
const decodeLengthInt = @import("./EncodeInt.zig").decodeLengthInt;
|
|
|
|
const DecodeBinaryValue = @import("./DecodeBinaryValue.zig");
|
|
const decodeBinaryValue = DecodeBinaryValue.decodeBinaryValue;
|
|
|
|
const NewReader = @import("./NewReader.zig").NewReader;
|
|
const decoderWrap = @import("./NewReader.zig").decoderWrap;
|
|
|
|
const jsc = bun.jsc;
|
|
const JSValue = jsc.JSValue;
|