diff --git a/drizzle-orm/src/mysql-core/columns/binary.ts b/drizzle-orm/src/mysql-core/columns/binary.ts index f94da26c5f..01bf54c42e 100644 --- a/drizzle-orm/src/mysql-core/columns/binary.ts +++ b/drizzle-orm/src/mysql-core/columns/binary.ts @@ -1,30 +1,40 @@ -import type { ColumnBuilderBaseConfig, ColumnBuilderRuntimeConfig, MakeColumnConfig } from '~/column-builder.ts'; +import type { + ColumnBuilderBaseConfig, + ColumnBuilderRuntimeConfig, + HasDefault, + MakeColumnConfig, +} from '~/column-builder.ts'; import type { ColumnBaseConfig } from '~/column.ts'; import { entityKind } from '~/entity.ts'; import type { AnyMySqlTable } from '~/mysql-core/table.ts'; +import type { SQL } from '~/sql/sql.ts'; import { getColumnNameAndConfig } from '~/utils.ts'; import { MySqlColumn, MySqlColumnBuilder } from './common.ts'; export type MySqlBinaryBuilderInitial = MySqlBinaryBuilder<{ name: TName; - dataType: 'string'; + dataType: 'buffer'; columnType: 'MySqlBinary'; - data: string; - driverParam: string; + data: Buffer; + driverParam: Buffer; enumValues: undefined; }>; -export class MySqlBinaryBuilder> extends MySqlColumnBuilder< +export class MySqlBinaryBuilder> extends MySqlColumnBuilder< T, MySqlBinaryConfig > { static override readonly [entityKind]: string = 'MySqlBinaryBuilder'; constructor(name: T['name'], length: number | undefined) { - super(name, 'string', 'MySqlBinary'); + super(name, 'buffer', 'MySqlBinary'); this.config.length = length; } + override default(value: Buffer | string | SQL): HasDefault { + return super.default(value as Buffer | SQL); + } + /** @internal */ override build( table: AnyMySqlTable<{ name: TTableName }>, @@ -33,7 +43,7 @@ export class MySqlBinaryBuilder> extends MySqlColumn< +export class MySqlBinary> extends MySqlColumn< T, MySqlBinaryConfig > { @@ -41,16 +51,9 @@ export class MySqlBinary> ex length: number | undefined = this.config.length; - override mapFromDriverValue(value: string | Buffer | Uint8Array): string { - if (typeof value === 'string') return value; - if (Buffer.isBuffer(value)) return value.toString(); - - const str: string[] = []; - for (const v of value) { - str.push(v === 49 ? '1' : '0'); - } - - return str.join(''); + override mapFromDriverValue(value: string | Buffer | Uint8Array): Buffer { + if (Buffer.isBuffer(value)) return value; + return Buffer.from(value); } getSQLType(): string { diff --git a/drizzle-orm/src/mysql-core/columns/varbinary.ts b/drizzle-orm/src/mysql-core/columns/varbinary.ts index 25b6864031..2813be308d 100644 --- a/drizzle-orm/src/mysql-core/columns/varbinary.ts +++ b/drizzle-orm/src/mysql-core/columns/varbinary.ts @@ -1,30 +1,40 @@ -import type { ColumnBuilderBaseConfig, ColumnBuilderRuntimeConfig, MakeColumnConfig } from '~/column-builder.ts'; +import type { + ColumnBuilderBaseConfig, + ColumnBuilderRuntimeConfig, + HasDefault, + MakeColumnConfig, +} from '~/column-builder.ts'; import type { ColumnBaseConfig } from '~/column.ts'; import { entityKind } from '~/entity.ts'; import type { AnyMySqlTable } from '~/mysql-core/table.ts'; +import type { SQL } from '~/sql/sql.ts'; import { getColumnNameAndConfig } from '~/utils.ts'; import { MySqlColumn, MySqlColumnBuilder } from './common.ts'; export type MySqlVarBinaryBuilderInitial = MySqlVarBinaryBuilder<{ name: TName; - dataType: 'string'; + dataType: 'buffer'; columnType: 'MySqlVarBinary'; - data: string; - driverParam: string; + data: Buffer; + driverParam: Buffer; enumValues: undefined; }>; -export class MySqlVarBinaryBuilder> +export class MySqlVarBinaryBuilder> extends MySqlColumnBuilder { static override readonly [entityKind]: string = 'MySqlVarBinaryBuilder'; /** @internal */ constructor(name: T['name'], config: MySqlVarbinaryOptions) { - super(name, 'string', 'MySqlVarBinary'); + super(name, 'buffer', 'MySqlVarBinary'); this.config.length = config?.length; } + override default(value: Buffer | string | SQL): HasDefault { + return super.default(value as Buffer | SQL); + } + /** @internal */ override build( table: AnyMySqlTable<{ name: TTableName }>, @@ -37,22 +47,15 @@ export class MySqlVarBinaryBuilder, + T extends ColumnBaseConfig<'buffer', 'MySqlVarBinary'>, > extends MySqlColumn { static override readonly [entityKind]: string = 'MySqlVarBinary'; length: number | undefined = this.config.length; - override mapFromDriverValue(value: string | Buffer | Uint8Array): string { - if (typeof value === 'string') return value; - if (Buffer.isBuffer(value)) return value.toString(); - - const str: string[] = []; - for (const v of value) { - str.push(v === 49 ? '1' : '0'); - } - - return str.join(''); + override mapFromDriverValue(value: string | Buffer | Uint8Array): Buffer { + if (Buffer.isBuffer(value)) return value; + return Buffer.from(value); } getSQLType(): string { diff --git a/drizzle-orm/tests/mysql-binary-buffer.test.ts b/drizzle-orm/tests/mysql-binary-buffer.test.ts new file mode 100644 index 0000000000..6ddf10a652 --- /dev/null +++ b/drizzle-orm/tests/mysql-binary-buffer.test.ts @@ -0,0 +1,42 @@ +import { Buffer } from 'node:buffer'; +import { describe, test } from 'vitest'; +import { binary, mysqlTable, varbinary } from '~/mysql-core/index.ts'; + +const table = mysqlTable('binary_buffer_test', { + binary: binary('binary', { length: 11 }).notNull(), + varbinary: varbinary('varbinary', { length: 11 }).notNull(), +}); + +describe.concurrent('MySQL binary columns', () => { + test('preserve Buffer values returned by mysql2', ({ expect }) => { + const value = Buffer.from([0x00, 0xff, 0x80, 0x31]); + + const binaryValue = table.binary.mapFromDriverValue(value); + const varbinaryValue = table.varbinary.mapFromDriverValue(value); + + expect(Buffer.isBuffer(binaryValue)).toBe(true); + expect(Buffer.isBuffer(varbinaryValue)).toBe(true); + expect(binaryValue).toEqual(value); + expect(varbinaryValue).toEqual(value); + }); + + test('convert Uint8Array values returned by binary adapters to Buffer without changing bytes', ({ expect }) => { + const value = new Uint8Array([0x00, 0xff, 0x80, 0x31]); + + const binaryValue = table.binary.mapFromDriverValue(value); + const varbinaryValue = table.varbinary.mapFromDriverValue(value); + + expect(Buffer.isBuffer(binaryValue)).toBe(true); + expect(Buffer.isBuffer(varbinaryValue)).toBe(true); + expect(binaryValue).toEqual(Buffer.from(value)); + expect(varbinaryValue).toEqual(Buffer.from(value)); + }); + + test('convert legacy string driver values to Buffer', ({ expect }) => { + const binaryValue = table.binary.mapFromDriverValue('hello world'); + const varbinaryValue = table.varbinary.mapFromDriverValue('hello world'); + + expect(binaryValue).toEqual(Buffer.from('hello world')); + expect(varbinaryValue).toEqual(Buffer.from('hello world')); + }); +}); diff --git a/drizzle-orm/type-tests/mysql/binary-buffer.ts b/drizzle-orm/type-tests/mysql/binary-buffer.ts new file mode 100644 index 0000000000..6971d471e1 --- /dev/null +++ b/drizzle-orm/type-tests/mysql/binary-buffer.ts @@ -0,0 +1,17 @@ +import { type Equal, Expect } from 'type-tests/utils.ts'; +import { binary, mysqlTable, varbinary } from '~/mysql-core/index.ts'; + +const table = mysqlTable('binary_buffer_types', { + binary: binary('binary').notNull(), + varbinary: varbinary('varbinary', { length: 255 }).notNull(), +}); + +Expect>; +Expect>; +Expect>; +Expect>; + +// Keep accepting string SQL defaults for backwards compatibility while +// the selected/inserted application data type is correctly inferred as Buffer. +binary('binary_default').default(''); +varbinary('varbinary_default', { length: 255 }).default(''); diff --git a/integration-tests/tests/mysql/mysql-planetscale.test.ts b/integration-tests/tests/mysql/mysql-planetscale.test.ts index fc97ea254a..77df3c36ef 100644 --- a/integration-tests/tests/mysql/mysql-planetscale.test.ts +++ b/integration-tests/tests/mysql/mysql-planetscale.test.ts @@ -1,7 +1,9 @@ import { Client } from '@planetscale/database'; +import { sql } from 'drizzle-orm'; +import { binary, mysqlTable, varbinary } from 'drizzle-orm/mysql-core'; import type { PlanetScaleDatabase } from 'drizzle-orm/planetscale-serverless'; import { drizzle } from 'drizzle-orm/planetscale-serverless'; -import { beforeAll, beforeEach } from 'vitest'; +import { beforeAll, beforeEach, expect, test } from 'vitest'; import { skipTests } from '~/common'; import { tests } from './mysql-common'; import { TestCache, TestGlobalCache, tests as cacheTests } from './mysql-common-cache'; @@ -84,5 +86,26 @@ skipTests([ 'insert returning sql', ]); +test('binary and varbinary preserve non-UTF-8 bytes', async () => { + const table = mysqlTable('binary_buffer_planetscale', { + binary: binary('binary', { length: 4 }).notNull(), + varbinary: varbinary('varbinary', { length: 4 }).notNull(), + }); + const value = Buffer.from([0x00, 0xff, 0x80, 0x31]); + + await db.execute(sql`drop table if exists ${table}`); + await db.execute(sql`create table ${table} (\`binary\` binary(4) not null, \`varbinary\` varbinary(4) not null)`); + await db.insert(table).values({ binary: value, varbinary: value }); + + const [row] = await db.select().from(table); + + expect(Buffer.isBuffer(row!.binary)).toBe(true); + expect(Buffer.isBuffer(row!.varbinary)).toBe(true); + expect(row!.binary).toEqual(value); + expect(row!.varbinary).toEqual(value); + + await db.execute(sql`drop table ${table}`); +}); + tests('planetscale'); cacheTests(); diff --git a/integration-tests/tests/mysql/mysql.test.ts b/integration-tests/tests/mysql/mysql.test.ts index 6641e2d14f..a290d7924c 100644 --- a/integration-tests/tests/mysql/mysql.test.ts +++ b/integration-tests/tests/mysql/mysql.test.ts @@ -1,8 +1,10 @@ import retry from 'async-retry'; +import { sql } from 'drizzle-orm'; +import { binary, mysqlTable, varbinary } from 'drizzle-orm/mysql-core'; import type { MySql2Database } from 'drizzle-orm/mysql2'; import { drizzle } from 'drizzle-orm/mysql2'; import * as mysql from 'mysql2/promise'; -import { afterAll, beforeAll, beforeEach } from 'vitest'; +import { afterAll, beforeAll, beforeEach, expect, test } from 'vitest'; import { createDockerDB, tests } from './mysql-common'; import { TestCache, TestGlobalCache, tests as cacheTests } from './mysql-common-cache'; @@ -57,5 +59,26 @@ beforeEach((ctx) => { }; }); +test('binary and varbinary preserve non-UTF-8 bytes', async () => { + const table = mysqlTable('binary_buffer_mysql2', { + binary: binary('binary', { length: 4 }).notNull(), + varbinary: varbinary('varbinary', { length: 4 }).notNull(), + }); + const value = Buffer.from([0x00, 0xff, 0x80, 0x31]); + + await db.execute(sql`drop table if exists ${table}`); + await db.execute(sql`create table ${table} (\`binary\` binary(4) not null, \`varbinary\` varbinary(4) not null)`); + await db.insert(table).values({ binary: value, varbinary: value }); + + const [row] = await db.select().from(table); + + expect(Buffer.isBuffer(row!.binary)).toBe(true); + expect(Buffer.isBuffer(row!.varbinary)).toBe(true); + expect(row!.binary).toEqual(value); + expect(row!.varbinary).toEqual(value); + + await db.execute(sql`drop table ${table}`); +}); + cacheTests(); tests();