diff --git a/packages/client/src/generation/TSClient/PrismaClient.ts b/packages/client/src/generation/TSClient/PrismaClient.ts index 4e32671bf406..b553485a8359 100644 --- a/packages/client/src/generation/TSClient/PrismaClient.ts +++ b/packages/client/src/generation/TSClient/PrismaClient.ts @@ -11,6 +11,11 @@ import { Datasources } from './Datasources' import type { Generatable } from './Generatable' function batchingTransactionDefinition(this: PrismaClientClass) { + const args = ['arg: [...P]'] + if (this.dmmf.hasEnumInNamespace('TransactionIsolationLevel', 'prisma')) { + args.push('options?: { isolationLevel?: Prisma.TransactionIsolationLevel }') + } + const argsString = args.join(', ') return ` /** * Allows the running of a sequence of read/write operations that are guaranteed to either succeed or fail as a whole. @@ -25,7 +30,7 @@ function batchingTransactionDefinition(this: PrismaClientClass) { * * Read more in our [docs](https://www.prisma.io/docs/concepts/components/prisma-client/transactions). */ - $transaction

[]>(arg: [...P]): Promise>;` + $transaction

[]>(${argsString}): Promise>;` } function interactiveTransactionDefinition(this: PrismaClientClass) { diff --git a/packages/client/src/runtime/RequestHandler.ts b/packages/client/src/runtime/RequestHandler.ts index 722b98e413e4..5dfafc97404c 100644 --- a/packages/client/src/runtime/RequestHandler.ts +++ b/packages/client/src/runtime/RequestHandler.ts @@ -9,6 +9,7 @@ import { PrismaClientRustPanicError, PrismaClientUnknownRequestError, } from '.' +import { PrismaPromiseTransaction } from './core/request/PrismaPromise' import { DataLoader } from './DataLoader' import type { Client, Unpacker } from './getPrismaClient' import type { EngineMiddleware } from './MiddlewareHandler' @@ -30,11 +31,10 @@ export type RequestParams = { clientMethod: string callsite?: CallSite rejectOnNotFound?: RejectOnNotFound - runInTransaction?: boolean + transaction?: PrismaPromiseTransaction engineHook?: EngineMiddleware args: any headers?: Record - transactionId?: string | number unpacker?: Unpacker otelParentCtx?: Context otelChildCtx?: Context @@ -48,8 +48,7 @@ export type HandleErrorParams = { export type Request = { document: Document - runInTransaction?: boolean - transactionId?: string | number + transaction?: PrismaPromiseTransaction headers?: Record otelParentCtx?: Context otelChildCtx?: Context @@ -57,20 +56,22 @@ export type Request = { } function getRequestInfo(request: Request) { - const txId = request.transactionId - const inTx = request.runInTransaction + const transaction = request.transaction const headers = request.headers ?? {} const traceparent = getTraceParent({ tracingConfig: request.tracingConfig }) - // if the tx has a number for an id, then it's a regular batch tx - const _inTx = typeof txId === 'number' && inTx ? true : undefined - // if the tx has a string for id, it's an interactive transaction - const _txId = typeof txId === 'string' && inTx ? txId : undefined + if (transaction?.kind === 'itx') { + headers.transactionId = transaction.id + } - if (_txId !== undefined) headers.transactionId = _txId - if (traceparent !== undefined) headers.traceparent = traceparent + if (traceparent !== undefined) { + headers.traceparent = traceparent + } - return { inTx: _inTx, headers } + return { + batchTransaction: transaction?.kind === 'batch' ? transaction : undefined, + headers, + } } export class RequestHandler { @@ -91,7 +92,9 @@ export class RequestHandler { // TODO: pass the child information to QE for it to issue links to queries // const links = requests.map((r) => trace.getSpanContext(r.otelChildCtx!)) - return this.client._engine.requestBatch(queries, info.headers, info.inTx) + return this.client._engine.requestBatch(queries, info.headers, { + isolationLevel: info.batchTransaction?.isolationLevel, + }) }, singleLoader: (request) => { const info = getRequestInfo(request) @@ -100,8 +103,8 @@ export class RequestHandler { return this.client._engine.request(query, info.headers) }, batchBy: (request) => { - if (request.transactionId) { - return `transaction-${request.transactionId}` + if (request.transaction?.id) { + return `transaction-${request.transaction.id}` } return batchFindUniqueBy(request) @@ -118,11 +121,10 @@ export class RequestHandler { callsite, rejectOnNotFound, clientMethod, - runInTransaction, engineHook, args, headers, - transactionId, + transaction, unpacker, otelParentCtx, otelChildCtx, @@ -149,18 +151,19 @@ export class RequestHandler { const result = await engineHook( { document, - runInTransaction, + runInTransaction: Boolean(transaction), + }, + (params) => { + return this.dataloader.request({ ...params, tracingConfig: this.client._tracingConfig }) }, - (params) => this.dataloader.request({ ...params, tracingConfig: this.client._tracingConfig }), ) data = result.data elapsed = result.elapsed } else { const result = await this.dataloader.request({ document, - runInTransaction, headers, - transactionId, + transaction, otelParentCtx, otelChildCtx, tracingConfig: this.client._tracingConfig, diff --git a/packages/client/src/runtime/core/model/applyModel.ts b/packages/client/src/runtime/core/model/applyModel.ts index a355fafaf7fc..04bb6cd4729c 100644 --- a/packages/client/src/runtime/core/model/applyModel.ts +++ b/packages/client/src/runtime/core/model/applyModel.ts @@ -61,13 +61,27 @@ export function applyModel(client: Client, dmmfModelName: string) { const action = (paramOverrides: O.Optional) => (userArgs?: UserArgs) => { const callSite = getCallSite(client._errorFormat) // used for showing better errors - return createPrismaPromise((txId, lock) => { - const data = { args: userArgs, dataPath: [] } // data and its dataPath for nested results - const action = { action: dmmfActionName, model: dmmfModelName } // action name and its related model - const method = { clientMethod: `${jsModelName}.${prop}`, jsModelName } // method name for display only - const tx = { runInTransaction: !!txId, transactionId: txId, lock } // transaction information - const trace = { callsite: callSite } // stack trace - const params = { ...data, ...action, ...method, ...tx, ...trace } + return createPrismaPromise((transaction, lock) => { + const params: InternalRequestParams = { + // data and its dataPath for nested results + args: userArgs, + dataPath: [], + + // action name and its related model + action: dmmfActionName, + model: dmmfModelName, + + // method name for display only + clientMethod: `${jsModelName}.${prop}`, + jsModelName, + + // transaction information + transaction, + lock, + + // stack trace + callsite: callSite, + } return requestFn({ ...params, ...paramOverrides }) }) diff --git a/packages/client/src/runtime/core/request/PrismaPromise.ts b/packages/client/src/runtime/core/request/PrismaPromise.ts index 0334384955f8..fa62d2b53775 100644 --- a/packages/client/src/runtime/core/request/PrismaPromise.ts +++ b/packages/client/src/runtime/core/request/PrismaPromise.ts @@ -1,3 +1,21 @@ +import { IsolationLevel } from '@prisma/engine-core' + +export type PrismaPromiseBatchTransaction = { + kind: 'batch' + id: number + isolationLevel?: IsolationLevel +} + +export type PrismaPromiseInteractiveTransaction = { + kind: 'itx' + id: string +} + +export type PrismaPromiseTransaction = PrismaPromiseBatchTransaction | PrismaPromiseInteractiveTransaction + +export type BatchTransactionOptions = Omit +export type InteractiveTransactionOptions = Omit + /** * Prisma's `Promise` that is backwards-compatible. All additions on top of the * original `Promise` are optional so that it can be backwards-compatible. @@ -8,31 +26,34 @@ export interface PrismaPromise extends Promise { * Extension of the original `.then` function * @param onfulfilled same as regular promises * @param onrejected same as regular promises - * @param txId GUID for interactive txs + * @param transaction interactive transaction options */ then( onfulfilled?: (value: A) => R1 | PromiseLike, onrejected?: (error: unknown) => R2 | PromiseLike, - txId?: string, + transaction?: InteractiveTransactionOptions, ): Promise /** * Extension of the original `.catch` function * @param onrejected same as regular promises - * @param txId GUID for interactive txs + * @param transaction interactive transaction options */ - catch(onrejected?: ((reason: any) => R | PromiseLike) | undefined | null, txId?: string): Promise + catch( + onrejected?: ((reason: any) => R | PromiseLike) | undefined | null, + transaction?: InteractiveTransactionOptions, + ): Promise /** * Extension of the original `.finally` function * @param onfinally same as regular promises - * @param txId GUID for interactive txs + * @param transaction interactive transaction options */ - finally(onfinally?: (() => void) | undefined | null, txId?: string): Promise + finally(onfinally?: (() => void) | undefined | null, transaction?: InteractiveTransactionOptions): Promise /** * Called when executing a batch of regular tx - * @param txId for regular tx ids + * @param transaction transaction options for regular tx */ - requestTransaction?(txId: number, lock?: PromiseLike): PromiseLike + requestTransaction?(transaction: BatchTransactionOptions, lock?: PromiseLike): PromiseLike } diff --git a/packages/client/src/runtime/core/request/createPrismaPromise.ts b/packages/client/src/runtime/core/request/createPrismaPromise.ts index de2401aeb5d1..2f2746b628ea 100644 --- a/packages/client/src/runtime/core/request/createPrismaPromise.ts +++ b/packages/client/src/runtime/core/request/createPrismaPromise.ts @@ -1,4 +1,4 @@ -import type { PrismaPromise } from './PrismaPromise' +import type { InteractiveTransactionOptions, PrismaPromise, PrismaPromiseTransaction } from './PrismaPromise' /** * Creates a [[PrismaPromise]]. It is Prisma's implementation of `Promise` which @@ -10,18 +10,18 @@ import type { PrismaPromise } from './PrismaPromise' * @returns */ export function createPrismaPromise( - callback: (txId?: string | number, lock?: PromiseLike) => PrismaPromise, + callback: (transaction?: PrismaPromiseTransaction, lock?: PromiseLike) => PrismaPromise, ): PrismaPromise { let promise: PrismaPromise | undefined - const _callback = (txId?: string | number, lock?: PromiseLike, cached = true) => { + const _callback = (transaction?: PrismaPromiseTransaction, lock?: PromiseLike, cached = true) => { try { // promises cannot be triggered twice after resolving if (cached === true) { - return (promise ??= callback(txId, lock)) + return (promise ??= callback(transaction, lock)) } // but for for batch tx we need to trigger them again - return callback(txId, lock) + return callback(transaction, lock) } catch (error) { // if the callback throws, then we reject the promise // and that is because exceptions are not always async @@ -30,21 +30,23 @@ export function createPrismaPromise( } return { - then(onFulfilled, onRejected, txId?: string) { - return _callback(txId, undefined).then(onFulfilled, onRejected, txId) + then(onFulfilled, onRejected, transaction?) { + return _callback(createItx(transaction), undefined).then(onFulfilled, onRejected, transaction) }, - catch(onRejected, txId?: string) { - return _callback(txId, undefined).catch(onRejected, txId) + catch(onRejected, transaction?) { + return _callback(createItx(transaction), undefined).catch(onRejected, transaction) }, - finally(onFinally, txId?: string) { - return _callback(txId, undefined).finally(onFinally, txId) + finally(onFinally, transaction?) { + return _callback(createItx(transaction), undefined).finally(onFinally, transaction) }, - requestTransaction(txId: number, lock?: PromiseLike) { - const promise = _callback(txId, lock, false) + + requestTransaction(transactionOptions, lock?: PromiseLike) { + const transaction = { kind: 'batch' as const, ...transactionOptions } + const promise = _callback(transaction, lock, false) if (promise.requestTransaction) { // we want to have support for nested promises - return promise.requestTransaction(txId, lock) + return promise.requestTransaction(transaction, lock) } return promise @@ -52,3 +54,10 @@ export function createPrismaPromise( [Symbol.toStringTag]: 'PrismaPromise', } } + +function createItx(transaction: InteractiveTransactionOptions | undefined): PrismaPromiseTransaction | undefined { + if (transaction) { + return { kind: 'itx', ...transaction } + } + return undefined +} diff --git a/packages/client/src/runtime/getPrismaClient.ts b/packages/client/src/runtime/getPrismaClient.ts index 423f970c8a9f..86c91251d46b 100644 --- a/packages/client/src/runtime/getPrismaClient.ts +++ b/packages/client/src/runtime/getPrismaClient.ts @@ -1,6 +1,7 @@ import { Context, context } from '@opentelemetry/api' import Debug from '@prisma/debug' import { + BatchTransactionOptions, BinaryEngine, DataProxyEngine, DatasourceOverwrite, @@ -29,7 +30,11 @@ import { PrismaClientValidationError } from '.' import { MetricsClient } from './core/metrics/MetricsClient' import { applyModels } from './core/model/applyModels' import { createPrismaPromise } from './core/request/createPrismaPromise' -import type { PrismaPromise } from './core/request/PrismaPromise' +import type { + InteractiveTransactionOptions, + PrismaPromise, + PrismaPromiseTransaction, +} from './core/request/PrismaPromise' import { getLockCountPromise } from './core/transaction/utils/createLockCountPromise' import { BaseDMMFHelper, DMMFHelper } from './dmmf' import type { DMMF } from './dmmf-types' @@ -171,11 +176,11 @@ export type InternalRequestParams = { callsite?: CallSite /** Headers metadata that will be passed to the Engine */ headers?: Record // TODO what is this - transactionId?: string | number + transaction?: PrismaPromiseTransaction unpacker?: Unpacker // TODO what is this lock?: PromiseLike otelParentCtx?: Context -} & QueryMiddlewareParams +} & Omit // only used by the .use() hooks export type AllHookArgs = { @@ -607,7 +612,7 @@ export function getPrismaClient(config: GetPrismaClientConfig) { * Executes a raw query and always returns a number */ private $executeRawInternal( - txId: string | number | undefined, + transaction: PrismaPromiseTransaction | undefined, lock: PromiseLike | undefined, query: string | TemplateStringsArray | Sql, ...values: RawValue[] @@ -702,8 +707,7 @@ export function getPrismaClient(config: GetPrismaClientConfig) { dataPath: [], action: 'executeRaw', callsite: getCallSite(this._errorFormat), - runInTransaction: !!txId, - transactionId: txId, + transaction, lock, }) } @@ -717,9 +721,9 @@ export function getPrismaClient(config: GetPrismaClientConfig) { * @returns */ $executeRaw(query: TemplateStringsArray | Sql, ...values: any[]) { - return createPrismaPromise((txId, lock) => { + return createPrismaPromise((transaction, lock) => { if ((query as TemplateStringsArray).raw !== undefined || (query as Sql).sql !== undefined) { - return this.$executeRawInternal(txId, lock, query, ...values) + return this.$executeRawInternal(transaction, lock, query, ...values) } throw new PrismaClientValidationError(`\`$executeRaw\` is a tag function, please use it like the following: @@ -741,8 +745,8 @@ Or read our docs at https://www.prisma.io/docs/concepts/components/prisma-client * @returns */ $executeRawUnsafe(query: string, ...values: RawValue[]) { - return createPrismaPromise((txId, lock) => { - return this.$executeRawInternal(txId, lock, query, ...values) + return createPrismaPromise((transaction, lock) => { + return this.$executeRawInternal(transaction, lock, query, ...values) }) } @@ -759,15 +763,14 @@ Or read our docs at https://www.prisma.io/docs/concepts/components/prisma-client ) } - return createPrismaPromise((txId, lock) => { + return createPrismaPromise((transaction, lock) => { return this._request({ args: { command: command }, clientMethod: '$runCommandRaw', dataPath: [], action: 'runCommandRaw', callsite: getCallSite(this._errorFormat), - runInTransaction: !!txId, - transactionId: txId, + transaction: transaction, lock, }) }) @@ -777,7 +780,7 @@ Or read our docs at https://www.prisma.io/docs/concepts/components/prisma-client * Executes a raw query and returns selected data */ private $queryRawInternal( - txId: string | number | undefined, + transaction: PrismaPromiseTransaction | undefined, lock: PromiseLike | undefined, query: string | TemplateStringsArray | Sql, ...values: RawValue[] @@ -875,8 +878,7 @@ Or read our docs at https://www.prisma.io/docs/concepts/components/prisma-client dataPath: [], action: 'queryRaw', callsite: getCallSite(this._errorFormat), - runInTransaction: !!txId, - transactionId: txId, + transaction, lock, }).then(deserializeRawResults) } @@ -943,7 +945,6 @@ new PrismaClient({ }, clientMethod: 'queryRaw', dataPath: [], - runInTransaction: false, headers, callsite: getCallSite(this._errorFormat), }) @@ -954,7 +955,13 @@ new PrismaClient({ * @param requests * @param options */ - private _transactionWithArray(promises: Array>): Promise { + private _transactionWithArray({ + promises, + options, + }: { + promises: Array> + options?: BatchTransactionOptions + }): Promise { const txId = this._transactionId++ const lock = getLockCountPromise(promises.length) @@ -965,7 +972,7 @@ new PrismaClient({ ) } - return request.requestTransaction?.(txId, lock) + return request.requestTransaction?.({ id: txId, isolationLevel: options?.isolationLevel }, lock) }) return Promise.all(requests) @@ -990,7 +997,7 @@ new PrismaClient({ let result: unknown try { // execute user logic with a proxied the client - result = await callback(transactionProxy(this, info.id)) + result = await callback(transactionProxy(this, { id: info.id })) // it went well, then we commit the transaction await this._engine.transaction('commit', headers, info) @@ -1016,7 +1023,7 @@ new PrismaClient({ if (typeof input === 'function' && this._hasPreviewFlag('interactiveTransactions')) { callback = () => this._transactionWithCallback({ callback: input, options }) } else { - callback = () => this._transactionWithArray(input) + callback = () => this._transactionWithArray({ promises: input, options }) } const spanOptions = { @@ -1042,7 +1049,7 @@ new PrismaClient({ const params: QueryMiddlewareParams = { args: internalParams.args, dataPath: internalParams.dataPath, - runInTransaction: internalParams.runInTransaction, + runInTransaction: Boolean(internalParams.transaction), action: internalParams.action, model: internalParams.model, } @@ -1068,7 +1075,7 @@ new PrismaClient({ let index = -1 // prepare recursive fn that will pipe params through middlewares - const consumer = (changedParams: QueryMiddlewareParams) => { + const consumer = (changedMiddlewareParams: QueryMiddlewareParams) => { // if this `next` was called and there's some more middlewares const nextMiddleware = this._middlewares.query.get(++index) @@ -1077,13 +1084,25 @@ new PrismaClient({ // calling `next` calls the consumer again with the new params return runInChildSpan(spanOptions.middleware, async (span) => { // we call `span.end()` _before_ calling the next middleware - return nextMiddleware(changedParams, (p) => (span?.end(), consumer(p))) + return nextMiddleware(changedMiddlewareParams, (p) => (span?.end(), consumer(p))) }) } // no middleware? then we just proceed with request execution // before we send the execution request, we use the changed params - return this._executeRequest({ ...internalParams, ...changedParams }) + const { runInTransaction, ...changedRequestParams } = changedMiddlewareParams + const requestParams = { + ...internalParams, + ...changedRequestParams, + } + + // if middleware switched off `runInTransaction`, unset + // `transaction` property on request as well so it will be executed outside + // of transaction + if (!runInTransaction) { + requestParams.transaction = undefined + } + return this._executeRequest(requestParams) } return await runInChildSpan(spanOptions.operation, () => { @@ -1107,11 +1126,10 @@ new PrismaClient({ jsModelName, dataPath, callsite, - runInTransaction, action, model, headers, - transactionId, + transaction, lock, unpacker, otelParentCtx, @@ -1205,9 +1223,8 @@ new PrismaClient({ callsite, args, engineHook: this._middlewares.engine.get(0), - runInTransaction, headers, - transactionId, + transaction, unpacker, otelParentCtx, otelChildCtx: context.active(), @@ -1250,10 +1267,10 @@ const forbidden = ['$connect', '$disconnect', '$on', '$transaction', '$use'] /** * Proxy that takes over the client promises to pass `txId` * @param thing to be proxied - * @param txId to be passed down to {@link RequestHandler} + * @param transaction to be passed down to {@link RequestHandler} * @returns */ -function transactionProxy(thing: T, txId: string): T { +function transactionProxy(thing: T, transaction: InteractiveTransactionOptions): T { // we only wrap within a proxy if it's possible: if it's an object if (typeof thing !== 'object') return thing @@ -1262,23 +1279,23 @@ function transactionProxy(thing: T, txId: string): T { // we don't want to allow any calls to our `forbidden` methods if (forbidden.includes(prop as string)) return undefined - if (prop === TX_ID) return txId // secret accessor to the txId + if (prop === TX_ID) return transaction?.id // secret accessor to the txId // we override and handle every function call within the proxy if (typeof target[prop] === 'function') { return (...args: unknown[]) => { // we hijack promise calls to pass txId to prisma promises - if (prop === 'then') return target[prop](args[0], args[1], txId) - if (prop === 'catch') return target[prop](args[0], txId) - if (prop === 'finally') return target[prop](args[0], txId) + if (prop === 'then') return target[prop](args[0], args[1], transaction) + if (prop === 'catch') return target[prop](args[0], transaction) + if (prop === 'finally') return target[prop](args[0], transaction) // if it's not the end promise, result is also tx-proxied - return transactionProxy(target[prop](...args), txId) + return transactionProxy(target[prop](...args), transaction) } } // if it's an object prop, then we keep on making it proxied - return transactionProxy(target[prop], txId) + return transactionProxy(target[prop], transaction) }, }) as any as T } diff --git a/packages/client/tests/functional/batch-transaction-isolation-level/_matrix.ts b/packages/client/tests/functional/batch-transaction-isolation-level/_matrix.ts new file mode 100644 index 000000000000..09ff5e9d6352 --- /dev/null +++ b/packages/client/tests/functional/batch-transaction-isolation-level/_matrix.ts @@ -0,0 +1,15 @@ +import { defineMatrix } from '../_utils/defineMatrix' + +export default defineMatrix(() => [ + [ + { + provider: 'postgresql', + }, + { + provider: 'mysql', + }, + { + provider: 'sqlserver', + }, + ], +]) diff --git a/packages/client/tests/functional/batch-transaction-isolation-level/prisma/_schema.ts b/packages/client/tests/functional/batch-transaction-isolation-level/prisma/_schema.ts new file mode 100644 index 000000000000..8ec523bbc6b6 --- /dev/null +++ b/packages/client/tests/functional/batch-transaction-isolation-level/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}" + url = env("DATABASE_URI_${provider}") + } + + model User { + id ${idForProvider(provider)} + email String + } + ` +}) diff --git a/packages/client/tests/functional/batch-transaction-isolation-level/tests.ts b/packages/client/tests/functional/batch-transaction-isolation-level/tests.ts new file mode 100644 index 000000000000..bfcf7d77d47d --- /dev/null +++ b/packages/client/tests/functional/batch-transaction-isolation-level/tests.ts @@ -0,0 +1,102 @@ +import { NewPrismaClient } from '../_utils/types' +import testMatrix from './_matrix' +// @ts-ignore +import type { Prisma as PrismaNamespace, PrismaClient } from './node_modules/@prisma/client' + +declare let newPrismaClient: NewPrismaClient +declare let Prisma: typeof PrismaNamespace + +testMatrix.setupTestSuite( + () => { + const queries: string[] = [] + let prisma: PrismaClient + + beforeAll(() => { + prisma = newPrismaClient({ + log: [ + { + emit: 'event', + level: 'query', + }, + ], + }) + + prisma.$on('query', (event) => { + queries.push(event.query) + }) + }) + + afterEach(() => { + queries.length = 0 + }) + + const testIsolationLevel = ( + name: string, + { level, expectSql }: { level: () => PrismaNamespace.TransactionIsolationLevel; expectSql: string }, + ) => { + test(name, async () => { + await prisma.$transaction([prisma.user.findFirst({}), prisma.user.findFirst({})], { + isolationLevel: level(), + }) + + // eslint-disable-next-line jest/no-standalone-expect + expect(queries).toContain(expectSql) + }) + } + + testIsolationLevel('ReadUncommitted', { + level: () => Prisma.TransactionIsolationLevel.ReadUncommitted, + expectSql: 'SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED', + }) + + testIsolationLevel('ReadCommitted', { + level: () => Prisma.TransactionIsolationLevel.ReadCommitted, + expectSql: 'SET TRANSACTION ISOLATION LEVEL READ COMMITTED', + }) + + testIsolationLevel('RepeatableRead', { + level: () => Prisma.TransactionIsolationLevel.RepeatableRead, + expectSql: 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', + }) + + testIsolationLevel('Serializable', { + level: () => Prisma.TransactionIsolationLevel.Serializable, + expectSql: 'SET TRANSACTION ISOLATION LEVEL SERIALIZABLE', + }) + + test('default value generates no SET TRANSACTION ISOLATION LEVEL statements', async () => { + await prisma.$transaction([prisma.user.findFirst({}), prisma.user.findFirst({})]) + + expect(queries.find((q) => q.includes('SET TRANSACTION ISOLATION LEVEL'))).toBeUndefined() + }) + + test('invalid level generates run- and compile- time error', async () => { + const result = prisma.$transaction([prisma.user.findFirst({}), prisma.user.findFirst({})], { + // @ts-expect-error + isolationLevel: 'yes', + }) + + await expect(result).rejects.toMatchPrismaErrorInlineSnapshot(` + + Invalid \`prisma.$transaction([prisma.user.findFirst()\` invocation in + /client/tests/functional/batch-transaction-isolalation-level/tests.ts:0:0 + + XX }) + XX + XX test('invalid level generates run- and compile- time error', async () => { + → XX const result = prisma.$transaction([prisma.user.findFirst( + Inconsistent column data: Conversion failed: Invalid isolation level \`yes\` + `) + }) + }, + { + optOut: { + from: ['mongodb', 'sqlite', 'cockroachdb'], + reason: ` + mongo - Not supported + sqlite, cockroach - Support only serializable level, never generate sql for setting isolation level + `, + }, + skipDefaultClientInstance: true, + }, +) diff --git a/packages/client/tests/functional/interactive-transactions/tests.ts b/packages/client/tests/functional/interactive-transactions/tests.ts index af8135bde827..fcd223dbc45a 100644 --- a/packages/client/tests/functional/interactive-transactions/tests.ts +++ b/packages/client/tests/functional/interactive-transactions/tests.ts @@ -394,6 +394,33 @@ testMatrix.setupTestSuite( expect(users.length).toBe(2) }) + + test('middleware exclude from transaction', async () => { + const isolatedPrisma = newPrismaClient() + + isolatedPrisma.$use((params, next) => { + return next({ ...params, runInTransaction: false }) + }) + + await isolatedPrisma + .$transaction(async (prisma) => { + await prisma.user.create({ + data: { + email: 'user_1@website.com', + }, + }) + + await prisma.user.create({ + data: { + email: 'user_1@website.com', + }, + }) + }) + .catch((e) => {}) + + const users = await isolatedPrisma.user.findMany() + expect(users).toHaveLength(1) + }) }) /** @@ -696,12 +723,13 @@ testMatrix.setupTestSuite( // }) test('invalid value', async () => { + // @ts-test-if: provider === 'mongodb' const result = prisma.$transaction( async (tx) => { await tx.user.create({ data: { email: 'user@example.com' } }) }, { - // @ts-expect-error + // @ts-test-if: provider !== 'mongodb' isolationLevel: 'NotAValidLevel', }, ) @@ -719,12 +747,13 @@ testMatrix.setupTestSuite( }) testIf(provider === 'mongodb')('attempt to set isolation level on mongo', async () => { + // @ts-test-if: provider === 'mongodb' const result = prisma.$transaction( async (tx) => { await tx.user.create({ data: { email: 'user@example.com' } }) }, { - // @ts-expect-error + // @ts-test-if: provider !== 'mongodb' isolationLevel: 'CanBeAnything', }, ) diff --git a/packages/engine-core/src/binary/BinaryEngine.ts b/packages/engine-core/src/binary/BinaryEngine.ts index 845155d2b8ca..bf28a3c60f2f 100644 --- a/packages/engine-core/src/binary/BinaryEngine.ts +++ b/packages/engine-core/src/binary/BinaryEngine.ts @@ -17,7 +17,13 @@ import type { Readable } from 'stream' import { URL } from 'url' import { promisify } from 'util' -import type { DatasourceOverwrite, EngineConfig, EngineEventType, GetConfigResult } from '../common/Engine' +import type { + BatchTransactionOptions, + DatasourceOverwrite, + EngineConfig, + EngineEventType, + GetConfigResult, +} from '../common/Engine' import { Engine } from '../common/Engine' import { PrismaClientInitializationError } from '../common/errors/PrismaClientInitializationError' import { PrismaClientKnownRequestError } from '../common/errors/PrismaClientKnownRequestError' @@ -29,7 +35,12 @@ import type { RustError, RustLog } from '../common/errors/utils/log' import { convertLog, getMessage, isRustError, isRustErrorLog } from '../common/errors/utils/log' import { prismaGraphQLToJSError } from '../common/errors/utils/prismaGraphQLToJSError' import { EngineMetricsOptions, Metrics, MetricsOptionsJson, MetricsOptionsPrometheus } from '../common/types/Metrics' -import type { EngineSpanEvent, QueryEngineRequestHeaders, QueryEngineResult } from '../common/types/QueryEngine' +import type { + EngineSpanEvent, + QueryEngineBatchRequest, + QueryEngineRequestHeaders, + QueryEngineResult, +} from '../common/types/QueryEngine' import type * as Tx from '../common/types/Transaction' import { printGeneratorConfig } from '../common/utils/printGeneratorConfig' import { fixBinaryTargets, plusX } from '../common/utils/util' @@ -964,14 +975,15 @@ You very likely have the wrong "binaryTarget" defined in the schema.prisma file. async requestBatch( queries: string[], headers: QueryEngineRequestHeaders = {}, - transaction = false, + transaction?: BatchTransactionOptions, numTry = 1, ): Promise[]> { await this.start() - const request = { + const request: QueryEngineBatchRequest = { batch: queries.map((query) => ({ query, variables: {} })), - transaction, + transaction: Boolean(transaction), + isolationLevel: transaction?.isolationLevel, } this.lastQuery = JSON.stringify(request) diff --git a/packages/engine-core/src/common/Engine.ts b/packages/engine-core/src/common/Engine.ts index 23d4158721cd..c6dca9462ff1 100644 --- a/packages/engine-core/src/common/Engine.ts +++ b/packages/engine-core/src/common/Engine.ts @@ -18,6 +18,10 @@ export type InlineDatasource = { url: NullableEnvValue } +export type BatchTransactionOptions = { + isolationLevel?: Transaction.IsolationLevel +} + // TODO Move shared logic in here export abstract class Engine { abstract on(event: EngineEventType, listener: (args?: any) => any): void @@ -34,7 +38,7 @@ export abstract class Engine { abstract requestBatch( queries: string[], headers?: QueryEngineRequestHeaders, - transaction?: boolean, + transaction?: BatchTransactionOptions, numTry?: number, ): Promise[]> abstract transaction( diff --git a/packages/engine-core/src/common/types/QueryEngine.ts b/packages/engine-core/src/common/types/QueryEngine.ts index 49787f0b6bdf..6b6cb0dbca59 100644 --- a/packages/engine-core/src/common/types/QueryEngine.ts +++ b/packages/engine-core/src/common/types/QueryEngine.ts @@ -1,5 +1,7 @@ import type { DataSource, GeneratorConfig } from '@prisma/generator-helper' +import * as Transaction from './Transaction' + // Events export type QueryEngineEvent = QueryEngineLogEvent | QueryEngineQueryEvent | QueryEnginePanicEvent | EngineSpanEvent @@ -86,6 +88,7 @@ export type QueryEngineRequestHeaders = { export type QueryEngineBatchRequest = { batch: QueryEngineRequest[] transaction: boolean + isolationLevel?: Transaction.IsolationLevel } export type GetConfigOptions = { diff --git a/packages/engine-core/src/data-proxy/DataProxyEngine.ts b/packages/engine-core/src/data-proxy/DataProxyEngine.ts index fa8db7130ffd..57e148e7f5df 100644 --- a/packages/engine-core/src/data-proxy/DataProxyEngine.ts +++ b/packages/engine-core/src/data-proxy/DataProxyEngine.ts @@ -2,10 +2,17 @@ import Debug from '@prisma/debug' import { DMMF } from '@prisma/generator-helper' import EventEmitter from 'events' -import type { EngineConfig, EngineEventType, GetConfigResult, InlineDatasource } from '../common/Engine' +import type { + BatchTransactionOptions, + EngineConfig, + EngineEventType, + GetConfigResult, + InlineDatasource, +} from '../common/Engine' import { Engine } from '../common/Engine' import { prismaGraphQLToJSError } from '../common/errors/utils/prismaGraphQLToJSError' import { EngineMetricsOptions, Metrics, MetricsOptionsJson, MetricsOptionsPrometheus } from '../common/types/Metrics' +import { QueryEngineBatchRequest } from '../common/types/QueryEngine' import { DataProxyError } from './errors/DataProxyError' import { ForcedRetryError } from './errors/ForcedRetryError' import { InvalidDatasourceError } from './errors/InvalidDatasourceError' @@ -129,14 +136,21 @@ export class DataProxyEngine extends Engine { return this.requestInternal({ query, variables: {} }, headers, attempt) } - async requestBatch(queries: string[], headers: Record, isTransaction = false, attempt = 0) { + async requestBatch( + queries: string[], + headers: Record, + transaction?: BatchTransactionOptions, + attempt = 0, + ) { + const isTransaction = Boolean(transaction) this.logEmitter.emit('query', { query: `Batch${isTransaction ? ' in transaction' : ''} (${queries.length}):\n${queries.join('\n')}`, }) - const body = { + const body: QueryEngineBatchRequest = { batch: queries.map((query) => ({ query, variables: {} })), transaction: isTransaction, + isolationLevel: transaction?.isolationLevel, } const { batchResult } = await this.requestInternal(body, headers, attempt) diff --git a/packages/engine-core/src/index.ts b/packages/engine-core/src/index.ts index 5c3a20a12b9e..2159e33929ae 100644 --- a/packages/engine-core/src/index.ts +++ b/packages/engine-core/src/index.ts @@ -2,6 +2,7 @@ export { BinaryEngine } from './binary/BinaryEngine' export type { EngineConfig } from './common/Engine' export type { EngineEventType } from './common/Engine' export type { DatasourceOverwrite } from './common/Engine' +export type { BatchTransactionOptions } from './common/Engine' export { Engine } from './common/Engine' export { PrismaClientInitializationError } from './common/errors/PrismaClientInitializationError' export { PrismaClientKnownRequestError } from './common/errors/PrismaClientKnownRequestError' diff --git a/packages/engine-core/src/library/LibraryEngine.ts b/packages/engine-core/src/library/LibraryEngine.ts index a0ba9c0876e9..0c82ede916fa 100644 --- a/packages/engine-core/src/library/LibraryEngine.ts +++ b/packages/engine-core/src/library/LibraryEngine.ts @@ -6,7 +6,7 @@ import chalk from 'chalk' import EventEmitter from 'events' import fs from 'fs' -import type { DatasourceOverwrite, EngineConfig, EngineEventType } from '../common/Engine' +import type { BatchTransactionOptions, DatasourceOverwrite, EngineConfig, EngineEventType } from '../common/Engine' import { Engine } from '../common/Engine' import { PrismaClientInitializationError } from '../common/errors/PrismaClientInitializationError' import { PrismaClientKnownRequestError } from '../common/errors/PrismaClientKnownRequestError' @@ -489,13 +489,13 @@ You may have to run ${chalk.greenBright('prisma generate')} for your changes to async requestBatch( queries: string[], headers: QueryEngineRequestHeaders = {}, - transaction = false, - numTry = 1, + transaction?: BatchTransactionOptions, ): Promise[]> { debug('requestBatch') const request: QueryEngineBatchRequest = { batch: queries.map((query) => ({ query, variables: {} })), - transaction, + transaction: Boolean(transaction), + isolationLevel: transaction?.isolationLevel, } await this.start()