diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 15eddbc7..454b302f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -4,6 +4,7 @@ early_access: false reviews: auto_review: enabled: true + base_branches: ['dev', 'main'] sequence_diagrams: false chat: auto_reply: true diff --git a/TODO.md b/TODO.md index 668aceca..35edf349 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,5 @@ ## V3 Alpha Todo -- [ ] Infra - - [ ] Dependency injection - [ ] CLI - [x] generate - [x] migrate @@ -11,7 +9,7 @@ - [ ] format - [x] plugin mechanism - [x] built-in plugins - - [x] ts + - [x] typescript - [x] prisma - [ ] ZModel - [x] Import @@ -34,10 +32,12 @@ - [x] Pagination - [x] Skip and limit - [x] Cursor - - [x] Filtering + - [ ] Filtering - [x] Unique fields - [x] Scalar fields - [x] Relation fields + - [ ] JSON filtering + - [ ] Full-text search - [x] Sort - [x] Scalar fields - [x] Relation fields @@ -46,7 +46,6 @@ - [x] Sorting - [x] Pagination - [x] Distinct - - [ ] JSON filtering - [x] Update - [x] Input validation - [x] Top-level @@ -66,10 +65,10 @@ - [x] Transactions - [x] Interactive transaction - [x] Sequential transaction - - [ ] Extensions + - [ ] Extensibility - [x] Query builder API - [x] Computed fields - - [x] Prisma client extension + - [x] Plugin - [ ] Custom procedures - [ ] Misc - [x] JSDoc for CRUD methods diff --git a/packages/runtime/src/client/client-impl.ts b/packages/runtime/src/client/client-impl.ts index e86e91c8..c762700f 100644 --- a/packages/runtime/src/client/client-impl.ts +++ b/packages/runtime/src/client/client-impl.ts @@ -7,6 +7,7 @@ import { Kysely, Log, sql, + Transaction, type KyselyProps, } from 'kysely'; import type { GetModels, ProcedureDef, SchemaDef } from '../schema'; @@ -155,6 +156,12 @@ export class ClientImpl { } } + forceTransaction() { + if (!this.kysely.isTransaction) { + this.kysely = new Transaction(this.kyselyProps); + } + } + private async interactiveTransaction( callback: (tx: ClientContract) => Promise, options?: { isolationLevel?: TransactionIsolationLevel }, diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 04a27a00..c3bab79d 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -22,7 +22,7 @@ import { clone } from '../../../utils/clone'; import { enumerate } from '../../../utils/enumerate'; import { extractFields, fieldsToSelectObject } from '../../../utils/object-utils'; import { NUMERIC_FIELD_TYPES } from '../../constants'; -import type { CRUD } from '../../contract'; +import { TransactionIsolationLevel, type CRUD } from '../../contract'; import type { FindArgs, SelectIncludeOmit, WhereInput } from '../../crud-types'; import { InternalError, NotFoundError, QueryError } from '../../errors'; import type { ToKysely } from '../../query-builder'; @@ -2089,7 +2089,7 @@ export abstract class BaseOperationHandler { } else { // otherwise, create a new transaction and execute the callback let txBuilder = this.kysely.transaction(); - txBuilder = txBuilder.setIsolationLevel(isolationLevel ?? 'repeatable read'); + txBuilder = txBuilder.setIsolationLevel(isolationLevel ?? TransactionIsolationLevel.RepeatableRead); return txBuilder.execute(callback); } } diff --git a/packages/runtime/src/client/executor/kysely-utils.ts b/packages/runtime/src/client/executor/kysely-utils.ts new file mode 100644 index 00000000..5ae92d39 --- /dev/null +++ b/packages/runtime/src/client/executor/kysely-utils.ts @@ -0,0 +1,14 @@ +import { invariant } from '@zenstackhq/common-helpers'; +import { type OperationNode, AliasNode, IdentifierNode } from 'kysely'; + +/** + * Strips alias from the node if it exists. + */ +export function stripAlias(node: OperationNode) { + if (AliasNode.is(node)) { + invariant(IdentifierNode.is(node.alias), 'Expected identifier as alias'); + return { alias: node.alias.name, node: node.node }; + } else { + return { alias: undefined, node }; + } +} diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index 2124150c..b2b96fda 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -19,6 +19,7 @@ import { } from 'kysely'; import type { FieldDef, ModelDef, SchemaDef } from '../../schema'; import { getModel, requireModel } from '../query-utils'; +import { stripAlias } from './kysely-utils'; type Scope = { model: string; @@ -94,7 +95,7 @@ export class QueryNameMapper extends OperationNodeTransformer { } protected override transformJoin(node: JoinNode) { - const { alias, node: innerNode } = this.stripAlias(node.table); + const { alias, node: innerNode } = stripAlias(node.table); if (TableNode.is(innerNode!)) { const modelName = innerNode.table.identifier.name; if (this.hasMappedColumns(modelName)) { @@ -150,7 +151,11 @@ export class QueryNameMapper extends OperationNodeTransformer { } protected override transformUpdateQuery(node: UpdateQueryNode) { - const { alias, node: innerTable } = this.stripAlias(node.table); + if (!node.table) { + return super.transformUpdateQuery(node); + } + + const { alias, node: innerTable } = stripAlias(node.table); if (!innerTable || !TableNode.is(innerTable)) { return super.transformUpdateQuery(node); } @@ -170,7 +175,7 @@ export class QueryNameMapper extends OperationNodeTransformer { // process name mapping in each "from" const froms = node.from.froms.map((from) => { - const { alias, node: innerNode } = this.stripAlias(from); + const { alias, node: innerNode } = stripAlias(from); if (TableNode.is(innerNode!)) { // map table name return this.wrapAlias(this.processTableRef(innerNode), alias); @@ -289,17 +294,6 @@ export class QueryNameMapper extends OperationNodeTransformer { } } - private stripAlias(node: OperationNode | undefined) { - if (!node) { - return { alias: undefined, node }; - } - if (AliasNode.is(node)) { - invariant(IdentifierNode.is(node.alias), 'Expected identifier as alias'); - return { alias: node.alias.name, node: node.node }; - } - return { alias: undefined, node }; - } - private hasMappedColumns(modelName: string) { return [...this.fieldToColumnMap.keys()].some((key) => key.startsWith(modelName + '.')); } @@ -310,7 +304,7 @@ export class QueryNameMapper extends OperationNodeTransformer { } return node.froms .map((from) => { - const { alias, node: innerNode } = this.stripAlias(from); + const { alias, node: innerNode } = stripAlias(from); if (innerNode && TableNode.is(innerNode)) { return { model: innerNode.table.identifier.name, alias, namesMapped }; } else { @@ -325,7 +319,7 @@ export class QueryNameMapper extends OperationNodeTransformer { return { ...super.transformFrom(node), froms: node.froms.map((from) => { - const { alias, node: innerNode } = this.stripAlias(from); + const { alias, node: innerNode } = stripAlias(from); if (!innerNode) { return super.transformNode(from); } diff --git a/packages/runtime/src/client/executor/zenstack-driver.ts b/packages/runtime/src/client/executor/zenstack-driver.ts index 9a0a32c3..747acded 100644 --- a/packages/runtime/src/client/executor/zenstack-driver.ts +++ b/packages/runtime/src/client/executor/zenstack-driver.ts @@ -79,7 +79,12 @@ export class ZenStackDriver implements Driver { this.#txConnections.delete(connection); if (callbacks) { for (const callback of callbacks) { - await callback(); + try { + await callback(); + } catch (err) { + // errors in commit callbacks are logged but do not fail the commit + console.error(`Error executing transaction commit callback: ${err}`); + } } } return result; diff --git a/packages/runtime/src/client/executor/zenstack-query-executor.ts b/packages/runtime/src/client/executor/zenstack-query-executor.ts index a7b55977..7b82959e 100644 --- a/packages/runtime/src/client/executor/zenstack-query-executor.ts +++ b/packages/runtime/src/client/executor/zenstack-query-executor.ts @@ -1,3 +1,4 @@ +import { invariant } from '@zenstackhq/common-helpers'; import { AndNode, CompiledQuery, @@ -7,6 +8,8 @@ import { ReturningNode, SelectionNode, SelectQueryNode, + SingleConnectionProvider, + TableNode, UpdateQueryNode, WhereNode, type ConnectionProvider, @@ -17,20 +20,37 @@ import { type QueryCompiler, type QueryResult, type RootOperationNode, - type TableNode, } from 'kysely'; -import { nanoid } from 'nanoid'; import { match } from 'ts-pattern'; import type { GetModels, SchemaDef } from '../../schema'; import { type ClientImpl } from '../client-impl'; -import type { ClientContract } from '../contract'; +import { TransactionIsolationLevel, type ClientContract } from '../contract'; import { InternalError, QueryError } from '../errors'; -import type { AfterEntityMutationCallback, MutationInterceptionFilterResult, OnKyselyQueryCallback } from '../plugin'; +import type { + AfterEntityMutationCallback, + MutationInterceptionFilterResult, + OnKyselyQueryCallback, + RuntimePlugin, +} from '../plugin'; +import { stripAlias } from './kysely-utils'; import { QueryNameMapper } from './name-mapper'; import type { ZenStackDriver } from './zenstack-driver'; type QueryId = { queryId: string }; +type MutationInterceptionInfo = Pick< + MutationInterceptionFilterResult, + 'loadBeforeMutationEntities' | 'loadAfterMutationEntities' +> & { + action: 'create' | 'update' | 'delete'; + where: WhereNode | undefined; + beforeMutationEntities: Record[] | undefined; + mutationModel: GetModels; + perPlugin: Map, MutationInterceptionFilterResult>; +}; + +type MutationQueryNode = InsertQueryNode | UpdateQueryNode | DeleteQueryNode; + export class ZenStackQueryExecutor extends DefaultQueryExecutor { private readonly nameMapper: QueryNameMapper; @@ -41,6 +61,7 @@ export class ZenStackQueryExecutor extends DefaultQuer adapter: DialectAdapter, private readonly connectionProvider: ConnectionProvider, plugins: KyselyPlugin[] = [], + private suppressMutationHooks: boolean = false, ) { super(compiler, adapter, connectionProvider, plugins); this.nameMapper = new QueryNameMapper(client.$schema); @@ -55,58 +76,15 @@ export class ZenStackQueryExecutor extends DefaultQuer } override async executeQuery(compiledQuery: CompiledQuery, _queryId: QueryId) { - let queryNode = compiledQuery.query; - let mutationInterceptionInfo: Awaited>; - if (this.isMutationNode(queryNode) && this.hasMutationHooks) { - mutationInterceptionInfo = await this.callMutationInterceptionFilters(queryNode); - } - - const task = async () => { - // call before mutation hooks - if (this.isMutationNode(queryNode)) { - await this.callBeforeMutationHooks(queryNode, mutationInterceptionInfo); - } - - // TODO: make sure insert and update return rows - const oldQueryNode = queryNode; - if ( - (InsertQueryNode.is(queryNode) || UpdateQueryNode.is(queryNode)) && - mutationInterceptionInfo?.loadAfterMutationEntities - ) { - // need to make sure the query node has "returnAll" - // for insert and update queries - queryNode = { - ...queryNode, - returning: ReturningNode.create([SelectionNode.createSelectAll()]), - }; - } + // proceed with the query with kysely interceptors + // if the query is a raw query, we need to carry over the parameters + const queryParams = (compiledQuery as any).$raw ? compiledQuery.parameters : undefined; + const result = await this.proceedQueryWithKyselyInterceptors(compiledQuery.query, queryParams); - // proceed with the query with kysely interceptors - // if the query is a raw query, we need to carry over the parameters - const queryParams = (compiledQuery as any).$raw ? compiledQuery.parameters : undefined; - const result = await this.proceedQueryWithKyselyInterceptors(queryNode, queryParams); - - // call after mutation hooks - if (this.isMutationNode(queryNode)) { - await this.callAfterMutationHooks( - result.result, - queryNode, - mutationInterceptionInfo, - result.connection, - ); - } - - if (oldQueryNode !== queryNode) { - // TODO: trim the result to the original query node - } - - return result.result; - }; - - return task(); + return result.result; } - private proceedQueryWithKyselyInterceptors( + private async proceedQueryWithKyselyInterceptors( queryNode: RootOperationNode, parameters: readonly unknown[] | undefined, ) { @@ -123,11 +101,8 @@ export class ZenStackQueryExecutor extends DefaultQuer for (const hook of hooks) { const _proceed = proceed; proceed = async (query: RootOperationNode) => { - let connection: DatabaseConnection | undefined; const _p = async (q: RootOperationNode) => { const r = await _proceed(q); - // carry over the database connection returned by the original executor - connection = r.connection; return r.result; }; @@ -138,33 +113,171 @@ export class ZenStackQueryExecutor extends DefaultQuer query, proceed: _p, }); - return { result: hookResult, connection: connection! }; + return { result: hookResult }; }; } - return proceed(queryNode); + const result = await proceed(queryNode); + + return result; } private async proceedQuery(query: RootOperationNode, parameters: readonly unknown[] | undefined) { - // run built-in transformers - const finalQuery = this.nameMapper.transformNode(query); - let compiled = this.compileQuery(finalQuery); - if (parameters) { - compiled = { ...compiled, parameters }; - } + let compiled: CompiledQuery | undefined; try { return await this.provideConnection(async (connection) => { - const result = await connection.executeQuery(compiled); - return { result, connection }; + if (this.suppressMutationHooks || !this.isMutationNode(query) || !this.hasEntityMutationPlugins) { + // non-mutation query or hooks suppressed, just proceed + const finalQuery = this.nameMapper.transformNode(query); + compiled = this.compileQuery(finalQuery); + if (parameters) { + compiled = { ...compiled, parameters }; + } + const result = await connection.executeQuery(compiled); + return { result }; + } + + const mutationInterceptionInfo = await this.callMutationInterceptionFilters(query, connection); + + if ( + (InsertQueryNode.is(query) || UpdateQueryNode.is(query)) && + mutationInterceptionInfo.loadAfterMutationEntities + ) { + // need to make sure the query node has "returnAll" + // for insert and update queries + query = { + ...query, + returning: ReturningNode.create([SelectionNode.createSelectAll()]), + }; + } + const finalQuery = this.nameMapper.transformNode(query); + compiled = this.compileQuery(finalQuery); + if (parameters) { + compiled = { ...compiled, parameters }; + } + + // the client passed to hooks needs to be in sync with current in-transaction + // status so that it doesn't try to create a nested one + const currentlyInTx = this.driver.isTransactionConnection(connection); + + const connectionClient = this.createClientForConnection(connection, currentlyInTx); + + // call before mutation hooks + await this.callBeforeMutationHooks(finalQuery, mutationInterceptionInfo!, connectionClient); + + // if mutation interceptor demands to run afterMutation hook in the transaction but we're not already + // inside one, we need to create one on the fly + const shouldCreateTx = + mutationInterceptionInfo && + this.hasPluginRequestingAfterMutationWithinTransaction(mutationInterceptionInfo) && + !this.driver.isTransactionConnection(connection); + + if (!shouldCreateTx) { + // if no on-the-fly tx is needed, just proceed with the query as is + const result = await connection.executeQuery(compiled); + + invariant(mutationInterceptionInfo); + + if (!this.driver.isTransactionConnection(connection)) { + // not in a transaction, just call all after-mutation hooks + await this.callAfterMutationHooks( + result, + finalQuery, + mutationInterceptionInfo, + connectionClient, + 'all', + ); + } else { + // run after-mutation hooks that are requested to be run inside tx + await this.callAfterMutationHooks( + result, + finalQuery, + mutationInterceptionInfo, + connectionClient, + 'inTx', + ); + + // register other after-mutation hooks to be run after the tx is committed + this.driver.registerTransactionCommitCallback(connection, () => + this.callAfterMutationHooks( + result, + finalQuery, + mutationInterceptionInfo, + connectionClient, + 'outTx', + ), + ); + } + + return { result }; + } else { + // if an on-the-fly tx is created, create one and wrap the query execution inside + await this.driver.beginTransaction(connection, { + isolationLevel: TransactionIsolationLevel.ReadCommitted, + }); + try { + // execute the query inside the on-the-fly transaction + const result = await connection.executeQuery(compiled); + + // run after-mutation hooks that are requested to be run inside tx + await this.callAfterMutationHooks( + result, + finalQuery, + mutationInterceptionInfo, + connectionClient, + 'inTx', + ); + + // commit the transaction + await this.driver.commitTransaction(connection); + + // run other after-mutation hooks after the tx is committed + await this.callAfterMutationHooks( + result, + finalQuery, + mutationInterceptionInfo, + connectionClient, + 'outTx', + ); + + return { result }; + } catch (err) { + // rollback the transaction + await this.driver.rollbackTransaction(connection); + throw err; + } + } }); } catch (err) { - const message = `Failed to execute query: ${err}, sql: ${compiled.sql}`; + const message = `Failed to execute query: ${err}, sql: ${compiled?.sql}`; throw new QueryError(message, err); } } - private isMutationNode(queryNode: RootOperationNode) { + private createClientForConnection(connection: DatabaseConnection, inTx: boolean) { + const innerExecutor = this.withConnectionProvider(new SingleConnectionProvider(connection)); + innerExecutor.suppressMutationHooks = true; + const innerClient = this.client.withExecutor(innerExecutor); + if (inTx) { + innerClient.forceTransaction(); + } + return innerClient as ClientContract; + } + + private get hasEntityMutationPlugins() { + return (this.client.$options.plugins ?? []).some((plugin) => plugin.onEntityMutation); + } + + private hasPluginRequestingAfterMutationWithinTransaction( + mutationInterceptionInfo: MutationInterceptionInfo, + ) { + return [...mutationInterceptionInfo.perPlugin.values()].some( + (info) => info.intercept && info.runAfterMutationWithinTransaction, + ); + } + + private isMutationNode(queryNode: RootOperationNode): queryNode is MutationQueryNode { return InsertQueryNode.is(queryNode) || UpdateQueryNode.is(queryNode) || DeleteQueryNode.is(queryNode); } @@ -176,6 +289,7 @@ export class ZenStackQueryExecutor extends DefaultQuer this.adapter, this.connectionProvider, [...this.plugins, plugin], + this.suppressMutationHooks, ); } @@ -187,6 +301,7 @@ export class ZenStackQueryExecutor extends DefaultQuer this.adapter, this.connectionProvider, [...this.plugins, ...plugins], + this.suppressMutationHooks, ); } @@ -198,6 +313,7 @@ export class ZenStackQueryExecutor extends DefaultQuer this.adapter, this.connectionProvider, [plugin, ...this.plugins], + this.suppressMutationHooks, ); } @@ -209,6 +325,7 @@ export class ZenStackQueryExecutor extends DefaultQuer this.adapter, this.connectionProvider, [], + this.suppressMutationHooks, ); } @@ -219,53 +336,64 @@ export class ZenStackQueryExecutor extends DefaultQuer this.compiler, this.adapter, connectionProvider, + this.plugins as KyselyPlugin[], + this.suppressMutationHooks, ); // replace client with a new one associated with the new executor newExecutor.client = this.client.withExecutor(newExecutor); return newExecutor; } - private get hasMutationHooks() { - return this.client.$options.plugins?.some((plugin) => !!plugin.onEntityMutation); - } - private getMutationModel(queryNode: OperationNode): GetModels { return match(queryNode) - .when(InsertQueryNode.is, (node) => node.into!.table.identifier.name) - .when(UpdateQueryNode.is, (node) => (node.table as TableNode).table.identifier.name) + .when(InsertQueryNode.is, (node) => { + invariant(node.into, 'InsertQueryNode must have an into clause'); + return node.into.table.identifier.name; + }) + .when(UpdateQueryNode.is, (node) => { + invariant(node.table, 'UpdateQueryNode must have a table'); + const { node: tableNode } = stripAlias(node.table); + invariant(TableNode.is(tableNode), 'UpdateQueryNode must use a TableNode'); + return tableNode.table.identifier.name; + }) .when(DeleteQueryNode.is, (node) => { - if (node.from.froms.length !== 1) { - throw new InternalError(`Delete query must have exactly one from table`); - } - return (node.from.froms[0] as TableNode).table.identifier.name; + invariant(node.from.froms.length === 1, 'Delete query must have exactly one from table'); + const { node: tableNode } = stripAlias(node.from.froms[0]!); + invariant(TableNode.is(tableNode), 'DeleteQueryNode must use a TableNode'); + return tableNode.table.identifier.name; }) .otherwise((node) => { throw new InternalError(`Invalid query node: ${node}`); }) as GetModels; } - private async callMutationInterceptionFilters(queryNode: UpdateQueryNode | InsertQueryNode | DeleteQueryNode) { + private async callMutationInterceptionFilters( + queryNode: UpdateQueryNode | InsertQueryNode | DeleteQueryNode, + connection: DatabaseConnection, + ): Promise> { + const mutationModel = this.getMutationModel(queryNode); + const { action, where } = match(queryNode) + .when(InsertQueryNode.is, () => ({ + action: 'create' as const, + where: undefined, + })) + .when(UpdateQueryNode.is, (node) => ({ + action: 'update' as const, + where: node.where, + })) + .when(DeleteQueryNode.is, (node) => ({ + action: 'delete' as const, + where: node.where, + })) + .exhaustive(); + const plugins = this.client.$options.plugins; + const perPlugin = new Map, MutationInterceptionFilterResult>(); if (plugins) { - const mutationModel = this.getMutationModel(queryNode); - const result: MutationInterceptionFilterResult = { - intercept: false, - }; - - const { action, where } = match(queryNode) - .when(InsertQueryNode.is, () => ({ - action: 'create' as const, - where: undefined, - })) - .when(UpdateQueryNode.is, (node) => ({ - action: 'update' as const, - where: node.where, - })) - .when(DeleteQueryNode.is, (node) => ({ - action: 'delete' as const, - where: node.where, - })) - .exhaustive(); + const mergedResult: Pick< + MutationInterceptionFilterResult, + 'loadBeforeMutationEntities' | 'loadAfterMutationEntities' + > = {}; for (const plugin of plugins) { const onEntityMutation = plugin.onEntityMutation; @@ -275,47 +403,58 @@ export class ZenStackQueryExecutor extends DefaultQuer if (!onEntityMutation.mutationInterceptionFilter) { // by default intercept without loading entities - result.intercept = true; + perPlugin.set(plugin, { intercept: true }); } else { const filterResult = await onEntityMutation.mutationInterceptionFilter({ model: mutationModel, action, queryNode, }); - result.intercept ||= filterResult.intercept; - result.loadBeforeMutationEntities ||= filterResult.loadBeforeMutationEntities; - result.loadAfterMutationEntities ||= filterResult.loadAfterMutationEntities; + mergedResult.loadBeforeMutationEntities ||= filterResult.loadBeforeMutationEntities; + mergedResult.loadAfterMutationEntities ||= filterResult.loadAfterMutationEntities; + perPlugin.set(plugin, filterResult); } } let beforeMutationEntities: Record[] | undefined; - if (result.loadBeforeMutationEntities && (UpdateQueryNode.is(queryNode) || DeleteQueryNode.is(queryNode))) { - beforeMutationEntities = await this.loadEntities(mutationModel, where); + if ( + mergedResult.loadBeforeMutationEntities && + (UpdateQueryNode.is(queryNode) || DeleteQueryNode.is(queryNode)) + ) { + beforeMutationEntities = await this.loadEntities(mutationModel, where, connection); } return { - ...result, + ...mergedResult, mutationModel, action, where, beforeMutationEntities, + perPlugin, }; } else { - return undefined; + return { + mutationModel, + action, + where, + beforeMutationEntities: undefined, + perPlugin, + }; } } private async callBeforeMutationHooks( queryNode: OperationNode, - mutationInterceptionInfo: Awaited>, + mutationInterceptionInfo: MutationInterceptionInfo, + client: ClientContract, ) { - if (!mutationInterceptionInfo?.intercept) { - return; - } - if (this.options.plugins) { const mutationModel = this.getMutationModel(queryNode); for (const plugin of this.options.plugins) { + const info = mutationInterceptionInfo.perPlugin.get(plugin); + if (!info?.intercept) { + continue; + } const onEntityMutation = plugin.onEntityMutation; if (onEntityMutation?.beforeEntityMutation) { await onEntityMutation.beforeEntityMutation({ @@ -323,6 +462,7 @@ export class ZenStackQueryExecutor extends DefaultQuer action: mutationInterceptionInfo.action, queryNode, entities: mutationInterceptionInfo.beforeMutationEntities, + client, }); } } @@ -332,27 +472,38 @@ export class ZenStackQueryExecutor extends DefaultQuer private async callAfterMutationHooks( queryResult: QueryResult, queryNode: OperationNode, - mutationInterceptionInfo: Awaited>, - connection: DatabaseConnection, + mutationInterceptionInfo: MutationInterceptionInfo, + client: ClientContract, + filterFor: 'inTx' | 'outTx' | 'all', ) { - if (!mutationInterceptionInfo?.intercept) { - return; - } - const hooks: AfterEntityMutationCallback[] = []; + // tsc perf for (const plugin of this.options.plugins ?? []) { + const info = mutationInterceptionInfo.perPlugin.get(plugin); + if (!info?.intercept) { + continue; + } + + if (filterFor === 'inTx' && !info.runAfterMutationWithinTransaction) { + continue; + } + + if (filterFor === 'outTx' && info.runAfterMutationWithinTransaction) { + continue; + } + const onEntityMutation = plugin.onEntityMutation; if (onEntityMutation?.afterEntityMutation) { hooks.push(onEntityMutation.afterEntityMutation.bind(plugin)); } } + if (hooks.length === 0) { return; } const mutationModel = this.getMutationModel(queryNode); - const inTransaction = this.driver.isTransactionConnection(connection); for (const hook of hooks) { let afterMutationEntities: Record[] | undefined = undefined; @@ -362,34 +513,21 @@ export class ZenStackQueryExecutor extends DefaultQuer } } - const action = async () => { - try { - await hook({ - model: mutationModel, - action: mutationInterceptionInfo.action, - queryNode, - beforeMutationEntities: mutationInterceptionInfo.beforeMutationEntities, - afterMutationEntities, - }); - } catch (err) { - console.error(`Error in afterEntityMutation hook for model "${mutationModel}": ${err}`); - } - }; - - if (inTransaction) { - // if we're in a transaction, the after mutation hooks should be triggered after the transaction is committed, - // only register a callback here - this.driver.registerTransactionCommitCallback(connection, action); - } else { - // otherwise trigger the hooks immediately - await action(); - } + await hook({ + model: mutationModel, + action: mutationInterceptionInfo.action, + queryNode, + beforeMutationEntities: mutationInterceptionInfo.beforeMutationEntities, + afterMutationEntities, + client, + }); } } private async loadEntities( model: GetModels, where: WhereNode | undefined, + connection: DatabaseConnection, ): Promise[]> { const selectQuery = this.kysely.selectFrom(model).selectAll(); let selectQueryNode = selectQuery.toOperationNode() as SelectQueryNode; @@ -398,7 +536,9 @@ export class ZenStackQueryExecutor extends DefaultQuer where: this.andNodes(selectQueryNode.where, where), }; const compiled = this.compileQuery(selectQueryNode); - const result = await this.executeQuery(compiled, { queryId: `zenstack-${nanoid()}` }); + // execute the query directly with the given connection to avoid triggering + // any other side effects + const result = await connection.executeQuery(compiled); return result.rows as Record[]; } diff --git a/packages/runtime/src/client/plugin.ts b/packages/runtime/src/client/plugin.ts index b8d6e314..69218be5 100644 --- a/packages/runtime/src/client/plugin.ts +++ b/packages/runtime/src/client/plugin.ts @@ -149,6 +149,20 @@ export type MutationInterceptionFilterResult = { * Whether entities should be loaded after the mutation. */ loadAfterMutationEntities?: boolean; + + /** + * Whether to run after-mutation hooks within the transaction that performs the mutation. + * + * If set to `true`, if the mutation already runs inside a transaction, the callbacks are + * executed immediately after the mutation within the transaction boundary. If the mutation + * is not running inside a transaction, a new transaction is created to run both the mutation + * and the callbacks. + * + * If set to `false`, the callbacks are executed after the mutation transaction is committed. + * + * Defaults to `false`. + */ + runAfterMutationWithinTransaction?: boolean; }; export type BeforeEntityMutationCallback = ( @@ -165,6 +179,15 @@ export type PluginBeforeEntityMutationArgs = MutationH * true in the return value of {@link RuntimePlugin.mutationInterceptionFilter}. */ entities?: unknown[]; + + /** + * The ZenStack client you can use to perform additional operations. The database operations initiated + * from this client are executed within the same transaction as the mutation if the mutation is running + * inside a transaction. + * + * Mutations initiated from this client will NOT trigger entity mutation hooks to avoid infinite loops. + */ + client: ClientContract; }; export type PluginAfterEntityMutationArgs = MutationHooksArgs & { @@ -179,6 +202,14 @@ export type PluginAfterEntityMutationArgs = MutationHo * value of {@link RuntimePlugin.mutationInterceptionFilter}. */ afterMutationEntities?: unknown[]; + + /** + * The ZenStack client you can use to perform additional operations. + * See {@link MutationInterceptionFilterResult.runAfterMutationWithinTransaction} for detailed transaction behavior. + * + * Mutations initiated from this client will NOT trigger entity mutation hooks to avoid infinite loops. + */ + client: ClientContract; }; // #endregion diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index 36bbe13b..7904ee8f 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -4,24 +4,84 @@ import type { ClientContract } from '../../src'; import { schema, type SchemaType } from '../schemas/name-mapping/schema'; import { createTestClient } from '../utils'; -describe('Name mapping tests', () => { - let db: ClientContract; - - beforeEach(async () => { - db = await createTestClient( - schema, - { usePrismaPush: true }, - path.join(__dirname, '../schemas/name-mapping/schema.zmodel'), - ); - }); - - afterEach(async () => { - await db.$disconnect(); - }); - - it('works with create', async () => { - await expect( - db.user.create({ +const TEST_DB = 'client-api-name-mapper-test'; + +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'Name mapping tests', + ({ provider }) => { + let db: ClientContract; + + beforeEach(async () => { + db = await createTestClient( + schema, + { usePrismaPush: true, provider, dbName: TEST_DB }, + path.join(__dirname, '../schemas/name-mapping/schema.zmodel'), + ); + }); + + afterEach(async () => { + await db.$disconnect(); + }); + + it('works with create', async () => { + await expect( + db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + title: 'Post1', + }, + }, + }, + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + }); + + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u2@test.com', + }) + .returning(['id', 'email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u2@test.com', + }); + + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u3@test.com', + }) + .returning(['User.id', 'User.email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u3@test.com', + }); + + await expect( + db.$qb + .insertInto('User') + .values({ + email: 'u4@test.com', + }) + .returningAll() + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u4@test.com', + }); + }); + + it('works with find', async () => { + const user = await db.user.create({ data: { email: 'u1@test.com', posts: { @@ -30,205 +90,150 @@ describe('Name mapping tests', () => { }, }, }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u1@test.com', - }); - - await expect( - db.$qb - .insertInto('User') - .values({ - email: 'u2@test.com', - }) - .returning(['id', 'email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u2@test.com', - }); - - await expect( - db.$qb - .insertInto('User') - .values({ - email: 'u3@test.com', - }) - .returning(['User.id', 'User.email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u3@test.com', - }); - - await expect( - db.$qb - .insertInto('User') - .values({ - email: 'u4@test.com', - }) - .returningAll() - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u4@test.com', - }); - }); - - it('works with find', async () => { - const user = await db.user.create({ - data: { - email: 'u1@test.com', - posts: { - create: { - title: 'Post1', + }); + + await expect( + db.user.findFirst({ + where: { email: 'u1@test.com' }, + select: { + id: true, + email: true, + posts: { where: { title: { contains: 'Post1' } }, select: { title: true } }, }, - }, - }, - }); - - await expect( - db.user.findFirst({ - where: { email: 'u1@test.com' }, - select: { - id: true, - email: true, - posts: { where: { title: { contains: 'Post1' } }, select: { title: true } }, - }, - }), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u1@test.com', - posts: [{ title: 'Post1' }], - }); - - await expect( - db.$qb.selectFrom('User').selectAll().where('email', '=', 'u1@test.com').executeTakeFirst(), - ).resolves.toMatchObject({ - id: expect.any(Number), - email: 'u1@test.com', - }); - - await expect( - db.$qb.selectFrom('User').select(['User.email']).where('email', '=', 'u1@test.com').executeTakeFirst(), - ).resolves.toMatchObject({ - email: 'u1@test.com', - }); - - await expect( - db.$qb - .selectFrom('User') - .select(['email']) - .whereRef('email', '=', 'email') - .orderBy(['email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ - email: 'u1@test.com', - }); - - await expect( - db.$qb - .selectFrom('Post') - .innerJoin('User', 'User.id', 'Post.authorId') - .select(['User.email', 'Post.authorId', 'Post.title']) - .whereRef('Post.authorId', '=', 'User.id') - .executeTakeFirst(), - ).resolves.toMatchObject({ - authorId: user.id, - title: 'Post1', - }); + }), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + posts: [{ title: 'Post1' }], + }); - await expect( - db.$qb - .selectFrom('Post') - .select(['id', 'title']) - .select((eb) => - eb.selectFrom('User').select(['email']).whereRef('User.id', '=', 'Post.authorId').as('email'), - ) - .executeTakeFirst(), - ).resolves.toMatchObject({ - id: user.id, - title: 'Post1', - email: 'u1@test.com', - }); - }); + await expect( + db.$qb.selectFrom('User').selectAll().where('email', '=', 'u1@test.com').executeTakeFirst(), + ).resolves.toMatchObject({ + id: expect.any(Number), + email: 'u1@test.com', + }); - it('works with update', async () => { - const user = await db.user.create({ - data: { + await expect( + db.$qb.selectFrom('User').select(['User.email']).where('email', '=', 'u1@test.com').executeTakeFirst(), + ).resolves.toMatchObject({ email: 'u1@test.com', - posts: { - create: { - id: 1, - title: 'Post1', - }, - }, - }, + }); + + await expect( + db.$qb + .selectFrom('User') + .select(['email']) + .whereRef('email', '=', 'email') + .orderBy(['email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ + email: 'u1@test.com', + }); + + await expect( + db.$qb + .selectFrom('Post') + .innerJoin('User', 'User.id', 'Post.authorId') + .select(['User.email', 'Post.authorId', 'Post.title']) + .whereRef('Post.authorId', '=', 'User.id') + .executeTakeFirst(), + ).resolves.toMatchObject({ + authorId: user.id, + title: 'Post1', + }); + + await expect( + db.$qb + .selectFrom('Post') + .select(['id', 'title']) + .select((eb) => + eb.selectFrom('User').select(['email']).whereRef('User.id', '=', 'Post.authorId').as('email'), + ) + .executeTakeFirst(), + ).resolves.toMatchObject({ + id: user.id, + title: 'Post1', + email: 'u1@test.com', + }); }); - await expect( - db.user.update({ - where: { id: user.id }, + it('works with update', async () => { + const user = await db.user.create({ data: { - email: 'u2@test.com', + email: 'u1@test.com', posts: { - update: { - where: { id: 1 }, - data: { title: 'Post2' }, + create: { + id: 1, + title: 'Post1', }, }, }, - include: { posts: true }, - }), - ).resolves.toMatchObject({ - id: user.id, - email: 'u2@test.com', - posts: [expect.objectContaining({ title: 'Post2' })], - }); - - await expect( - db.$qb - .updateTable('User') - .set({ email: (eb) => eb.fn('concat', [eb.ref('email'), eb.val('_updated')]) }) - .where('email', '=', 'u2@test.com') - .returning(['email']) - .executeTakeFirst(), - ).resolves.toMatchObject({ email: 'u2@test.com_updated' }); - - await expect( - db.$qb.updateTable('User as u').set({ email: 'u3@test.com' }).returningAll().executeTakeFirst(), - ).resolves.toMatchObject({ id: expect.any(Number), email: 'u3@test.com' }); - }); - - it('works with delete', async () => { - const user = await db.user.create({ - data: { - email: 'u1@test.com', - posts: { - create: { - id: 1, - title: 'Post1', + }); + + await expect( + db.user.update({ + where: { id: user.id }, + data: { + email: 'u2@test.com', + posts: { + update: { + where: { id: 1 }, + data: { title: 'Post2' }, + }, + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + id: user.id, + email: 'u2@test.com', + posts: [expect.objectContaining({ title: 'Post2' })], + }); + + await expect( + db.$qb + .updateTable('User') + .set({ email: (eb) => eb.fn('upper', [eb.ref('email')]) }) + .where('email', '=', 'u2@test.com') + .returning(['email']) + .executeTakeFirst(), + ).resolves.toMatchObject({ email: 'U2@TEST.COM' }); + + await expect( + db.$qb.updateTable('User as u').set({ email: 'u3@test.com' }).returningAll().executeTakeFirst(), + ).resolves.toMatchObject({ id: expect.any(Number), email: 'u3@test.com' }); + }); + + it('works with delete', async () => { + const user = await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: { + id: 1, + title: 'Post1', + }, }, }, - }, - }); - - await expect( - db.$qb.deleteFrom('Post').where('title', '=', 'Post1').returning(['id', 'title']).executeTakeFirst(), - ).resolves.toMatchObject({ - id: user.id, - title: 'Post1', - }); - - await expect( - db.user.delete({ - where: { email: 'u1@test.com' }, - include: { posts: true }, - }), - ).resolves.toMatchObject({ - email: 'u1@test.com', - posts: [], + }); + + await expect( + db.$qb.deleteFrom('Post').where('title', '=', 'Post1').returning(['id', 'title']).executeTakeFirst(), + ).resolves.toMatchObject({ + id: user.id, + title: 'Post1', + }); + + await expect( + db.user.delete({ + where: { email: 'u1@test.com' }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + email: 'u1@test.com', + posts: [], + }); }); - }); -}); + }, +); diff --git a/packages/runtime/test/plugin/entity-mutation-hooks.test.ts b/packages/runtime/test/plugin/entity-mutation-hooks.test.ts index 6ca7f112..65c40eb6 100644 --- a/packages/runtime/test/plugin/entity-mutation-hooks.test.ts +++ b/packages/runtime/test/plugin/entity-mutation-hooks.test.ts @@ -1,412 +1,844 @@ -import SQLite from 'better-sqlite3'; -import { DeleteQueryNode, InsertQueryNode, SqliteDialect, UpdateQueryNode } from 'kysely'; +import { DeleteQueryNode, InsertQueryNode, UpdateQueryNode } from 'kysely'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackClient, type ClientContract } from '../../src'; +import { type ClientContract } from '../../src'; import { schema } from '../schemas/basic'; +import { createTestClient } from '../utils'; -describe('Entity mutation hooks tests', () => { - let _client: ClientContract; +const TEST_DB = 'client-api-entity-mutation-hooks-test'; - beforeEach(async () => { - _client = new ZenStackClient(schema, { - dialect: new SqliteDialect({ database: new SQLite(':memory:') }), - }); - await _client.$pushSchema(); - }); - - afterEach(async () => { - await _client?.$disconnect(); - }); - - it('can intercept all mutations', async () => { - const beforeCalled = { create: false, update: false, delete: false }; - const afterCalled = { create: false, update: false, delete: false }; - - const client = _client.$use({ - id: 'test', - onEntityMutation: { - beforeEntityMutation(args) { - beforeCalled[args.action] = true; - if (args.action === 'create') { - expect(InsertQueryNode.is(args.queryNode)).toBe(true); - } - if (args.action === 'update') { - expect(UpdateQueryNode.is(args.queryNode)).toBe(true); - } - if (args.action === 'delete') { - expect(DeleteQueryNode.is(args.queryNode)).toBe(true); - } - expect(args.entities).toBeUndefined(); - }, - afterEntityMutation(args) { - afterCalled[args.action] = true; - expect(args.beforeMutationEntities).toBeUndefined(); - expect(args.afterMutationEntities).toBeUndefined(); - }, - }, - }); - - const user = await client.user.create({ - data: { email: 'u1@test.com' }, - }); - await client.user.update({ - where: { id: user.id }, - data: { email: 'u2@test.com' }, - }); - await client.user.delete({ where: { id: user.id } }); +describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as const }])( + 'Entity mutation hooks tests for $provider', + ({ provider }) => { + let _client: ClientContract; - expect(beforeCalled).toEqual({ - create: true, - update: true, - delete: true, - }); - expect(afterCalled).toEqual({ - create: true, - update: true, - delete: true, - }); - }); - - it('can intercept with filtering', async () => { - const beforeCalled = { create: false, update: false, delete: false }; - const afterCalled = { create: false, update: false, delete: false }; - - const client = _client.$use({ - id: 'test', - onEntityMutation: { - mutationInterceptionFilter: (args) => { - return { - intercept: args.action !== 'delete', - }; - }, - beforeEntityMutation(args) { - beforeCalled[args.action] = true; - expect(args.entities).toBeUndefined(); - }, - afterEntityMutation(args) { - afterCalled[args.action] = true; - }, - }, + beforeEach(async () => { + _client = await createTestClient(schema, { + provider, + dbName: TEST_DB, + }); }); - const user = await client.user.create({ - data: { email: 'u1@test.com' }, - }); - await client.user.update({ - where: { id: user.id }, - data: { email: 'u2@test.com' }, + afterEach(async () => { + await _client?.$disconnect(); }); - await client.user.delete({ where: { id: user.id } }); - expect(beforeCalled).toEqual({ - create: true, - update: true, - delete: false, - }); - expect(afterCalled).toEqual({ - create: true, - update: true, - delete: false, - }); - }); - - it('can intercept with loading before mutation entities', async () => { - const client = _client.$use({ - id: 'test', - onEntityMutation: { - mutationInterceptionFilter: () => { - return { - intercept: true, - loadBeforeMutationEntities: true, - }; - }, - beforeEntityMutation(args) { - if (args.action === 'update' || args.action === 'delete') { - expect(args.entities).toEqual([ - expect.objectContaining({ - email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', - }), - ]); - } else { - expect(args.entities).toBeUndefined(); - } - }, - afterEntityMutation(args) { - if (args.action === 'update' || args.action === 'delete') { - expect(args.beforeMutationEntities).toEqual([ - expect.objectContaining({ - email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', - }), - ]); - } - expect(args.afterMutationEntities).toBeUndefined(); - }, - }, - }); + it('can intercept all mutations', async () => { + const beforeCalled = { create: false, update: false, delete: false }; + const afterCalled = { create: false, update: false, delete: false }; - const user = await client.user.create({ - data: { email: 'u1@test.com' }, - }); - await client.user.create({ - data: { email: 'u2@test.com' }, - }); - await client.user.update({ - where: { id: user.id }, - data: { email: 'u3@test.com' }, - }); - await client.user.delete({ where: { id: user.id } }); - }); - - it('can intercept with loading after mutation entities', async () => { - let userCreateIntercepted = false; - let userUpdateIntercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - mutationInterceptionFilter: () => { - return { - intercept: true, - loadAfterMutationEntities: true, - }; - }, - afterEntityMutation(args) { - if (args.action === 'create' || args.action === 'update') { + const client = _client.$use({ + id: 'test', + onEntityMutation: { + beforeEntityMutation(args) { + beforeCalled[args.action] = true; if (args.action === 'create') { - userCreateIntercepted = true; + expect(InsertQueryNode.is(args.queryNode)).toBe(true); } if (args.action === 'update') { - userUpdateIntercepted = true; + expect(UpdateQueryNode.is(args.queryNode)).toBe(true); } - expect(args.afterMutationEntities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - email: args.action === 'create' ? 'u1@test.com' : 'u2@test.com', - }), - ]), - ); - } else { + if (args.action === 'delete') { + expect(DeleteQueryNode.is(args.queryNode)).toBe(true); + } + expect(args.entities).toBeUndefined(); + }, + afterEntityMutation(args) { + afterCalled[args.action] = true; + expect(args.beforeMutationEntities).toBeUndefined(); expect(args.afterMutationEntities).toBeUndefined(); - } + }, }, - }, - }); + }); - const user = await client.user.create({ - data: { email: 'u1@test.com' }, - }); - await client.user.update({ - where: { id: user.id }, - data: { email: 'u2@test.com' }, + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + await client.user.delete({ where: { id: user.id } }); + + expect(beforeCalled).toEqual({ + create: true, + update: true, + delete: true, + }); + expect(afterCalled).toEqual({ + create: true, + update: true, + delete: true, + }); }); - expect(userCreateIntercepted).toBe(true); - expect(userUpdateIntercepted).toBe(true); - }); - - it('can intercept multi-entity mutations', async () => { - let userCreateIntercepted = false; - let userUpdateIntercepted = false; - let userDeleteIntercepted = false; - - const client = _client.$use({ - id: 'test', - onEntityMutation: { - mutationInterceptionFilter: () => { - return { - intercept: true, - loadAfterMutationEntities: true, - }; + it('can intercept with filtering', async () => { + const beforeCalled = { create: false, update: false, delete: false }; + const afterCalled = { create: false, update: false, delete: false }; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: (args) => { + return { + intercept: args.action !== 'delete', + }; + }, + beforeEntityMutation(args) { + beforeCalled[args.action] = true; + expect(args.entities).toBeUndefined(); + }, + afterEntityMutation(args) { + afterCalled[args.action] = true; + }, }, - afterEntityMutation(args) { - if (args.action === 'create') { - userCreateIntercepted = true; - expect(args.afterMutationEntities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u1@test.com' }), - expect.objectContaining({ email: 'u2@test.com' }), - ]), - ); - } else if (args.action === 'update') { - userUpdateIntercepted = true; - expect(args.afterMutationEntities).toEqual( - expect.arrayContaining([ + }); + + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + await client.user.delete({ where: { id: user.id } }); + + expect(beforeCalled).toEqual({ + create: true, + update: true, + delete: false, + }); + expect(afterCalled).toEqual({ + create: true, + update: true, + delete: false, + }); + }); + + it('can intercept with loading before mutation entities', async () => { + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + loadBeforeMutationEntities: true, + }; + }, + beforeEntityMutation(args) { + if (args.action === 'update' || args.action === 'delete') { + expect(args.entities).toEqual([ expect.objectContaining({ - email: 'u1@test.com', - name: 'A user', + email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', }), + ]); + } else { + expect(args.entities).toBeUndefined(); + } + }, + afterEntityMutation(args) { + if (args.action === 'update' || args.action === 'delete') { + expect(args.beforeMutationEntities).toEqual([ expect.objectContaining({ - email: 'u2@test.com', - name: 'A user', + email: args.action === 'update' ? 'u1@test.com' : 'u3@test.com', }), - ]), - ); - } else if (args.action === 'delete') { - userDeleteIntercepted = true; - expect(args.afterMutationEntities).toEqual( - expect.arrayContaining([ - expect.objectContaining({ email: 'u1@test.com' }), - expect.objectContaining({ email: 'u2@test.com' }), - ]), - ); - } + ]); + } + expect(args.afterMutationEntities).toBeUndefined(); + }, }, - }, - }); + }); - await client.user.createMany({ - data: [{ email: 'u1@test.com' }, { email: 'u2@test.com' }], - }); - await client.user.updateMany({ - data: { name: 'A user' }, + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.create({ + data: { email: 'u2@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u3@test.com' }, + }); + await client.user.delete({ where: { id: user.id } }); }); - expect(userCreateIntercepted).toBe(true); - expect(userUpdateIntercepted).toBe(true); - expect(userDeleteIntercepted).toBe(false); - }); - - it('can intercept nested mutations', async () => { - let post1Intercepted = false; - let post2Intercepted = false; - const client = _client.$use({ - id: 'test', - onEntityMutation: { - mutationInterceptionFilter: (args) => { - return { - intercept: args.action === 'create' || args.action === 'update', - loadAfterMutationEntities: true, - }; - }, - afterEntityMutation(args) { - if (args.action === 'create') { - if (args.model === 'Post') { - if ((args.afterMutationEntities![0] as any).title === 'Post1') { - post1Intercepted = true; + it('can intercept with loading after mutation entities', async () => { + let userCreateIntercepted = false; + let userUpdateIntercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + loadAfterMutationEntities: true, + }; + }, + afterEntityMutation(args) { + if (args.action === 'create' || args.action === 'update') { + if (args.action === 'create') { + userCreateIntercepted = true; } - if ((args.afterMutationEntities![0] as any).title === 'Post2') { - post2Intercepted = true; + if (args.action === 'update') { + userUpdateIntercepted = true; } + expect(args.afterMutationEntities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: args.action === 'create' ? 'u1@test.com' : 'u2@test.com', + }), + ]), + ); + } else { + expect(args.afterMutationEntities).toBeUndefined(); } - } + }, }, - }, - }); + }); - const user = await client.user.create({ - data: { - email: 'u1@test.com', - posts: { create: { title: 'Post1' } }, - }, - }); - await client.user.update({ - where: { id: user.id }, - data: { - email: 'u2@test.com', - posts: { create: { title: 'Post2' } }, - }, + const user = await client.user.create({ + data: { email: 'u1@test.com' }, + }); + await client.user.update({ + where: { id: user.id }, + data: { email: 'u2@test.com' }, + }); + + expect(userCreateIntercepted).toBe(true); + expect(userUpdateIntercepted).toBe(true); }); - expect(post1Intercepted).toBe(true); - expect(post2Intercepted).toBe(true); - }); + it('can intercept multi-entity mutations', async () => { + let userCreateIntercepted = false; + let userUpdateIntercepted = false; + let userDeleteIntercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + loadAfterMutationEntities: true, + }; + }, + afterEntityMutation(args) { + if (args.action === 'create') { + userCreateIntercepted = true; + expect(args.afterMutationEntities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + } else if (args.action === 'update') { + userUpdateIntercepted = true; + expect(args.afterMutationEntities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + email: 'u1@test.com', + name: 'A user', + }), + expect.objectContaining({ + email: 'u2@test.com', + name: 'A user', + }), + ]), + ); + } else if (args.action === 'delete') { + userDeleteIntercepted = true; + expect(args.afterMutationEntities).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u1@test.com' }), + expect.objectContaining({ email: 'u2@test.com' }), + ]), + ); + } + }, + }, + }); - it('does not affect the database operation if an afterEntityMutation hook throws', async () => { - let intercepted = false; + await client.user.createMany({ + data: [{ email: 'u1@test.com' }, { email: 'u2@test.com' }], + }); + await client.user.updateMany({ + data: { name: 'A user' }, + }); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - afterEntityMutation() { - intercepted = true; - throw new Error('trigger rollback'); - }, - }, + expect(userCreateIntercepted).toBe(true); + expect(userUpdateIntercepted).toBe(true); + expect(userDeleteIntercepted).toBe(false); }); - await client.user.create({ - data: { email: 'u1@test.com' }, - }); + it('can intercept nested mutations', async () => { + let post1Intercepted = false; + let post2Intercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: (args) => { + return { + intercept: args.action === 'create' || args.action === 'update', + loadAfterMutationEntities: true, + }; + }, + afterEntityMutation(args) { + if (args.action === 'create') { + if (args.model === 'Post') { + if ((args.afterMutationEntities![0] as any).title === 'Post1') { + post1Intercepted = true; + } + if ((args.afterMutationEntities![0] as any).title === 'Post2') { + post2Intercepted = true; + } + } + } + }, + }, + }); - expect(intercepted).toBe(true); - await expect(client.user.findMany()).toResolveWithLength(1); - }); + const user = await client.user.create({ + data: { + email: 'u1@test.com', + posts: { create: { title: 'Post1' } }, + }, + }); + await client.user.update({ + where: { id: user.id }, + data: { + email: 'u2@test.com', + posts: { create: { title: 'Post2' } }, + }, + }); - it('does not trigger afterEntityMutation hook if a transaction is rolled back', async () => { - let intercepted = false; + expect(post1Intercepted).toBe(true); + expect(post2Intercepted).toBe(true); + }); - const client = _client.$use({ - id: 'test', - onEntityMutation: { - afterEntityMutation() { - intercepted = true; + it('triggers multiple afterEntityMutation hooks for multiple mutations', async () => { + const triggered: any[] = []; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + loadBeforeMutationEntities: true, + loadAfterMutationEntities: true, + }; + }, + afterEntityMutation(args) { + triggered.push(args); + }, }, - }, - }); + }); - try { await client.$transaction(async (tx) => { await tx.user.create({ data: { email: 'u1@test.com' }, }); - throw new Error('trigger rollback'); - }); - } catch { - // noop - } - - await expect(client.user.findMany()).toResolveWithLength(0); - expect(intercepted).toBe(false); - }); - - it('triggers multiple afterEntityMutation hooks for multiple mutations', async () => { - const triggered: any[] = []; - - const client = _client.$use({ - id: 'test', - onEntityMutation: { - mutationInterceptionFilter: () => { - return { - intercept: true, - loadBeforeMutationEntities: true, - loadAfterMutationEntities: true, - }; - }, - afterEntityMutation(args) { - triggered.push(args); - }, - }, + await tx.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + await tx.user.delete({ where: { email: 'u2@test.com' } }); + }); + + expect(triggered).toEqual([ + expect.objectContaining({ + action: 'create', + model: 'User', + beforeMutationEntities: undefined, + afterMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], + }), + expect.objectContaining({ + action: 'update', + model: 'User', + beforeMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], + afterMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], + }), + expect.objectContaining({ + action: 'delete', + model: 'User', + beforeMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], + afterMutationEntities: undefined, + }), + ]); }); - await client.$transaction(async (tx) => { - await tx.user.create({ - data: { email: 'u1@test.com' }, + describe('Without outer transaction', () => { + it('persists hooks db side effects when run out of tx', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(ctx) { + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + }, + }, + }); + + await client.user.create({ + data: { email: 'u1@test.com' }, + }); + expect(intercepted).toBe(true); + // both the mutation and hook's side effect are persisted + await expect(client.profile.findMany()).toResolveWithLength(1); + await expect(client.user.findFirst()).resolves.toMatchObject({ email: 'u2@test.com' }); }); - await tx.user.update({ - where: { email: 'u1@test.com' }, - data: { email: 'u2@test.com' }, + + it('persists hooks db side effects when run within tx', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + runAfterMutationWithinTransaction: true, + }; + }, + async beforeEntityMutation(ctx) { + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + }, + }, + }); + + await client.user.create({ + data: { email: 'u1@test.com' }, + }); + expect(intercepted).toBe(true); + // both the mutation and hook's side effect are persisted + await expect(client.profile.findMany()).toResolveWithLength(1); + await expect(client.user.findFirst()).resolves.toMatchObject({ email: 'u2@test.com' }); + }); + + it('fails the mutation if before mutation hook throws', async () => { + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation() { + throw new Error('trigger failure'); + }, + }, + }); + + await expect( + client.user.create({ + data: { email: 'u1@test.com' }, + }), + ).rejects.toThrow(); + + // mutation is persisted + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('does not affect the database operation if after mutation hook throws', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation() { + intercepted = true; + throw new Error('trigger rollback'); + }, + }, + }); + + await client.user.create({ + data: { email: 'u1@test.com' }, + }); + + expect(intercepted).toBe(true); + // mutation is persisted + await expect(client.user.findMany()).toResolveWithLength(1); + }); + + it('fails the entire transaction if specified to run inside the tx', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + runAfterMutationWithinTransaction: true, + }; + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger rollback'); + }, + }, + }); + + await expect( + client.user.create({ + data: { email: 'u1@test.com' }, + }), + ).rejects.toThrow(); + + expect(intercepted).toBe(true); + // mutation is not persisted + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('does not trigger afterEntityMutation hook if a transaction is rolled back', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + }, + }, + }); + + try { + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('trigger rollback'); + }); + } catch { + // noop + } + + expect(intercepted).toBe(false); + // neither the mutation nor the hook's side effect are persisted + await expect(client.user.findMany()).toResolveWithLength(0); + }); + + it('triggers afterEntityMutation hook if a transaction is rolled back but hook runs within tx', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + runAfterMutationWithinTransaction: true, + }; + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + }, + }, + }); + + try { + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('trigger rollback'); + }); + } catch { + // noop + } + + expect(intercepted).toBe(true); + // neither the mutation nor the hook's side effect are persisted + await expect(client.user.findMany()).toResolveWithLength(0); }); - await tx.user.delete({ where: { email: 'u2@test.com' } }); }); - expect(triggered).toEqual([ - expect.objectContaining({ - action: 'create', - model: 'User', - beforeMutationEntities: undefined, - afterMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], - }), - expect.objectContaining({ - action: 'update', - model: 'User', - beforeMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], - afterMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], - }), - expect.objectContaining({ - action: 'delete', - model: 'User', - beforeMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], - afterMutationEntities: undefined, - }), - ]); - }); -}); + describe('With outer transaction', () => { + it('sees changes in the transaction prior to reading before mutation entities', async () => { + let intercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: (ctx) => { + return { + intercept: ctx.action === 'update', + loadBeforeMutationEntities: true, + }; + }, + async beforeEntityMutation(ctx) { + intercepted = true; + expect(ctx.entities).toEqual([expect.objectContaining({ email: 'u1@test.com' })]); + }, + }, + }); + + await client.$transaction(async (tx) => { + await tx.user.create({ data: { email: 'u1@test.com' } }); + await tx.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + }); + + expect(intercepted).toBe(true); + }); + + it('runs before mutation hook within the transaction', async () => { + let intercepted = false; + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(ctx) { + intercepted = true; + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + }, + }, + }); + + await expect( + client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + throw new Error('trigger rollback'); + }), + ).rejects.toThrow(); + + expect(intercepted).toBe(true); + await expect(client.user.findMany()).toResolveWithLength(0); + await expect(client.profile.findMany()).toResolveWithLength(0); + }); + + it('persists hooks db side effects when run out of tx', async () => { + let intercepted = false; + let txVisible = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async beforeEntityMutation(ctx) { + const r = await ctx.client.user.findUnique({ where: { email: 'u1@test.com' } }); + if (r) { + // second create + txVisible = true; + } else { + // first create + await ctx.client.profile.create({ + data: { bio: 'Bio1' }, + }); + } + }, + async afterEntityMutation(ctx) { + if (intercepted) { + return; + } + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u3@test.com' }, + }); + }, + }, + }); + + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + await tx.user.create({ + data: { email: 'u2@test.com' }, + }); + }); + + expect(intercepted).toBe(true); + expect(txVisible).toBe(true); + + // both the mutation and hook's side effect are persisted + await expect(client.profile.findMany()).toResolveWithLength(1); + await expect(client.user.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u2@test.com' }), + expect.objectContaining({ email: 'u3@test.com' }), + ]), + ); + }); + + it('persists hooks db side effects when run within tx', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + runAfterMutationWithinTransaction: true, + }; + }, + async afterEntityMutation(ctx) { + if (intercepted) { + return; + } + intercepted = true; + await ctx.client.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u3@test.com' }, + }); + }, + }, + }); + + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + await tx.user.create({ + data: { email: 'u2@test.com' }, + }); + }); + + expect(intercepted).toBe(true); + + // both the mutation and hook's side effect are persisted + await expect(client.user.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'u2@test.com' }), + expect.objectContaining({ email: 'u3@test.com' }), + ]), + ); + }); + + it('persists mutation when run out of tx and throws', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger error'); + }, + }, + }); + + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + }); + + expect(intercepted).toBe(true); + + // both the mutation and hook's side effect are persisted + await expect(client.user.findMany()).toResolveWithLength(2); + }); + + it('rolls back mutation when run within tx and throws', async () => { + let intercepted = false; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + runAfterMutationWithinTransaction: true, + }; + }, + async afterEntityMutation(ctx) { + intercepted = true; + await ctx.client.user.create({ data: { email: 'u2@test.com' } }); + throw new Error('trigger error'); + }, + }, + }); + + await expect( + client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + }), + ).rejects.toThrow(); + + expect(intercepted).toBe(true); + + // both the mutation and hook's side effect are rolled back + await expect(client.user.findMany()).toResolveWithLength(0); + }); + }); + + it('triggers multiple afterEntityMutation hooks for multiple mutations', async () => { + const triggered: any[] = []; + + const client = _client.$use({ + id: 'test', + onEntityMutation: { + mutationInterceptionFilter: () => { + return { + intercept: true, + loadBeforeMutationEntities: true, + loadAfterMutationEntities: true, + }; + }, + afterEntityMutation(args) { + triggered.push(args); + }, + }, + }); + + await client.$transaction(async (tx) => { + await tx.user.create({ + data: { email: 'u1@test.com' }, + }); + await tx.user.update({ + where: { email: 'u1@test.com' }, + data: { email: 'u2@test.com' }, + }); + await tx.user.delete({ where: { email: 'u2@test.com' } }); + }); + + expect(triggered).toEqual([ + expect.objectContaining({ + action: 'create', + model: 'User', + beforeMutationEntities: undefined, + afterMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], + }), + expect.objectContaining({ + action: 'update', + model: 'User', + beforeMutationEntities: [expect.objectContaining({ email: 'u1@test.com' })], + afterMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], + }), + expect.objectContaining({ + action: 'delete', + model: 'User', + beforeMutationEntities: [expect.objectContaining({ email: 'u2@test.com' })], + afterMutationEntities: undefined, + }), + ]); + }); + }, +);