From b311672669de614f2e3667484b0a0bdc2b8b85a9 Mon Sep 17 00:00:00 2001 From: jacek-prisma Date: Fri, 27 Feb 2026 13:39:24 +0000 Subject: [PATCH 1/4] test: do not wrap array parameters in arrays in push (#29244) [TML-1923](https://linear.app/prisma-company/issue/TML-1923/investigate-and-fix-push-regression) Fixes https://github.com/prisma/prisma/issues/29212 ## Summary by CodeRabbit * **Tests** * Added a new functional test suite covering the array-push regression across supported database providers. * **Chores** * Bumped Prisma engines and related package versions to 7.5.0-10 across multiple packages. * **Refactor** * Normalized error field naming in the runtime to a consistent camelCase property for validation errors. --------- Co-authored-by: Prismo --- .../src/interpreter/validation.ts | 4 +- .../client-engine-runtime/src/query-plan.ts | 12 +-- packages/client-generator-js/package.json | 2 +- packages/client-generator-ts/package.json | 2 +- packages/client/package.json | 4 +- .../29212-array-push-regression/_matrix.ts | 8 ++ .../prisma/_schema.ts | 20 +++++ .../29212-array-push-regression/tests.ts | 35 +++++++++ packages/engines/package.json | 2 +- packages/fetch-engine/package.json | 2 +- packages/internals/package.json | 4 +- packages/migrate/package.json | 2 +- packages/schema-files-loader/package.json | 2 +- pnpm-lock.yaml | 76 +++++++++---------- 14 files changed, 119 insertions(+), 56 deletions(-) create mode 100644 packages/client/tests/functional/issues/29212-array-push-regression/_matrix.ts create mode 100644 packages/client/tests/functional/issues/29212-array-push-regression/prisma/_schema.ts create mode 100644 packages/client/tests/functional/issues/29212-array-push-regression/tests.ts diff --git a/packages/client-engine-runtime/src/interpreter/validation.ts b/packages/client-engine-runtime/src/interpreter/validation.ts index 0af7d8a16f19..42c5a9dec07a 100644 --- a/packages/client-engine-runtime/src/interpreter/validation.ts +++ b/packages/client-engine-runtime/src/interpreter/validation.ts @@ -42,7 +42,7 @@ export function doesSatisfyRule(data: unknown, rule: DataRule): boolean { } function renderMessage(data: unknown, error: ValidationError): string { - switch (error.error_identifier) { + switch (error.errorIdentifier) { case 'RELATION_VIOLATION': return `The change you are trying to make would violate the required relation '${error.context.relation}' between the \`${error.context.modelA}\` and \`${error.context.modelB}\` models.` case 'MISSING_RECORD': @@ -70,7 +70,7 @@ function renderMessage(data: unknown, error: ValidationError): string { } function getErrorCode(error: ValidationError): string { - switch (error.error_identifier) { + switch (error.errorIdentifier) { case 'RELATION_VIOLATION': return 'P2014' case 'RECORDS_NOT_CONNECTED': diff --git a/packages/client-engine-runtime/src/query-plan.ts b/packages/client-engine-runtime/src/query-plan.ts index 35bc710d0ded..61b561845672 100644 --- a/packages/client-engine-runtime/src/query-plan.ts +++ b/packages/client-engine-runtime/src/query-plan.ts @@ -266,7 +266,7 @@ export type DataRule = export type ValidationError = | { - error_identifier: 'RELATION_VIOLATION' + errorIdentifier: 'RELATION_VIOLATION' context: { relation: string modelA: string @@ -274,7 +274,7 @@ export type ValidationError = } } | { - error_identifier: 'MISSING_RELATED_RECORD' + errorIdentifier: 'MISSING_RELATED_RECORD' context: { model: string relation: string @@ -284,19 +284,19 @@ export type ValidationError = } } | { - error_identifier: 'MISSING_RECORD' + errorIdentifier: 'MISSING_RECORD' context: { operation: string } } | { - error_identifier: 'INCOMPLETE_CONNECT_INPUT' + errorIdentifier: 'INCOMPLETE_CONNECT_INPUT' context: { expectedRows: number } } | { - error_identifier: 'INCOMPLETE_CONNECT_OUTPUT' + errorIdentifier: 'INCOMPLETE_CONNECT_OUTPUT' context: { expectedRows: number relation: string @@ -304,7 +304,7 @@ export type ValidationError = } } | { - error_identifier: 'RECORDS_NOT_CONNECTED' + errorIdentifier: 'RECORDS_NOT_CONNECTED' context: { relation: string parent: string diff --git a/packages/client-generator-js/package.json b/packages/client-generator-js/package.json index ab2b05eec8eb..0f35d2bb8703 100644 --- a/packages/client-generator-js/package.json +++ b/packages/client-generator-js/package.json @@ -28,7 +28,7 @@ "@prisma/client-common": "workspace:*", "@prisma/debug": "workspace:*", "@prisma/dmmf": "workspace:*", - "@prisma/engines-version": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "workspace:*", "@prisma/generator": "workspace:*", "@prisma/get-platform": "workspace:*", diff --git a/packages/client-generator-ts/package.json b/packages/client-generator-ts/package.json index 8bba740e8bb1..a80322797155 100644 --- a/packages/client-generator-ts/package.json +++ b/packages/client-generator-ts/package.json @@ -28,7 +28,7 @@ "@prisma/client-common": "workspace:*", "@prisma/debug": "workspace:*", "@prisma/dmmf": "workspace:*", - "@prisma/engines-version": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "workspace:*", "@prisma/generator": "workspace:*", "@prisma/get-platform": "workspace:*", diff --git a/packages/client/package.json b/packages/client/package.json index 97275cf6e8f1..a86e9cd4fa18 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -198,7 +198,7 @@ "@prisma/dmmf": "workspace:*", "@prisma/driver-adapter-utils": "workspace:*", "@prisma/engines": "workspace:*", - "@prisma/engines-version": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "workspace:*", "@prisma/generator": "workspace:*", "@prisma/generator-helper": "workspace:*", @@ -210,7 +210,7 @@ "@prisma/migrate": "workspace:*", "@prisma/param-graph": "workspace:*", "@prisma/param-graph-builder": "workspace:*", - "@prisma/query-compiler-wasm": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/query-compiler-wasm": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/query-plan-executor": "workspace:*", "@prisma/sqlcommenter": "workspace:*", "@prisma/sqlcommenter-trace-context": "workspace:*", diff --git a/packages/client/tests/functional/issues/29212-array-push-regression/_matrix.ts b/packages/client/tests/functional/issues/29212-array-push-regression/_matrix.ts new file mode 100644 index 000000000000..4002505d9f7a --- /dev/null +++ b/packages/client/tests/functional/issues/29212-array-push-regression/_matrix.ts @@ -0,0 +1,8 @@ +import { defineMatrix } from '../../_utils/defineMatrix' +import { allProviders, Providers } from '../../_utils/providers' + +export default defineMatrix(() => [ + allProviders.filter( + ({ provider }) => provider !== Providers.MYSQL && provider !== Providers.SQLITE && provider !== Providers.SQLSERVER, + ), +]) diff --git a/packages/client/tests/functional/issues/29212-array-push-regression/prisma/_schema.ts b/packages/client/tests/functional/issues/29212-array-push-regression/prisma/_schema.ts new file mode 100644 index 000000000000..ee79bf95663e --- /dev/null +++ b/packages/client/tests/functional/issues/29212-array-push-regression/prisma/_schema.ts @@ -0,0 +1,20 @@ +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 Item { + id ${idForProvider(provider, { includeDefault: true })} + name String + tags String[] + } + ` +}) diff --git a/packages/client/tests/functional/issues/29212-array-push-regression/tests.ts b/packages/client/tests/functional/issues/29212-array-push-regression/tests.ts new file mode 100644 index 000000000000..847b7ac2b1ba --- /dev/null +++ b/packages/client/tests/functional/issues/29212-array-push-regression/tests.ts @@ -0,0 +1,35 @@ +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite( + () => { + test('correctly pushes to array field', async () => { + const item = await prisma.item.create({ + data: { + name: 'Test', + tags: ['a', 'b', 'c'], + }, + }) + + const updated = await prisma.item.update({ + where: { id: item.id }, + data: { tags: { push: ['foo', 'bar'] } }, + }) + + expect(updated).toMatchObject({ + id: item.id, + name: 'Test', + tags: ['a', 'b', 'c', 'foo', 'bar'], + }) + }) + }, + { + optOut: { + from: ['mysql', 'sqlite', 'sqlserver'], + reason: 'Array push is not supported on these providers', + }, + }, +) diff --git a/packages/engines/package.json b/packages/engines/package.json index fdf1ff58dab6..11ebc17867e8 100644 --- a/packages/engines/package.json +++ b/packages/engines/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@prisma/debug": "workspace:*", - "@prisma/engines-version": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "workspace:*", "@prisma/get-platform": "workspace:*" }, diff --git a/packages/fetch-engine/package.json b/packages/fetch-engine/package.json index 95ba352e7ec8..3d85667e9756 100644 --- a/packages/fetch-engine/package.json +++ b/packages/fetch-engine/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@prisma/debug": "workspace:*", - "@prisma/engines-version": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/get-platform": "workspace:*" }, "scripts": { diff --git a/packages/internals/package.json b/packages/internals/package.json index 15bd53fe12dc..6140e802d16d 100644 --- a/packages/internals/package.json +++ b/packages/internals/package.json @@ -79,8 +79,8 @@ "@prisma/generator": "workspace:*", "@prisma/generator-helper": "workspace:*", "@prisma/get-platform": "workspace:*", - "@prisma/prisma-schema-wasm": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", - "@prisma/schema-engine-wasm": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/prisma-schema-wasm": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "@prisma/schema-engine-wasm": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/schema-files-loader": "workspace:*", "arg": "5.0.2", "prompts": "2.4.2" diff --git a/packages/migrate/package.json b/packages/migrate/package.json index b4b7584587bd..6bb1d153752e 100644 --- a/packages/migrate/package.json +++ b/packages/migrate/package.json @@ -54,7 +54,7 @@ "@prisma/config": "workspace:*", "@prisma/debug": "workspace:*", "@prisma/driver-adapter-utils": "workspace:*", - "@prisma/engines-version": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/generator": "workspace:*", "@prisma/get-platform": "workspace:*", "@prisma/internals": "workspace:*", diff --git a/packages/schema-files-loader/package.json b/packages/schema-files-loader/package.json index 99732ab2452e..3ab27ce258a2 100644 --- a/packages/schema-files-loader/package.json +++ b/packages/schema-files-loader/package.json @@ -22,7 +22,7 @@ ], "sideEffects": false, "dependencies": { - "@prisma/prisma-schema-wasm": "7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21", + "@prisma/prisma-schema-wasm": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "fs-extra": "11.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1455db5521d1..fe4f12147dad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -730,8 +730,8 @@ importers: specifier: workspace:* version: link:../engines '@prisma/engines-version': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/fetch-engine': specifier: workspace:* version: link:../fetch-engine @@ -766,8 +766,8 @@ importers: specifier: workspace:* version: link:../param-graph-builder '@prisma/query-compiler-wasm': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/query-plan-executor': specifier: workspace:* version: link:../query-plan-executor @@ -1010,8 +1010,8 @@ importers: specifier: workspace:* version: link:../dmmf '@prisma/engines-version': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/fetch-engine': specifier: workspace:* version: link:../fetch-engine @@ -1083,8 +1083,8 @@ importers: specifier: workspace:* version: link:../dmmf '@prisma/engines-version': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/fetch-engine': specifier: workspace:* version: link:../fetch-engine @@ -1199,8 +1199,8 @@ importers: specifier: workspace:* version: link:../debug '@prisma/engines-version': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/fetch-engine': specifier: workspace:* version: link:../fetch-engine @@ -1233,8 +1233,8 @@ importers: specifier: workspace:* version: link:../debug '@prisma/engines-version': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/get-platform': specifier: workspace:* version: link:../get-platform @@ -1516,11 +1516,11 @@ importers: specifier: workspace:* version: link:../get-platform '@prisma/prisma-schema-wasm': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/schema-engine-wasm': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/schema-files-loader': specifier: workspace:* version: link:../schema-files-loader @@ -1675,8 +1675,8 @@ importers: specifier: workspace:* version: link:../driver-adapter-utils '@prisma/engines-version': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/generator': specifier: workspace:* version: link:../generator @@ -1837,8 +1837,8 @@ importers: packages/schema-files-loader: dependencies: '@prisma/prisma-schema-wasm': - specifier: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 - version: 7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21 + specifier: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 + version: 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 fs-extra: specifier: 11.3.0 version: 11.3.0 @@ -1883,7 +1883,7 @@ importers: dependencies: '@ark/attest': specifier: 0.48.2 - version: 0.48.2(typescript@5.4.5) + version: 0.48.2(typescript@5.8.2) '@prisma/client': specifier: workspace:* version: link:../client @@ -3596,8 +3596,8 @@ packages: '@prisma/dev@0.20.0': resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==} - '@prisma/engines-version@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': - resolution: {integrity: sha512-eWgBDH+mS98J4VKqYjC6PHWXRP0RbA2ZTZ8Nagj2JT+/wQGYHyXq4q2kkBWU2fXnmBLnyQBsaKbugYOzHHyomQ==} + '@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': + resolution: {integrity: sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==} '@prisma/get-platform@7.2.0': resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} @@ -3608,17 +3608,17 @@ packages: '@prisma/ppg@1.0.1': resolution: {integrity: sha512-rRRXuPPerXwNWjSA3OE0e/bqXSTfsE82EsMvoiluc0fN0DizQSe3937/Tnl5+DPbxY5rdAOlYjWXG0A2wwTbKA==} - '@prisma/prisma-schema-wasm@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': - resolution: {integrity: sha512-nF5n4gz4bX1hKQMlt/BZZSBjjuhzTlBkaru3dm86H1oGuRJ4ZY88pUKtvoezV/VLPsoF/MSYgMcIWT+HsKz0Sw==} + '@prisma/prisma-schema-wasm@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': + resolution: {integrity: sha512-DV5lGaN01UD4t9kilHXekw7oruBvigjkvYspaCq+D961zAYJrXuSDVXe4wM0EDVml0ujoa3apNf/QRxv0ecVuA==} - '@prisma/query-compiler-wasm@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': - resolution: {integrity: sha512-bbQW/gOLPkunQGOeAV9+PoN8KJY3QShoTfx+MBF4ZETnlO5zmY4zFq497Rzam0qMR82kb39BbpxVaO0dEcVwBQ==} + '@prisma/query-compiler-wasm@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': + resolution: {integrity: sha512-kjrNCKHwoEWp+5TNDPJPYtkjnA23QQaqiOnxb04Tq31fDd1d17ilMnq9nDjxzx7//WgGXONbXQBHvnLG5+GZSw==} '@prisma/query-plan-executor@7.2.0': resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} - '@prisma/schema-engine-wasm@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': - resolution: {integrity: sha512-KMJHUMIhqxgfzBeOYCKaeiUMZ2kM+JYGVt3idqM60PUhuE6agNQdYWkZKURwU8G64MUy26IEcFKWXj8kTz01Ag==} + '@prisma/schema-engine-wasm@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': + resolution: {integrity: sha512-v7E/pxOvzJyILTK5ZvBs7fLjcoFPG2t6BdnAsfKIZtQZlQz7TyTC3kkZvcigtnV/6/ktVOsd0RQFIk0rD4e5lg==} '@prisma/studio-core@0.13.1': resolution: {integrity: sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==} @@ -8436,16 +8436,16 @@ snapshots: '@antfu/ni@0.21.12': {} - '@ark/attest@0.48.2(typescript@5.4.5)': + '@ark/attest@0.48.2(typescript@5.8.2)': dependencies: '@ark/fs': 0.46.0 '@ark/util': 0.46.0 '@prettier/sync': 0.5.5(prettier@3.5.3) '@typescript/analyze-trace': 0.10.1 - '@typescript/vfs': 1.6.1(typescript@5.4.5) + '@typescript/vfs': 1.6.1(typescript@5.8.2) arktype: 2.1.20 prettier: 3.5.3 - typescript: 5.4.5 + typescript: 5.8.2 transitivePeerDependencies: - supports-color @@ -10088,7 +10088,7 @@ snapshots: transitivePeerDependencies: - typescript - '@prisma/engines-version@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': {} + '@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} '@prisma/get-platform@7.2.0': dependencies: @@ -10105,13 +10105,13 @@ snapshots: - bufferutil - utf-8-validate - '@prisma/prisma-schema-wasm@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': {} + '@prisma/prisma-schema-wasm@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} - '@prisma/query-compiler-wasm@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': {} + '@prisma/query-compiler-wasm@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} '@prisma/query-plan-executor@7.2.0': {} - '@prisma/schema-engine-wasm@7.5.0-9.c6be8e68bf8e4a36534064f9323a343f2fcafe21': {} + '@prisma/schema-engine-wasm@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} '@prisma/studio-core@0.13.1': {} @@ -10700,10 +10700,10 @@ snapshots: treeify: 1.1.0 yargs: 16.2.0 - '@typescript/vfs@1.6.1(typescript@5.4.5)': + '@typescript/vfs@1.6.1(typescript@5.8.2)': dependencies: debug: 4.4.1 - typescript: 5.4.5 + typescript: 5.8.2 transitivePeerDependencies: - supports-color From 90d119c1dd9a69bf4ac2a53976c6dc52a11686eb Mon Sep 17 00:00:00 2001 From: jacek-prisma Date: Fri, 27 Feb 2026 13:39:37 +0000 Subject: [PATCH 2/4] fix: support joins that do not require strict equality (#29251) [TML-1868](https://linear.app/prisma-company/issue/TML-1868/fix-mysql-bigint-relation-issue) Adds support for a new `canAssumeStrictEquality` flag used for in-memory joins. When it's set to false, we infer the type of the key present on the parent rows and inject casts for children rows. Fixes some MySQL edge cases. Fixes https://github.com/prisma/prisma/issues/29122 Engine PR: https://github.com/prisma/prisma-engines/pull/5785 ## Summary by CodeRabbit * **Bug Fixes** * Improved join key matching with optional per-field value mapping/type casting and a new flag to control strict-equality assumptions. * Standardized validation error identifiers to camelCase for more consistent error reporting. * **Tests** * Added MySQL-only functional tests for view relations with large BigInt keys. * **Chores** * Updated internal engine/tooling version references. --------- Co-authored-by: Prismo --- .../bench/sample-query-plans.ts | 4 ++ .../src/interpreter/in-memory-processing.ts | 7 ++- .../src/interpreter/query-interpreter.ts | 53 ++++++++++++++-- .../client-engine-runtime/src/query-plan.ts | 1 + .../_matrix.ts | 4 ++ .../prisma/_schema.ts | 36 +++++++++++ .../29122-mysql-bigint-view-relation/tests.ts | 62 +++++++++++++++++++ 7 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/_matrix.ts create mode 100644 packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/prisma/_schema.ts create mode 100644 packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/tests.ts diff --git a/packages/client-engine-runtime/bench/sample-query-plans.ts b/packages/client-engine-runtime/bench/sample-query-plans.ts index 6098ae1a2a6f..b0a96c7895ed 100644 --- a/packages/client-engine-runtime/bench/sample-query-plans.ts +++ b/packages/client-engine-runtime/bench/sample-query-plans.ts @@ -111,6 +111,7 @@ export const JOIN_PLAN: QueryPlanNode = { isRelationUnique: false, }, ], + canAssumeStrictEquality: true, }, }, structure: { @@ -231,6 +232,7 @@ export const DEEP_JOIN_PLAN: QueryPlanNode = { isRelationUnique: true, }, ], + canAssumeStrictEquality: true, }, }, children: [ @@ -278,6 +280,7 @@ export const DEEP_JOIN_PLAN: QueryPlanNode = { isRelationUnique: false, }, ], + canAssumeStrictEquality: true, }, }, on: [['id', 'authorId']], @@ -285,6 +288,7 @@ export const DEEP_JOIN_PLAN: QueryPlanNode = { isRelationUnique: false, }, ], + canAssumeStrictEquality: true, }, }, structure: { diff --git a/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts b/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts index 9da17a174f5c..7101af69a74c 100644 --- a/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts +++ b/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts @@ -111,6 +111,9 @@ function paginateSingleList(list: {}[], { cursor, skip, take }: Pagination): {}[ /* * Generate a key string for a record based on the values of the specified fields. */ -export function getRecordKey(record: {}, fields: string[]): string { - return JSON.stringify(fields.map((field) => record[field])) +export function getRecordKey(record: {}, fields: string[], mappers?: ((value: unknown) => unknown)[]): string { + const array = fields.map((field, index) => + mappers?.[index] ? (record[field] !== null ? mappers[index](record[field]) : null) : record[field], + ) + return JSON.stringify(array) } diff --git a/packages/client-engine-runtime/src/interpreter/query-interpreter.ts b/packages/client-engine-runtime/src/interpreter/query-interpreter.ts index b2c25ac4eda4..3b8fad80a9c3 100644 --- a/packages/client-engine-runtime/src/interpreter/query-interpreter.ts +++ b/packages/client-engine-runtime/src/interpreter/query-interpreter.ts @@ -243,7 +243,7 @@ export class QueryInterpreter { })), )) satisfies JoinExpressionWithRecords[] - return { value: attachChildrenToParents(parent, children), lastInsertId } + return { value: attachChildrenToParents(parent, children, node.args.canAssumeStrictEquality), lastInsertId } } case 'transaction': { @@ -420,13 +420,20 @@ type JoinExpressionWithRecords = { childRecords: Value } -function attachChildrenToParents(parentRecords: unknown, children: JoinExpressionWithRecords[]) { +type KeyCast = (value: Value) => Value + +function attachChildrenToParents( + parentRecords: unknown, + children: JoinExpressionWithRecords[], + canAssumeStrictEquality: boolean, +) { for (const { joinExpr, childRecords } of children) { const parentKeys = joinExpr.on.map(([k]) => k) const childKeys = joinExpr.on.map(([, k]) => k) const parentMap = {} - for (const parent of Array.isArray(parentRecords) ? parentRecords : [parentRecords]) { + const parentArray = Array.isArray(parentRecords) ? parentRecords : [parentRecords] + for (const parent of parentArray) { const parentRecord = asRecord(parent) const key = getRecordKey(parentRecord, parentKeys) if (!parentMap[key]) { @@ -441,12 +448,13 @@ function attachChildrenToParents(parentRecords: unknown, children: JoinExpressio } } + const mappers = canAssumeStrictEquality ? undefined : inferKeyCasts(parentArray, parentKeys) for (const childRecord of Array.isArray(childRecords) ? childRecords : [childRecords]) { if (childRecord === null) { continue } - const key = getRecordKey(asRecord(childRecord), childKeys) + const key = getRecordKey(asRecord(childRecord), childKeys, mappers) for (const parentRecord of parentMap[key] ?? []) { if (joinExpr.isRelationUnique) { parentRecord[joinExpr.parentField] = childRecord @@ -460,6 +468,43 @@ function attachChildrenToParents(parentRecords: unknown, children: JoinExpressio return parentRecords } +function inferKeyCasts(rows: unknown[], keys: string[]): KeyCast[] { + function getKeyCast(type: string): KeyCast | undefined { + switch (type) { + case 'number': + return Number + case 'string': + return String + case 'boolean': + return Boolean + case 'bigint': + return BigInt as KeyCast + default: + return + } + } + + const keyCasts: KeyCast[] = Array.from({ length: keys.length }) + let keysFound = 0 + for (const parent of rows) { + const parentRecord = asRecord(parent) + for (const [i, key] of keys.entries()) { + if (parentRecord[key] !== null && keyCasts[i] === undefined) { + const keyCast = getKeyCast(typeof parentRecord[key]) + if (keyCast !== undefined) { + keyCasts[i] = keyCast + } + keysFound++ + } + } + if (keysFound === keys.length) { + break + } + } + + return keyCasts +} + function evalFieldInitializer( initializer: FieldInitializer, lastInsertId: string | undefined, diff --git a/packages/client-engine-runtime/src/query-plan.ts b/packages/client-engine-runtime/src/query-plan.ts index 61b561845672..751a56e3dab3 100644 --- a/packages/client-engine-runtime/src/query-plan.ts +++ b/packages/client-engine-runtime/src/query-plan.ts @@ -154,6 +154,7 @@ export type QueryPlanNode = args: { parent: QueryPlanNode children: JoinExpression[] + canAssumeStrictEquality: boolean } } | { diff --git a/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/_matrix.ts b/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/_matrix.ts new file mode 100644 index 000000000000..3585f7b579f9 --- /dev/null +++ b/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/_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/29122-mysql-bigint-view-relation/prisma/_schema.ts b/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/prisma/_schema.ts new file mode 100644 index 000000000000..8bbbc863390e --- /dev/null +++ b/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/prisma/_schema.ts @@ -0,0 +1,36 @@ +import testMatrix from '../_matrix' + +export default testMatrix.setupSchema(({ provider }) => { + return /* Prisma */ ` + generator client { + provider = "prisma-client-js" + previewFeatures = ["views"] + } + + datasource db { + provider = "${provider}" + } + + model users { + id Int @id @default(autoincrement()) + posts posts[] + userPostSummary user_post_summary[] + } + + model posts { + id Int @id @default(autoincrement()) + user_id Int + users users @relation(fields: [user_id], references: [id]) + userPostSummary user_post_summary[] + + @@index([user_id]) + } + + view user_post_summary { + user_id Int @unique + last_post_id Int? + users users? @relation(fields: [user_id], references: [id]) + last_post posts? @relation(fields: [last_post_id], references: [id]) + } + ` +}) diff --git a/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/tests.ts b/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/tests.ts new file mode 100644 index 000000000000..946fbd414347 --- /dev/null +++ b/packages/client/tests/functional/issues/29122-mysql-bigint-view-relation/tests.ts @@ -0,0 +1,62 @@ +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite( + () => { + test('correctly handles an integer key returned from a view relation in MySQL', async () => { + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE VIEW user_post_summary AS + SELECT users.id AS user_id, + ( + SELECT posts.id + FROM posts + WHERE posts.user_id = users.id + ORDER BY posts.id DESC + LIMIT 1 + ) AS last_post_id + FROM users + `) + + await prisma.users.create({ + data: { + id: 1, + posts: { + create: [ + { + id: 1, + }, + ], + }, + }, + }) + + const result = await prisma.user_post_summary.findMany({ + include: { + last_post: true, + }, + }) + + expect(result).toMatchInlineSnapshot(` + [ + { + "last_post": { + "id": 1, + "user_id": 1, + }, + "last_post_id": 1, + "user_id": 1, + }, + ] + `) + }) + }, + { + optOut: { + from: ['postgresql', 'sqlite', 'cockroachdb', 'sqlserver', 'mongodb'], + reason: 'This test is only relevant for MySQL, as it tests a MySQL specific regression.', + }, + }, +) From 658697212823ac3aae4b84c206ec091106eda1ae Mon Sep 17 00:00:00 2001 From: jacek-prisma Date: Fri, 27 Feb 2026 14:00:16 +0000 Subject: [PATCH 3/4] fix: fix accidental query plan mutation (#29262) [TML-1946](https://linear.app/prisma-company/issue/TML-1946/investigate-and-fix-null-cursor-regression) Fixes a bug where the query interpreter would unintentionally mutate the query plan. I've marked all of the query plan bits as readonly in the query interpreter to prevent similar issues. Fixes https://github.com/prisma/prisma/issues/29254 ## Summary by CodeRabbit * **Tests** * Added functional tests, a test matrix and a dynamic schema to cover query-plan cache mutation and paging scenarios. * **Refactor** * Enforced immutability across the query runtime and rendering pipeline by using DeepReadonly/DeepUnreadonly and safer cloning where needed. * Introduced deep readonly/unreadonly type utilities. * **Bug Fixes** * Made parameter and field collections readonly in public signatures to prevent accidental mutations. --- packages/client-engine-runtime/src/events.ts | 2 +- .../src/interpreter/in-memory-processing.ts | 2 +- .../src/interpreter/query-interpreter.ts | 44 +++++++++++------ .../src/interpreter/render-query.ts | 32 ++++++++----- .../src/interpreter/validation.ts | 4 +- packages/client-engine-runtime/src/tracing.ts | 4 +- packages/client-engine-runtime/src/utils.ts | 16 +++++++ .../_matrix.ts | 4 ++ .../prisma/_schema.ts | 19 ++++++++ .../29254-query-plan-cache-mutation/tests.ts | 47 +++++++++++++++++++ 10 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 packages/client/tests/functional/issues/29254-query-plan-cache-mutation/_matrix.ts create mode 100644 packages/client/tests/functional/issues/29254-query-plan-cache-mutation/prisma/_schema.ts create mode 100644 packages/client/tests/functional/issues/29254-query-plan-cache-mutation/tests.ts diff --git a/packages/client-engine-runtime/src/events.ts b/packages/client-engine-runtime/src/events.ts index e87fe630cfc1..b5cae9e8ec6b 100644 --- a/packages/client-engine-runtime/src/events.ts +++ b/packages/client-engine-runtime/src/events.ts @@ -1,6 +1,6 @@ export type QueryEvent = { timestamp: Date query: string - params: unknown[] + params: readonly unknown[] duration: number } diff --git a/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts b/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts index 7101af69a74c..afd7dc5fb9a5 100644 --- a/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts +++ b/packages/client-engine-runtime/src/interpreter/in-memory-processing.ts @@ -111,7 +111,7 @@ function paginateSingleList(list: {}[], { cursor, skip, take }: Pagination): {}[ /* * Generate a key string for a record based on the values of the specified fields. */ -export function getRecordKey(record: {}, fields: string[], mappers?: ((value: unknown) => unknown)[]): string { +export function getRecordKey(record: {}, fields: readonly string[], mappers?: ((value: unknown) => unknown)[]): string { const array = fields.map((field, index) => mappers?.[index] ? (record[field] !== null ? mappers[index](record[field]) : null) : record[field], ) diff --git a/packages/client-engine-runtime/src/interpreter/query-interpreter.ts b/packages/client-engine-runtime/src/interpreter/query-interpreter.ts index 3b8fad80a9c3..f63fd9cb271c 100644 --- a/packages/client-engine-runtime/src/interpreter/query-interpreter.ts +++ b/packages/client-engine-runtime/src/interpreter/query-interpreter.ts @@ -1,5 +1,6 @@ import { ConnectionInfo, SqlQuery, SqlQueryable, SqlResultSet } from '@prisma/driver-adapter-utils' import type { SqlCommenterPlugin, SqlCommenterQueryInfo } from '@prisma/sqlcommenter' +import { klona } from 'klona' import { QueryEvent } from '../events' import { FieldInitializer, FieldOperation, InMemoryOps, JoinExpression, QueryPlanNode } from '../query-plan' @@ -8,7 +9,7 @@ import { appendSqlComment, buildSqlComment } from '../sql-commenter' import { type TracingHelper, withQuerySpanAndEvent } from '../tracing' import { type TransactionManager } from '../transaction-manager/transaction-manager' import { rethrowAsUserFacing, rethrowAsUserFacingRawError } from '../user-facing-error' -import { assertNever } from '../utils' +import { assertNever, DeepReadonly, DeepUnreadonly } from '../utils' import { applyDataMap } from './data-mapper' import { GeneratorRegistry, GeneratorRegistrySnapshot } from './generators' import { getRecordKey, processRecords } from './in-memory-processing' @@ -89,7 +90,7 @@ export class QueryInterpreter { }) } - async run(queryPlan: QueryPlanNode, options: QueryRuntimeOptions): Promise { + async run(queryPlan: DeepReadonly, options: QueryRuntimeOptions): Promise { const { value } = await this.interpretNode(queryPlan, { ...options, generators: this.#generators.snapshot(), @@ -98,7 +99,10 @@ export class QueryInterpreter { return value } - private async interpretNode(node: QueryPlanNode, context: QueryRuntimeContext): Promise { + private async interpretNode( + node: DeepReadonly, + context: QueryRuntimeContext, + ): Promise { switch (node.type) { case 'value': { return { @@ -163,7 +167,7 @@ export class QueryInterpreter { const commentedQuery = applyComments(query, context.sqlCommenter) sum += await this.#withQuerySpanAndEvent(commentedQuery, context.queryable, () => context.queryable - .executeRaw(commentedQuery) + .executeRaw(cloneObject(commentedQuery)) .catch((err) => node.args.type === 'rawSql' ? rethrowAsUserFacingRawError(err) : rethrowAsUserFacing(err), ), @@ -181,7 +185,7 @@ export class QueryInterpreter { const commentedQuery = applyComments(query, context.sqlCommenter) const result = await this.#withQuerySpanAndEvent(commentedQuery, context.queryable, () => context.queryable - .queryRaw(commentedQuery) + .queryRaw(cloneObject(commentedQuery)) .catch((err) => node.args.type === 'rawSql' ? rethrowAsUserFacingRawError(err) : rethrowAsUserFacing(err), ), @@ -236,12 +240,12 @@ export class QueryInterpreter { return { value: null, lastInsertId } } - const children = (await Promise.all( + const children = await Promise.all( node.args.children.map(async (joinExpr) => ({ joinExpr, childRecords: (await this.interpretNode(joinExpr.child, context)).value, })), - )) satisfies JoinExpressionWithRecords[] + ) return { value: attachChildrenToParents(parent, children, node.args.canAssumeStrictEquality), lastInsertId } } @@ -301,8 +305,9 @@ export class QueryInterpreter { case 'process': { const { value, lastInsertId } = await this.interpretNode(node.args.expr, context) - evaluateProcessingParameters(node.args.operations, context.scope, context.generators) - return { value: processRecords(value, node.args.operations), lastInsertId } + const ops = cloneObject(node.args.operations) + evaluateProcessingParameters(ops, context.scope, context.generators) + return { value: processRecords(value, ops), lastInsertId } } case 'initializeRecord': { @@ -360,7 +365,11 @@ export class QueryInterpreter { } } - #withQuerySpanAndEvent(query: SqlQuery, queryable: SqlQueryable, execute: () => Promise): Promise { + #withQuerySpanAndEvent( + query: DeepReadonly, + queryable: SqlQueryable, + execute: () => Promise, + ): Promise { return withQuerySpanAndEvent({ query, execute, @@ -424,7 +433,7 @@ type KeyCast = (value: Value) => Value function attachChildrenToParents( parentRecords: unknown, - children: JoinExpressionWithRecords[], + children: DeepReadonly, canAssumeStrictEquality: boolean, ) { for (const { joinExpr, childRecords } of children) { @@ -506,7 +515,7 @@ function inferKeyCasts(rows: unknown[], keys: string[]): KeyCast[] { } function evalFieldInitializer( - initializer: FieldInitializer, + initializer: DeepReadonly, lastInsertId: string | undefined, scope: ScopeBindings, generators: GeneratorRegistrySnapshot, @@ -522,7 +531,7 @@ function evalFieldInitializer( } function evalFieldOperation( - op: FieldOperation, + op: DeepReadonly, value: Value, scope: ScopeBindings, generators: GeneratorRegistrySnapshot, @@ -553,7 +562,10 @@ function evalFieldOperation( } } -function applyComments(query: SqlQuery, sqlCommenter?: QueryInterpreterSqlCommenter): SqlQuery { +function applyComments( + query: DeepReadonly, + sqlCommenter?: QueryInterpreterSqlCommenter, +): DeepReadonly { if (!sqlCommenter || sqlCommenter.plugins.length === 0) { return query } @@ -588,3 +600,7 @@ function evaluateProcessingParameters( evaluateProcessingParameters(nested, scope, generators) } } + +function cloneObject(value: T): DeepUnreadonly { + return klona(value) as DeepUnreadonly +} diff --git a/packages/client-engine-runtime/src/interpreter/render-query.ts b/packages/client-engine-runtime/src/interpreter/render-query.ts index 732ea3c08345..cbb4441e9ba9 100644 --- a/packages/client-engine-runtime/src/interpreter/render-query.ts +++ b/packages/client-engine-runtime/src/interpreter/render-query.ts @@ -11,16 +11,16 @@ import { type QueryPlanDbQuery, } from '../query-plan' import { UserFacingError } from '../user-facing-error' -import { assertNever } from '../utils' +import { assertNever, DeepReadonly } from '../utils' import { GeneratorRegistrySnapshot } from './generators' import { ScopeBindings } from './scope' export function renderQuery( - dbQuery: QueryPlanDbQuery, + dbQuery: DeepReadonly, scope: ScopeBindings, generators: GeneratorRegistrySnapshot, maxChunkSize?: number, -): SqlQuery[] { +): DeepReadonly[] { const args = dbQuery.args.map((arg) => evaluateArg(arg, scope, generators)) switch (dbQuery.type) { @@ -69,10 +69,10 @@ export function evaluateArg(arg: unknown, scope: ScopeBindings, generators: Gene } function renderTemplateSql( - fragments: Fragment[], + fragments: DeepReadonly, placeholderFormat: PlaceholderFormat, params: unknown[], - argTypes: DynamicArgType[], + argTypes: DeepReadonly, ): SqlQuery { let sql = '' const ctx = { placeholderNumber: 1 } @@ -112,7 +112,7 @@ function renderTemplateSql( } } -function renderFragment( +function renderFragment | undefined>( fragment: FragmentWithParams, placeholderFormat: PlaceholderFormat, ctx: { placeholderNumber: number }, @@ -158,10 +158,14 @@ function formatPlaceholder(placeholderFormat: PlaceholderFormat, placeholderNumb return placeholderFormat.hasNumbering ? `${placeholderFormat.prefix}${placeholderNumber}` : placeholderFormat.prefix } -function renderRawSql(sql: string, args: unknown[], argTypes: ArgType[]): SqlQuery { +function renderRawSql( + sql: string, + args: readonly unknown[], + argTypes: DeepReadonly, +): DeepReadonly { return { sql, - args: args, + args, argTypes, } } @@ -170,7 +174,7 @@ function doesRequireEvaluation(param: unknown): param is PrismaValuePlaceholder return isPrismaValuePlaceholder(param) || isPrismaValueGenerator(param) } -type FragmentWithParams = Fragment & +type FragmentWithParams | undefined = undefined> = Fragment & ( | { type: 'stringChunk' } | { type: 'parameter'; value: unknown; argType: Type } @@ -179,10 +183,12 @@ type FragmentWithParams = F ) function* pairFragmentsWithParams( - fragments: Fragment[], + fragments: DeepReadonly, params: unknown[], argTypes: Types, -): Generator> { +): Generator< + FragmentWithParams ? DeepReadonly : undefined> +> { let index = 0 for (const fragment of fragments) { @@ -239,7 +245,7 @@ function* pairFragmentsWithParams( } } -function* flattenedFragmentParams( +function* flattenedFragmentParams | undefined>( fragment: FragmentWithParams, ): Generator { switch (fragment.type) { @@ -259,7 +265,7 @@ function* flattenedFragmentParams( } } -function chunkParams(fragments: Fragment[], params: unknown[], maxChunkSize?: number): unknown[][] { +function chunkParams(fragments: DeepReadonly, params: unknown[], maxChunkSize?: number): unknown[][] { // Find out the total number of parameters once flattened and what the maximum number of // parameters in a single fragment is. let totalParamCount = 0 diff --git a/packages/client-engine-runtime/src/interpreter/validation.ts b/packages/client-engine-runtime/src/interpreter/validation.ts index 42c5a9dec07a..87c47d387d51 100644 --- a/packages/client-engine-runtime/src/interpreter/validation.ts +++ b/packages/client-engine-runtime/src/interpreter/validation.ts @@ -1,8 +1,8 @@ import { DataRule, ValidationError } from '../query-plan' import { UserFacingError } from '../user-facing-error' -import { assertNever } from '../utils' +import { assertNever, DeepReadonly } from '../utils' -export function performValidation(data: unknown, rules: DataRule[], error: ValidationError) { +export function performValidation(data: unknown, rules: DeepReadonly, error: ValidationError) { if (!rules.every((rule) => doesSatisfyRule(data, rule))) { const message = renderMessage(data, error) const code = getErrorCode(error) diff --git a/packages/client-engine-runtime/src/tracing.ts b/packages/client-engine-runtime/src/tracing.ts index 82234c641572..5be434403c3d 100644 --- a/packages/client-engine-runtime/src/tracing.ts +++ b/packages/client-engine-runtime/src/tracing.ts @@ -3,7 +3,7 @@ import type { SqlQuery } from '@prisma/driver-adapter-utils' import { QueryEvent } from './events' import type { SchemaProvider } from './schema' -import { assertNever } from './utils' +import { assertNever, DeepReadonly } from './utils' export type SpanCallback = (span?: Span, context?: Context) => R @@ -51,7 +51,7 @@ export async function withQuerySpanAndEvent({ onQuery, execute, }: { - query: SqlQuery + query: DeepReadonly tracingHelper: TracingHelper provider: SchemaProvider onQuery?: (event: QueryEvent) => void diff --git a/packages/client-engine-runtime/src/utils.ts b/packages/client-engine-runtime/src/utils.ts index 744333c245ef..a5948f1d2739 100644 --- a/packages/client-engine-runtime/src/utils.ts +++ b/packages/client-engine-runtime/src/utils.ts @@ -1,5 +1,21 @@ import { Decimal } from '@prisma/client-runtime-utils' +export type DeepReadonly = T extends undefined | null | boolean | string | number | symbol | Function | Date + ? T + : T extends Array + ? ReadonlyArray> + : unknown extends T + ? unknown + : { readonly [K in keyof T]: DeepReadonly } + +export type DeepUnreadonly = T extends undefined | null | boolean | string | number | symbol | Function | Date + ? T + : T extends ReadonlyArray + ? Array> + : unknown extends T + ? unknown + : { -readonly [K in keyof T]: DeepUnreadonly } + // Copied over to avoid the heavy dependency on `@prisma/internals` with its // transitive dependencies that are not needed for other query plan executor // implementations outside of Prisma Client (e.g. test executor for query diff --git a/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/_matrix.ts b/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/_matrix.ts new file mode 100644 index 000000000000..d56f2f6b7702 --- /dev/null +++ b/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/_matrix.ts @@ -0,0 +1,4 @@ +import { defineMatrix } from '../../_utils/defineMatrix' +import { allProviders } from '../../_utils/providers' + +export default defineMatrix(() => [allProviders]) diff --git a/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/prisma/_schema.ts b/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/prisma/_schema.ts new file mode 100644 index 000000000000..624eedad7a1a --- /dev/null +++ b/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/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 Item { + id ${idForProvider(provider, { includeDefault: true })} + price Float? + } + ` +}) diff --git a/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/tests.ts b/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/tests.ts new file mode 100644 index 000000000000..35f536a00e87 --- /dev/null +++ b/packages/client/tests/functional/issues/29254-query-plan-cache-mutation/tests.ts @@ -0,0 +1,47 @@ +import { faker } from '@snaplet/copycat' + +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite(() => { + const id1 = faker.database.mongodbObjectId() + const id2 = faker.database.mongodbObjectId() + const id3 = faker.database.mongodbObjectId() + + afterEach(async () => { + await prisma.item.deleteMany() + }) + + beforeEach(async () => { + await prisma.item.createMany({ + data: [ + { id: id1, price: 10 }, + { id: id2, price: 20 }, + { id: id3, price: 30 }, + ], + }) + }) + + test('correctly handles two subsequent queries with a different cursor', async () => { + const ORDER_BY_NULLABLE = [{ price: 'asc' as const }, { id: 'asc' as const }] + + const result1 = await prisma.item.findMany({ + orderBy: ORDER_BY_NULLABLE, + cursor: { id: id1 }, + skip: 1, + take: 1, + }) + expect(result1).toEqual([{ id: id2, price: 20 }]) + + const result2 = await prisma.item.findMany({ + orderBy: ORDER_BY_NULLABLE, + cursor: { id: id2 }, + skip: 1, + take: 1, + }) + expect(result2).toEqual([{ id: id3, price: 30 }]) + }) +}) From 455853d0ddae89da28ff9f9fa65c5ed0a803c908 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Fri, 27 Feb 2026 23:14:41 +0900 Subject: [PATCH 4/4] fix: use safeJsonStringify for nested Uint8Array in Json fields (#29268) Fixes https://github.com/prisma/prisma/issues/29267 ## Problem `Uint8Array` values nested inside objects or arrays are serialized as numeric-keyed objects (e.g. `{"0":72,"1":101,"2":108,"3":108,"4":111}`) instead of base64 strings when stored in a `Json` field. Top-level `Uint8Array` values are unaffected. This was introduced in #29182, which added `deserializeJsonObject()` before `JSON.stringify()` in the parameterization step to fix Date handling in Json fields. `deserializeJsonObject` correctly converts `{ $type: 'Bytes', value: 'SGVsbG8=' }` back to a `Uint8Array`, but `JSON.stringify` cannot natively serialize `Uint8Array` and falls back to `{"0":72,...}`. ## Fix Replace `JSON.stringify` with `safeJsonStringify` (already exported from `@prisma/client-engine-runtime`) in both the `#handleArray` and `#handleObject` methods of `Parameterizer`. `safeJsonStringify` uses a custom replacer that converts `ArrayBuffer.isView` values to base64 strings and `BigInt` to string representation. ## Tests Added a functional regression test in `packages/client/tests/functional/issues/29267-uint8array-in-json/` covering: - `Uint8Array` nested inside an object - `Uint8Array` nested inside an array - `Uint8Array` passed directly (already working, included for completeness) - Deeply nested `Uint8Array` ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Improved JSON serialization for parameter handling to properly convert Uint8Array values to base64 strings when stored in JSON fields, ensuring correct data handling across create operations. * **Tests** * Added comprehensive test coverage for Uint8Array serialization in JSON fields, including scenarios with nested objects, arrays, and various nesting depths across multiple database providers. --- .../client/parameterization/parameterize.ts | 6 +- .../29267-uint8array-in-json/_matrix.ts | 4 ++ .../prisma/_schema.ts | 19 ++++++ .../issues/29267-uint8array-in-json/tests.ts | 68 +++++++++++++++++++ 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 packages/client/tests/functional/issues/29267-uint8array-in-json/_matrix.ts create mode 100644 packages/client/tests/functional/issues/29267-uint8array-in-json/prisma/_schema.ts create mode 100644 packages/client/tests/functional/issues/29267-uint8array-in-json/tests.ts diff --git a/packages/client/src/runtime/core/engines/client/parameterization/parameterize.ts b/packages/client/src/runtime/core/engines/client/parameterization/parameterize.ts index 9619b487ce0b..3edb3402b18a 100644 --- a/packages/client/src/runtime/core/engines/client/parameterization/parameterize.ts +++ b/packages/client/src/runtime/core/engines/client/parameterization/parameterize.ts @@ -6,7 +6,7 @@ * both schema rules and runtime value types agree. */ -import { deserializeJsonObject } from '@prisma/client-engine-runtime' +import { deserializeJsonObject, safeJsonStringify } from '@prisma/client-engine-runtime' import type { JsonArgumentValue, JsonBatchQuery, @@ -273,7 +273,7 @@ class Parameterizer { */ #handleArray(items: unknown[], originalValue: unknown, edge: InputEdge): unknown { if (hasFlag(edge, EdgeFlag.ParamScalar) && getScalarMask(edge) & ScalarMask.Json) { - const jsonValue = JSON.stringify(deserializeJsonObject(items)) + const jsonValue = safeJsonStringify(deserializeJsonObject(items)) const type: PlaceholderType = { type: 'Json' } return this.#getOrCreatePlaceholder(jsonValue, type) } @@ -324,7 +324,7 @@ class Parameterizer { const mask = getScalarMask(edge) if (mask & ScalarMask.Json) { - const jsonValue = JSON.stringify(deserializeJsonObject(obj)) + const jsonValue = safeJsonStringify(deserializeJsonObject(obj)) const type: PlaceholderType = { type: 'Json' } return this.#getOrCreatePlaceholder(jsonValue, type) } diff --git a/packages/client/tests/functional/issues/29267-uint8array-in-json/_matrix.ts b/packages/client/tests/functional/issues/29267-uint8array-in-json/_matrix.ts new file mode 100644 index 000000000000..169663a76317 --- /dev/null +++ b/packages/client/tests/functional/issues/29267-uint8array-in-json/_matrix.ts @@ -0,0 +1,4 @@ +import { defineMatrix } from '../../_utils/defineMatrix' +import { allProviders, Providers } from '../../_utils/providers' + +export default defineMatrix(() => [allProviders.filter(({ provider }) => provider !== Providers.SQLSERVER)]) diff --git a/packages/client/tests/functional/issues/29267-uint8array-in-json/prisma/_schema.ts b/packages/client/tests/functional/issues/29267-uint8array-in-json/prisma/_schema.ts new file mode 100644 index 000000000000..04ec2a7bf437 --- /dev/null +++ b/packages/client/tests/functional/issues/29267-uint8array-in-json/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 TestRecord { + id ${idForProvider(provider)} + data Json + } + ` +}) diff --git a/packages/client/tests/functional/issues/29267-uint8array-in-json/tests.ts b/packages/client/tests/functional/issues/29267-uint8array-in-json/tests.ts new file mode 100644 index 000000000000..d4665451d2fb --- /dev/null +++ b/packages/client/tests/functional/issues/29267-uint8array-in-json/tests.ts @@ -0,0 +1,68 @@ +import testMatrix from './_matrix' +// @ts-ignore +import type { PrismaClient } from './generated/prisma/client' + +declare let prisma: PrismaClient + +testMatrix.setupTestSuite( + () => { + test('serializes Uint8Array nested in object as base64', async () => { + const uint8 = new Uint8Array([72, 101, 108, 108, 111]) + + const record = await prisma.testRecord.create({ + data: { + data: { payload: uint8, label: 'test' } as any, + }, + }) + + expect(record.data).toEqual({ + payload: 'SGVsbG8=', + label: 'test', + }) + }) + + test('serializes Uint8Array nested in array as base64', async () => { + const uint8 = new Uint8Array([72, 101, 108, 108, 111]) + + const record = await prisma.testRecord.create({ + data: { + data: [uint8, 'hello'] as any, + }, + }) + + expect(record.data).toEqual(['SGVsbG8=', 'hello']) + }) + + test('serializes Uint8Array directly as base64', async () => { + const uint8 = new Uint8Array([72, 101, 108, 108, 111]) + + const record = await prisma.testRecord.create({ + data: { + data: uint8 as any, + }, + }) + + expect(record.data).toBe('SGVsbG8=') + }) + + test('serializes deeply nested Uint8Array as base64', async () => { + const uint8 = new Uint8Array([1, 2, 3]) + + const record = await prisma.testRecord.create({ + data: { + data: { outer: { inner: uint8 } } as any, + }, + }) + + expect(record.data).toEqual({ + outer: { inner: 'AQID' }, + }) + }) + }, + { + optOut: { + from: ['sqlserver'], + reason: 'SQL Server does not support JSON fields', + }, + }, +)