From 76bb95b96b663cfbd622a7d1d62f78619f2cff2c Mon Sep 17 00:00:00 2001 From: sakurai-ryo <58683719+sakurai-ryo@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:30:44 +0900 Subject: [PATCH] fix(adapter-mariadb,adapter-planetscale): return strings for text columns with binary collation in raw queries (#29238) Fixes #29237 ## Summary ### adapter-mariadb Replace the `BINARY_FLAG` check with a collation index check (`collation.index === 63`), matching the approach used by `mariadb-connector-nodejs`. https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/3.5.1/lib/cmd/decoder/text-decoder.js#L44 ### adapter-planetscale Add a charset-based collation check (`field.charset !== 63`) in `fieldToColumnType` to distinguish text columns from true binary columns. Vitess converts CHAR/VARCHAR/TEXT columns with a binary collation to BINARY/VARBINARY/BLOB respectively, so the charset value is used to detect this conversion. https://github.com/planetscale/database-js/blob/de78eebfaec8cd88c670b8c644fc5a3fd69e664c/src/cast.ts#L92 ## Root cause Because the `mapColumnType` function in `adapter-mariadb` returns `ColumnTypeEnum.Bytes` when a column has a Binary Collation, https://github.com/prisma/prisma/blob/7.4.1/packages/adapter-mariadb/src/conversion.ts#L86 and the `fieldToColumnType` function in `adapter-planetscale` does not inspect the charset at all for BLOB/BINARY/VARBINARY types, https://github.com/prisma/prisma/blob/7.4.1/packages/adapter-planetscale/src/conversion.ts#L85 the `deserializeValue` function unintentionally converts even string data into a `Uint8Array`, treating it as a base64 string. https://github.com/prisma/prisma/blob/7.4.1/packages/client/src/runtime/utils/deserializeRawResults.ts#L20 Since a Binary Collation can also apply to string data types like `CHAR`, the type determination logic in both `adapter-mariadb` and `adapter-planetscale` needs to be updated. ## Summary by CodeRabbit * **Bug Fixes** * Improved binary collation detection in MariaDB and PlanetScale database adapters. Columns with binary collation now correctly return as text data. * **Tests** * Added MySQL column type tests to validate binary collation handling in query results. --- packages/adapter-mariadb/src/conversion.ts | 8 +++- .../adapter-planetscale/src/conversion.ts | 22 +++++++-- .../adapter-planetscale/src/planetscale.ts | 4 +- .../raw-queries/mysql-column-type/_matrix.ts | 4 ++ .../mysql-column-type/prisma/_schema.ts | 22 +++++++++ .../raw-queries/mysql-column-type/test.ts | 47 +++++++++++++++++++ 6 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 packages/client/tests/functional/raw-queries/mysql-column-type/_matrix.ts create mode 100644 packages/client/tests/functional/raw-queries/mysql-column-type/prisma/_schema.ts create mode 100644 packages/client/tests/functional/raw-queries/mysql-column-type/test.ts diff --git a/packages/adapter-mariadb/src/conversion.ts b/packages/adapter-mariadb/src/conversion.ts index 8b7daca414cb..fa133c12da9b 100644 --- a/packages/adapter-mariadb/src/conversion.ts +++ b/packages/adapter-mariadb/src/conversion.ts @@ -2,7 +2,8 @@ import { ArgType, ColumnType, ColumnTypeEnum, ResultValue } from '@prisma/driver import * as mariadb from 'mariadb' const UNSIGNED_FLAG = 1 << 5 -const BINARY_FLAG = 1 << 7 +// https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/be72ebf9fee6e0bd153b6ff6e0bb252f794dbf0e/lib/const/collations.js#L150 +const BINARY_COLLATION_INDEX = 63 const enum MariaDbColumnType { DECIMAL = 'DECIMAL', @@ -83,7 +84,10 @@ export function mapColumnType(field: mariadb.FieldInfo): ColumnType { // https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/1bbbb41e92d2123948c2322a4dbb5021026f2d05/lib/cmd/column-definition.js#L27 if (field['dataTypeFormat'] === 'json') { return ColumnTypeEnum.Json - } else if (field.flags.valueOf() & BINARY_FLAG) { + } + // The Binary flag of column definition applies to both text and binary data. To distinguish them, check if collation == 'binary' instead of checking the flag. + // https://github.com/mariadb-corporation/mariadb-connector-nodejs/blob/be72ebf9fee6e0bd153b6ff6e0bb252f794dbf0e/lib/cmd/decoder/text-decoder.js#L44 + else if (field.collation.index === BINARY_COLLATION_INDEX) { return ColumnTypeEnum.Bytes } else { return ColumnTypeEnum.Text diff --git a/packages/adapter-planetscale/src/conversion.ts b/packages/adapter-planetscale/src/conversion.ts index b96a89664b6b..bcdd14ac8e55 100644 --- a/packages/adapter-planetscale/src/conversion.ts +++ b/packages/adapter-planetscale/src/conversion.ts @@ -1,10 +1,13 @@ -import { cast as defaultCast } from '@planetscale/database' +import { cast as defaultCast, type Field } from '@planetscale/database' import { ArgType, type ColumnType, ColumnTypeEnum } from '@prisma/driver-adapter-utils' import { decodeUtf8 } from './text' +// https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_character_set.html +const BINARY_COLLATION_INDEX = 63 + // See: https://github.com/planetscale/vitess-types/blob/06235e372d2050b4c0fff49972df8111e696c564/src/vitess/query/v16/query.proto#L108-L218 -export type PlanetScaleColumnType = +type PlanetScaleColumnType = | 'NULL' | 'INT8' | 'UINT8' @@ -46,8 +49,9 @@ export type PlanetScaleColumnType = * module to see how other attributes of the field packet such as the field length are used to infer * the correct quaint::Value variant. */ -export function fieldToColumnType(field: PlanetScaleColumnType): ColumnType { - switch (field) { +export function fieldToColumnType(field: Field): ColumnType { + const type = field.type as PlanetScaleColumnType + switch (type) { case 'INT8': case 'UINT8': case 'INT16': @@ -85,6 +89,14 @@ export function fieldToColumnType(field: PlanetScaleColumnType): ColumnType { case 'BLOB': case 'BINARY': case 'VARBINARY': + // vitess converts CHAR/VARCHAR/TEXT columns with a binary collation to BINARY/VARBINARY/BLOB respectively before returning them to @planetscale/database driver. + // https://github.com/vitessio/vitess/blob/a94fa13f2ab53c98aad07a56eb15fe20b5ea7ade/go/sqltypes/type.go#L269 + // Therefore, we check the collation to distinguish between text and binary data. + // https://github.com/planetscale/database-js/blob/de78eebfaec8cd88c670b8c644fc5a3fd69e664c/src/cast.ts#L92 + if (field.charset && field.charset !== BINARY_COLLATION_INDEX) { + return ColumnTypeEnum.Text + } + return ColumnTypeEnum.Bytes case 'BIT': case 'BITNUM': case 'HEXNUM': @@ -95,7 +107,7 @@ export function fieldToColumnType(field: PlanetScaleColumnType): ColumnType { // Fall back to Int32 for consistency with quaint. return ColumnTypeEnum.Int32 default: - throw new Error(`Unsupported column type: ${field}`) + throw new Error(`Unsupported column type: ${type}`) } } diff --git a/packages/adapter-planetscale/src/planetscale.ts b/packages/adapter-planetscale/src/planetscale.ts index 5e62d7498fea..36a80145adf4 100644 --- a/packages/adapter-planetscale/src/planetscale.ts +++ b/packages/adapter-planetscale/src/planetscale.ts @@ -18,7 +18,7 @@ import { Debug, DriverAdapterError } from '@prisma/driver-adapter-utils' import { Mutex } from 'async-mutex' import { name as packageName } from '../package.json' -import { cast, fieldToColumnType, mapArg, type PlanetScaleColumnType } from './conversion' +import { cast, fieldToColumnType, mapArg } from './conversion' import { createDeferred, Deferred } from './deferred' import { convertDriverError } from './errors' @@ -54,7 +54,7 @@ class PlanetScaleQueryable field.name) return { columnNames: columns, - columnTypes: fields.map((field) => fieldToColumnType(field.type as PlanetScaleColumnType)), + columnTypes: fields.map((field) => fieldToColumnType(field)), rows: rows as SqlResultSet['rows'], lastInsertId, } diff --git a/packages/client/tests/functional/raw-queries/mysql-column-type/_matrix.ts b/packages/client/tests/functional/raw-queries/mysql-column-type/_matrix.ts new file mode 100644 index 000000000000..3585f7b579f9 --- /dev/null +++ b/packages/client/tests/functional/raw-queries/mysql-column-type/_matrix.ts @@ -0,0 +1,4 @@ +import { defineMatrix } from '../../_utils/defineMatrix' +import { Providers } from '../../_utils/providers' + +export default defineMatrix(() => [[{ provider: Providers.MYSQL }]]) diff --git a/packages/client/tests/functional/raw-queries/mysql-column-type/prisma/_schema.ts b/packages/client/tests/functional/raw-queries/mysql-column-type/prisma/_schema.ts new file mode 100644 index 000000000000..f65abf2a17a9 --- /dev/null +++ b/packages/client/tests/functional/raw-queries/mysql-column-type/prisma/_schema.ts @@ -0,0 +1,22 @@ +import { idForProvider } from '../../../_utils/idForProvider' +import testMatrix from '../_matrix' + +export default testMatrix.setupSchema(({ provider }) => { + return /* Prisma */ ` + generator client { + provider = "prisma-client-js" + output = "../generated/prisma/client" + } + + datasource db { + provider = "${provider}" + } + + model User { + id ${idForProvider(provider)} + char_bin_collation String @db.Char(191) + varchar_bin_collation String @db.VarChar(191) + text_bin_collation String @db.Text + } + ` +}) diff --git a/packages/client/tests/functional/raw-queries/mysql-column-type/test.ts b/packages/client/tests/functional/raw-queries/mysql-column-type/test.ts new file mode 100644 index 000000000000..e3933b09476f --- /dev/null +++ b/packages/client/tests/functional/raw-queries/mysql-column-type/test.ts @@ -0,0 +1,47 @@ +import { Providers } from '../../_utils/providers' +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite( + () => { + beforeAll(async () => { + // Prisma Schema does not support specifying column collation + await prisma.$executeRaw`ALTER TABLE \`User\` MODIFY \`char_bin_collation\` CHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL` + await prisma.$executeRaw`ALTER TABLE \`User\` MODIFY \`varchar_bin_collation\` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL` + await prisma.$executeRaw`ALTER TABLE \`User\` MODIFY \`text_bin_collation\` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL` + }) + + beforeEach(async () => { + await prisma.user.deleteMany() + }) + + test('columns with _bin collation return strings, not Uint8Array', async () => { + await prisma.user.create({ + data: { + char_bin_collation: 'hello', + varchar_bin_collation: 'hello', + text_bin_collation: 'hello', + }, + }) + + const result = + (await prisma.$queryRaw`SELECT \`char_bin_collation\`, \`varchar_bin_collation\`, \`text_bin_collation\` FROM \`User\``) as Array< + Record + > + + expect(result).toHaveLength(1) + expect(result[0].char_bin_collation).toBe('hello') + expect(result[0].varchar_bin_collation).toBe('hello') + expect(result[0].text_bin_collation).toBe('hello') + }) + }, + { + optOut: { + from: [Providers.POSTGRESQL, Providers.SQLITE, Providers.MONGODB, Providers.COCKROACHDB, Providers.SQLSERVER], + reason: 'This test is for MySQL-specific column type detection', + }, + }, +)