From bee450265aaa2e8bab7320fdbe4ce83d11289ac0 Mon Sep 17 00:00:00 2001 From: jacek-prisma Date: Mon, 9 Mar 2026 09:00:50 +0000 Subject: [PATCH 1/2] fix: use mysql binary protocol to avoid a lossy conversion (#29285) [TML-1899](https://linear.app/prisma-company/issue/TML-1899/investigate-and-fix-mysql-update-precision-issue) Fixes https://github.com/prisma/prisma/issues/29160 Modifies the mariadb adapter to use the binary protocol whenever possible. This solves some edge cases with serializing large numbers. Also changed the adapter to use phantom queries, because the driver does not support transactions in its binary protocol, so it has to issue `query` calls directly. ## Summary by CodeRabbit * **Bug Fixes** * Improved MariaDB adapter transaction and query behavior to avoid protocol-related issues; made commit/rollback/savepoint operations more robust and error-safe. * **Tests** * Added end-to-end tests validating large-decimal precision on MySQL. * Added test matrix/schema wiring for MySQL precision scenarios. * Updated tracing tests to include MariaDB/MySQL driver scenarios. * Relaxed float assertions in MySQL scalar tests to allow Float32 representation variance. --- packages/adapter-mariadb/src/mariadb.ts | 38 ++++++++---- .../29160-mysql-precision-loss/_matrix.ts | 4 ++ .../prisma/_schema.ts | 19 ++++++ .../29160-mysql-precision-loss/tests.ts | 61 +++++++++++++++++++ .../client/tests/functional/tracing/tests.ts | 5 +- .../typed-sql/mysql-scalars-nullable/test.ts | 3 +- .../typed-sql/mysql-scalars/test.ts | 3 +- 7 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 packages/client/tests/functional/issues/29160-mysql-precision-loss/_matrix.ts create mode 100644 packages/client/tests/functional/issues/29160-mysql-precision-loss/prisma/_schema.ts create mode 100644 packages/client/tests/functional/issues/29160-mysql-precision-loss/tests.ts diff --git a/packages/adapter-mariadb/src/mariadb.ts b/packages/adapter-mariadb/src/mariadb.ts index 92ef04b232a7..7c1f1ac7f194 100644 --- a/packages/adapter-mariadb/src/mariadb.ts +++ b/packages/adapter-mariadb/src/mariadb.ts @@ -63,7 +63,8 @@ class MariaDbQueryable imp typeCast, } const values = args.map((arg, i) => mapArg(arg, query.argTypes[i])) - return await this.client.query(req, values) + // We intentionally use `execute` here, because it uses the binary protocol, unlike `query`. + return await this.client.execute(req, values) } catch (e) { const error = e as Error this.onError(error) @@ -76,9 +77,11 @@ class MariaDbQueryable imp } } +// All transaction operations use `client.query` instead of `client.execute` to avoid using the +// binary protocol, which does not support transactions in the MariaDB driver. class MariaDbTransaction extends MariaDbQueryable implements Transaction { constructor( - conn: mariadb.Connection, + readonly conn: mariadb.Connection, readonly options: TransactionOptions, readonly cleanup?: () => void, ) { @@ -88,27 +91,39 @@ class MariaDbTransaction extends MariaDbQueryable implements async commit(): Promise { debug(`[js::commit]`) - this.cleanup?.() - await this.client.end() + try { + await this.client.query({ sql: 'COMMIT' }) + } catch (err) { + this.onError(err) + } finally { + this.cleanup?.() + await this.client.end() + } } async rollback(): Promise { debug(`[js::rollback]`) - this.cleanup?.() - await this.client.end() + try { + await this.client.query({ sql: 'ROLLBACK' }) + } catch (err) { + this.onError(err) + } finally { + this.cleanup?.() + await this.client.end() + } } async createSavepoint(name: string): Promise { - await this.executeRaw({ sql: `SAVEPOINT ${name}`, args: [], argTypes: [] }) + await this.client.query({ sql: `SAVEPOINT ${name}` }).catch(this.onError.bind(this)) } async rollbackToSavepoint(name: string): Promise { - await this.executeRaw({ sql: `ROLLBACK TO ${name}`, args: [], argTypes: [] }) + await this.client.query({ sql: `ROLLBACK TO ${name}` }).catch(this.onError.bind(this)) } async releaseSavepoint(name: string): Promise { - await this.executeRaw({ sql: `RELEASE SAVEPOINT ${name}`, args: [], argTypes: [] }) + await this.client.query({ sql: `RELEASE SAVEPOINT ${name}` }).catch(this.onError.bind(this)) } } @@ -143,7 +158,7 @@ export class PrismaMariaDbAdapter extends MariaDbQueryable impleme async startTransaction(isolationLevel?: IsolationLevel): Promise { const options: TransactionOptions = { - usePhantomQuery: false, + usePhantomQuery: true, } const tag = '[js::startTransaction]' @@ -169,7 +184,8 @@ export class PrismaMariaDbAdapter extends MariaDbQueryable impleme argTypes: [], }) } - await tx.executeRaw({ sql: 'BEGIN', args: [], argTypes: [] }) + // Uses `query` instead of `execute` to avoid the binary protocol. + await tx.conn.query({ sql: 'BEGIN' }).catch(this.onError.bind(this)) return tx } catch (error) { await conn.end() diff --git a/packages/client/tests/functional/issues/29160-mysql-precision-loss/_matrix.ts b/packages/client/tests/functional/issues/29160-mysql-precision-loss/_matrix.ts new file mode 100644 index 000000000000..3585f7b579f9 --- /dev/null +++ b/packages/client/tests/functional/issues/29160-mysql-precision-loss/_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/issues/29160-mysql-precision-loss/prisma/_schema.ts b/packages/client/tests/functional/issues/29160-mysql-precision-loss/prisma/_schema.ts new file mode 100644 index 000000000000..58773d11ad84 --- /dev/null +++ b/packages/client/tests/functional/issues/29160-mysql-precision-loss/prisma/_schema.ts @@ -0,0 +1,19 @@ +import { idForProvider } from '../../../_utils/idForProvider' +import testMatrix from '../_matrix' + +export default testMatrix.setupSchema(({ provider }) => { + return /* Prisma */ ` + generator client { + provider = "prisma-client-js" + } + + datasource db { + provider = "${provider}" + } + + model AssetAccount { + assetId ${idForProvider(provider)} + amount Decimal @default(0) @db.Decimal(30, 0) + } + ` +}) diff --git a/packages/client/tests/functional/issues/29160-mysql-precision-loss/tests.ts b/packages/client/tests/functional/issues/29160-mysql-precision-loss/tests.ts new file mode 100644 index 000000000000..4a55c815b097 --- /dev/null +++ b/packages/client/tests/functional/issues/29160-mysql-precision-loss/tests.ts @@ -0,0 +1,61 @@ +import testMatrix from './_matrix' +// @ts-ignore +import type { Prisma as PrismaNamespace, PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient +declare let Prisma: typeof PrismaNamespace + +testMatrix.setupTestSuite( + () => { + test('preserves precision for large decimal values', async () => { + await prisma.assetAccount.create({ + data: { + assetId: '1', + amount: new Prisma.Decimal('0'), + }, + }) + + await expect( + prisma.assetAccount.update({ + where: { assetId: '1' }, + data: { amount: { increment: new Prisma.Decimal('5000000000000000000000000000') } }, + }), + ).resolves.toMatchObject({ + assetId: '1', + amount: new Prisma.Decimal('5000000000000000000000000000'), + }) + + // First withdrawal + await expect( + prisma.assetAccount.update({ + where: { assetId: '1' }, + data: { amount: { decrement: new Prisma.Decimal('1000000000000000000000') } }, + }), + ).resolves.toMatchObject({ + assetId: '1', + amount: new Prisma.Decimal('4999999000000000000000000000'), + }) + + await expect( + prisma.assetAccount.update({ + where: { assetId: '1' }, + data: { amount: { decrement: new Prisma.Decimal('1000000000000000000000') } }, + }), + ).resolves.toMatchObject({ + assetId: '1', + amount: new Prisma.Decimal('4999998000000000000000000000'), + }) + }) + }, + { + optOut: { + from: ['sqlserver', 'cockroachdb', 'mongodb', 'postgresql', 'sqlite'], + reason: + 'This test is specific to MySQL/MariaDB and the precision loss issue with large decimal values. It does not apply to other databases that do not have this issue.', + }, + skipDriverAdapter: { + from: ['js_planetscale'], + reason: 'This issue currently cannot be addressed in Planetscale due to limitations of their API', + }, + }, +) diff --git a/packages/client/tests/functional/tracing/tests.ts b/packages/client/tests/functional/tracing/tests.ts index fc296012a44e..469b0086417f 100644 --- a/packages/client/tests/functional/tracing/tests.ts +++ b/packages/client/tests/functional/tracing/tests.ts @@ -123,8 +123,9 @@ testMatrix.setupTestSuite( const usesJsDrivers = driverAdapter !== undefined || clientEngineExecutor === 'remote' const usesSyntheticTxQueries = - (driverAdapter !== undefined && ['js_d1', 'js_libsql', 'js_planetscale', 'js_mssql'].includes(driverAdapter)) || - (clientEngineExecutor === 'remote' && provider === Providers.SQLSERVER) + (driverAdapter !== undefined && + ['js_d1', 'js_libsql', 'js_planetscale', 'js_mssql', 'js_mariadb'].includes(driverAdapter)) || + (clientEngineExecutor === 'remote' && [Providers.SQLSERVER, Providers.MYSQL].includes(provider)) beforeEach(async () => { await prisma.$connect() diff --git a/packages/client/tests/functional/typed-sql/mysql-scalars-nullable/test.ts b/packages/client/tests/functional/typed-sql/mysql-scalars-nullable/test.ts index 81195a74f0e8..31a0d5f80800 100644 --- a/packages/client/tests/functional/typed-sql/mysql-scalars-nullable/test.ts +++ b/packages/client/tests/functional/typed-sql/mysql-scalars-nullable/test.ts @@ -51,7 +51,8 @@ testMatrix.setupTestSuite( test('float - output', async () => { const result = await prisma.$queryRawTyped(sql.getFloat(id)) - expect(result[0].float).toBe(12.3) + // Account for potential precision loss when storing the value as a FLOAT + expect(result[0].float).toBeOneOf([new Float32Array([12.3])[0], 12.3]) expectTypeOf(result[0].float).toEqualTypeOf() }) diff --git a/packages/client/tests/functional/typed-sql/mysql-scalars/test.ts b/packages/client/tests/functional/typed-sql/mysql-scalars/test.ts index c20119b23af8..593bc2b27414 100644 --- a/packages/client/tests/functional/typed-sql/mysql-scalars/test.ts +++ b/packages/client/tests/functional/typed-sql/mysql-scalars/test.ts @@ -50,7 +50,8 @@ testMatrix.setupTestSuite( test('float - output', async () => { const result = await prisma.$queryRawTyped(sql.getFloat(id)) - expect(result[0].float).toBe(12.3) + // Account for potential precision loss when storing the value as a FLOAT + expect(result[0].float).toBeOneOf([new Float32Array([12.3])[0], 12.3]) expectTypeOf(result[0].float).toBeNumber() }) From 7a1f497e9afa699732919d3c559db900455faa8d Mon Sep 17 00:00:00 2001 From: Connor Tessaro Date: Mon, 9 Mar 2026 05:41:53 -0400 Subject: [PATCH 2/2] Fix DateTime fields returning Invalid Date with unixepoch-ms (#29274) ## Summary Fix DateTime fields returning `Invalid Date` when using `unixepoch-ms`. ## Changes - Correct DateTime handling for `unixepoch-ms` (millisecond timestamps). - Add regression test covering DateTime read/write with `unixepoch-ms`. ## Tests - Added/updated tests for this regression and verified they pass. Fixes #28890 ## Summary by CodeRabbit * **Bug Fixes** * Safer handling of very large integers from the database: values that fit safely are returned as numbers, while out-of-range integers fall back to strings to prevent precision loss. * **Tests** * Added functional tests for millisecond-precision timestamps to verify created and aggregated datetime values are returned as valid Date instances across create, find, and aggregate scenarios. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../adapter-better-sqlite3/src/conversion.ts | 5 +- .../functional/unixepoch-ms-datetime/tests.ts | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/packages/adapter-better-sqlite3/src/conversion.ts b/packages/adapter-better-sqlite3/src/conversion.ts index 869ee14b1d92..d28e5f9a27dd 100644 --- a/packages/adapter-better-sqlite3/src/conversion.ts +++ b/packages/adapter-better-sqlite3/src/conversion.ts @@ -168,9 +168,10 @@ export function mapRow(row: Row, columnTypes: ColumnType[]): ResultValue[] { continue } - // Convert bigint to string as we can only use JSON-encodable types here. + // Convert bigint values to numbers when safe, otherwise use strings. if (typeof value === 'bigint') { - result[i] = value.toString() + const asNumber = Number(value) + result[i] = Number.isSafeInteger(asNumber) ? asNumber : value.toString() continue } diff --git a/packages/client/tests/functional/unixepoch-ms-datetime/tests.ts b/packages/client/tests/functional/unixepoch-ms-datetime/tests.ts index 291aaca56e66..c074ad54c900 100644 --- a/packages/client/tests/functional/unixepoch-ms-datetime/tests.ts +++ b/packages/client/tests/functional/unixepoch-ms-datetime/tests.ts @@ -107,6 +107,97 @@ testMatrix.setupTestSuite( // These two queries are going to be compacted together and run as one. await expect(Promise.all([find(), find()])).resolves.toMatchObject([created, created]) }) + + test('findUnique() returns valid Date when createdAt is stored as unix millis directly', async () => { + const prisma = createClient(info, driverAdapter) + + const uuid = randomUUID() + const nowMillis = Date.now() + + await prisma.$executeRaw` + INSERT INTO Event (name, uuid, createdAt) + VALUES ('event', ${uuid}, ${nowMillis}) + ` + + const found = await prisma.event.findFirst({ + where: { + uuid, + }, + }) + + expect(found?.createdAt).toBeInstanceOf(Date) + expect(isNaN(found!.createdAt.getTime())).toBe(false) + }) + + test('aggregate() returns valid Date when unix millis are stored directly', async () => { + const prisma = createClient(info, driverAdapter) + + const uuid = randomUUID() + const nowMillis = Date.now() + + await prisma.$executeRaw` + INSERT INTO Event (name, uuid, createdAt) + VALUES ('event', ${uuid}, ${nowMillis}) + ` + + const agg = await prisma.event.aggregate({ + _min: { createdAt: true }, + _max: { createdAt: true }, + }) + + expect(agg._min.createdAt).toBeInstanceOf(Date) + expect(isNaN(agg._min.createdAt!.getTime())).toBe(false) + expect(agg._max.createdAt).toBeInstanceOf(Date) + expect(isNaN(agg._max.createdAt!.getTime())).toBe(false) + }) + + test('manually created INTEGER DateTime column returns valid Date values', async () => { + const prisma = createClient(info, driverAdapter) + + await prisma.$executeRaw` + DROP TABLE IF EXISTS Event + ` + + await prisma.$executeRaw` + CREATE TABLE Event ( + name TEXT NOT NULL, + uuid TEXT NOT NULL, + createdAt INTEGER NOT NULL DEFAULT (unixepoch('now') * 1000), + PRIMARY KEY (uuid, createdAt) + ) + ` + + const created = await prisma.event.create({ + data: { + name: 'event', + }, + }) + + expect(created.createdAt).toBeInstanceOf(Date) + expect(isNaN(created.createdAt.getTime())).toBe(false) + + const found = await prisma.event.findUnique({ + where: { + uuid_createdAt: { + uuid: created.uuid, + createdAt: created.createdAt, + }, + }, + }) + + expect(found?.createdAt).toBeInstanceOf(Date) + expect(isNaN(found!.createdAt.getTime())).toBe(false) + + const agg = await prisma.event.aggregate({ + _min: { createdAt: true }, + _max: { createdAt: true }, + }) + + expect(agg._min.createdAt).toBeInstanceOf(Date) + expect(isNaN(agg._min.createdAt!.getTime())).toBe(false) + expect(agg._max.createdAt).toBeInstanceOf(Date) + expect(isNaN(agg._max.createdAt!.getTime())).toBe(false) + }) }, { optOut: {