From bc32347be33f7cd43fe97998eca8e2a426860c20 Mon Sep 17 00:00:00 2001 From: dinhvietha Date: Fri, 15 Nov 2024 15:22:54 +0700 Subject: [PATCH] Add support for mikro-orm --- .../CHANGES.md | 9 + .../transactional-adapter-mikro-orm/README.md | 5 + .../jest.config.js | 17 ++ .../package.json | 69 ++++++ .../src/index.ts | 1 + .../lib/transactional-adapter-mikro-orm.ts | 58 +++++ .../test/docker-compose.yml | 14 ++ .../transactional-adapter-mikro-orm.spec.ts | 228 ++++++++++++++++++ .../tsconfig.json | 8 + 9 files changed, 409 insertions(+) create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/CHANGES.md create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/README.md create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/jest.config.js create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/package.json create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/src/index.ts create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/src/lib/transactional-adapter-mikro-orm.ts create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/test/docker-compose.yml create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/test/transactional-adapter-mikro-orm.spec.ts create mode 100644 packages/transactional-adapters/transactional-adapter-mikro-orm/tsconfig.json diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/CHANGES.md b/packages/transactional-adapters/transactional-adapter-mikro-orm/CHANGES.md new file mode 100644 index 00000000..70825b65 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/CHANGES.md @@ -0,0 +1,9 @@ +# Changelog + + + +## [1.0.0](https://github.com/Papooch/nestjs-cls/compare/@nestjs-cls/transactional-adapter-mikro-orm@1.0.0...@nestjs-cls/transactional-adapter-mikro-orm@1.1.0) "@nestjs-cls/transactional-adapter-mikro-orm" (2024-11-15) + +### Features + +* **transactional-adapter-mikro-orm**: initial version \ No newline at end of file diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/README.md b/packages/transactional-adapters/transactional-adapter-mikro-orm/README.md new file mode 100644 index 00000000..c77d2608 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/README.md @@ -0,0 +1,5 @@ +# @nestjs-cls/transactional-adapter-mikro-orm + +`mikro-orm` adapter for the `@nestjs-cls/transactional` plugin. + +### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional) 📖 diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/jest.config.js b/packages/transactional-adapters/transactional-adapter-mikro-orm/jest.config.js new file mode 100644 index 00000000..14ae88e3 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.ts$': [ + 'ts-jest', + { + isolatedModules: true, + maxWorkers: 1, + }, + ], + }, + collectCoverageFrom: ['src/**/*.ts'], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/package.json b/packages/transactional-adapters/transactional-adapter-mikro-orm/package.json new file mode 100644 index 00000000..1847ec9c --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/package.json @@ -0,0 +1,69 @@ +{ + "name": "@nestjs-cls/transactional-adapter-mikro-orm", + "version": "1.0.0", + "description": "A mikro-orm adapter for @nestjs-cls/transactional", + "author": "whileloop99 ", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Papooch/nestjs-cls.git" + }, + "homepage": "https://papooch.github.io/nestjs-cls/", + "keywords": [ + "nest", + "nestjs", + "cls", + "continuation-local-storage", + "als", + "AsyncLocalStorage", + "async_hooks", + "request context", + "async context", + "mikro-orm" + ], + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src/**/!(*.spec).d.ts", + "dist/src/**/!(*.spec).js" + ], + "scripts": { + "prepack": "cp ../../../LICENSE ./LICENSE", + "prebuild": "rimraf dist", + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage" + }, + "peerDependencies": { + "@mikro-orm/core": "^6.4.0", + "@mikro-orm/postgresql": "^6.4.0", + "@nestjs-cls/transactional": "workspace:^2.4.2", + "nestjs-cls": "workspace:^4.4.1" + }, + "devDependencies": { + "@mikro-orm/core": "^6.4.0", + "@mikro-orm/postgresql": "^6.4.0", + "@nestjs/cli": "^10.0.2", + "@nestjs/common": "^10.3.7", + "@nestjs/core": "^10.3.7", + "@nestjs/testing": "^10.3.7", + "@types/jest": "^28.1.2", + "@types/node": "^18.0.0", + "jest": "^29.7.0", + "reflect-metadata": "^0.1.13", + "rimraf": "^3.0.2", + "rxjs": "^7.5.5", + "ts-jest": "^29.1.2", + "ts-loader": "^9.3.0", + "ts-node": "^10.8.1", + "tsconfig-paths": "^4.0.0", + "typescript": "5.0" + } +} diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/src/index.ts b/packages/transactional-adapters/transactional-adapter-mikro-orm/src/index.ts new file mode 100644 index 00000000..f7a2fc9a --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/src/index.ts @@ -0,0 +1 @@ +export * from './lib/transactional-adapter-mikro-orm'; diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/src/lib/transactional-adapter-mikro-orm.ts b/packages/transactional-adapters/transactional-adapter-mikro-orm/src/lib/transactional-adapter-mikro-orm.ts new file mode 100644 index 00000000..8696abcd --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/src/lib/transactional-adapter-mikro-orm.ts @@ -0,0 +1,58 @@ +import { TransactionalAdapter } from '@nestjs-cls/transactional'; +import { MikroORM, EntityManager, IsolationLevel, IDatabaseDriver, Connection } from '@mikro-orm/core'; + +export interface MikroOrmTransactionalAdapterOptions { + /** + * The injection token for the MikroORM instance. + */ + dataSourceToken: any; + + /** + * Default options for the transaction. These will be merged with any transaction-specific options + * passed to the `@Transactional` decorator or the `TransactionHost#withTransaction` method. + */ + defaultTxOptions?: Partial; +} + +export interface MikroOrmTransactionOptions { + isolationLevel?: IsolationLevel; +} + +export class TransactionalAdapterMikroOrm + implements + TransactionalAdapter< + MikroORM>, + EntityManager>, + MikroOrmTransactionOptions + > { + connectionToken: any; + defaultTxOptions?: Partial; + + constructor(options: MikroOrmTransactionalAdapterOptions) { + this.connectionToken = options.dataSourceToken; + this.defaultTxOptions = options.defaultTxOptions; + } + + optionsFactory = (orm: MikroORM>) => ({ + wrapWithTransaction: async ( + options: MikroOrmTransactionOptions, + fn: (...args: any[]) => Promise, + setClient: (client?: EntityManager>) => void, + ) => { + const em = orm.em.fork(); // Create a forked EntityManager for the transaction + await em.begin({ + isolationLevel: options?.isolationLevel || this.defaultTxOptions?.isolationLevel, + }); + try { + setClient(em); + const result = await fn(); + await em.commit(); + return result; + } catch (error) { + await em.rollback(); + throw error; + } + }, + getFallbackInstance: () => orm.em, + }); +} diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/test/docker-compose.yml b/packages/transactional-adapters/transactional-adapter-mikro-orm/test/docker-compose.yml new file mode 100644 index 00000000..85414b22 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/test/docker-compose.yml @@ -0,0 +1,14 @@ +services: + db: + image: postgres:15 + ports: + - 5446:5432 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 1s + timeout: 1s + retries: 5 diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/test/transactional-adapter-mikro-orm.spec.ts b/packages/transactional-adapters/transactional-adapter-mikro-orm/test/transactional-adapter-mikro-orm.spec.ts new file mode 100644 index 00000000..16fa07a0 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/test/transactional-adapter-mikro-orm.spec.ts @@ -0,0 +1,228 @@ +import { + ClsPluginTransactional, + InjectTransaction, + Transaction, + Transactional, + TransactionHost, +} from '@nestjs-cls/transactional'; +import { Injectable, Module } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ClsModule, UseCls } from 'nestjs-cls'; +import { execSync } from 'node:child_process'; +import { MikroORM, Entity, PrimaryKey, Property, IsolationLevel } from '@mikro-orm/core'; +import { TransactionalAdapterMikroOrm } from '../src'; +import { PostgreSqlDriver } from '@mikro-orm/postgresql'; + +@Entity() +class User { + @PrimaryKey() + id!: number; + + @Property() + name?: string; + + @Property() + email?: string; +} + +const mikroOrm = MikroORM.init({ + dbName: 'postgres', + user: 'postgres', + password: 'postgres', + host: 'localhost', + port: 5446, + entities: [User], + driver: PostgreSqlDriver, + allowGlobalContext: true, +}); + + +@Injectable() +class UserRepository { + constructor( + @InjectTransaction() + private readonly tx: Transaction, + ) { } + + async getUserById(id: number) { + // Use the transactional EntityManager directly + return await this.tx.findOne(User, { id }); + } + + async createUser(name: string) { + // Use the transactional EntityManager directly + const user = this.tx.create(User, { + name, + email: `${name}@email.com`, + }); + await this.tx.persistAndFlush(user); // Save the user entity + return user; + } +} + + +@Injectable() +class UserService { + constructor( + private readonly userRepository: UserRepository, + private readonly transactionHost: TransactionHost, + private readonly orm: MikroORM, + ) { } + + @UseCls() + async withoutTransaction() { + const r1 = await this.userRepository.createUser('Jim'); + const r2 = await this.userRepository.getUserById(r1.id); + return { r1, r2 }; + } + + @Transactional() + async transactionWithDecorator() { + const r1 = await this.userRepository.createUser('John'); + const r2 = await this.userRepository.getUserById(r1.id); + return { r1, r2 }; + } + + @Transactional({ + isolationLevel: IsolationLevel.SERIALIZABLE, + }) + async transactionWithDecoratorWithOptions() { + const r1 = await this.userRepository.createUser('James'); + const r2 = await this.orm.em.findOne(User, { id: r1.id }); + const r3 = await this.userRepository.getUserById(r1.id); + return { r1, r2, r3 }; + } + + async transactionWithFunctionWrapper() { + return this.transactionHost.withTransaction( + { isolationLevel: IsolationLevel.SERIALIZABLE }, + async () => { + const r1 = await this.userRepository.createUser('Joe'); + const r2 = await this.orm.em.findOne(User, { id: r1.id }); + const r3 = await this.userRepository.getUserById(r1.id); + return { r1, r2, r3 }; + }, + ); + } + + @Transactional() + async transactionWithDecoratorError() { + await this.userRepository.createUser('Nobody'); + throw new Error('Rollback'); + } +} + +@Module({ + providers: [ + { + provide: MikroORM, + useValue: mikroOrm, + }, + UserRepository, + UserService, + ], + exports: [MikroORM], +}) +class MikroOrmModule { } + +@Module({ + imports: [ + MikroOrmModule, + ClsModule.forRoot({ + plugins: [ + new ClsPluginTransactional({ + imports: [MikroOrmModule], + adapter: new TransactionalAdapterMikroOrm({ + dataSourceToken: MikroORM, + }), + enableTransactionProxy: true, + }), + ], + }), + ], + providers: [UserRepository, UserService], +}) +class AppModule { } + +describe('TransactionalAdapterMikroOrm', () => { + let module: TestingModule; + let callingService: UserService; + + beforeAll(async () => { + execSync( + 'docker compose -f test/docker-compose.yml up -d --quiet-pull --wait', + { + stdio: 'inherit', + cwd: process.cwd(), + }, + ); + const orm = await mikroOrm; + await orm.getSchemaGenerator().dropSchema(); + await orm.getSchemaGenerator().createSchema(); + }, 60_000); + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + await module.init(); + callingService = module.get(UserService); + }); + + afterAll(async () => { + const orm = await mikroOrm; + await orm.close(true); + execSync('docker compose -f test/docker-compose.yml down', { + stdio: 'inherit', + }); + }, 60_000); + + describe('Transactions', () => { + it('should work without an active transaction', async () => { + const { r1, r2 } = await callingService.withoutTransaction(); + expect(r1).toEqual(r2); + const orm = await mikroOrm; + const users = await orm.em.find(User, {}); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + it('should run a transaction with the default options with a decorator', async () => { + const { r1, r2 } = await callingService.transactionWithDecorator(); + expect(r1).toEqual(r2); + const orm = await mikroOrm; + const users = await orm.em.find(User, {}); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + it('should run a transaction with the specified options with a decorator', async () => { + const { r1, r2, r3 } = + await callingService.transactionWithDecoratorWithOptions(); + expect(r1).toEqual(r3); + expect(r2).toBeNull(); + const orm = await mikroOrm; + const users = await orm.em.find(User, {}); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + it('should run a transaction with the specified options with a function wrapper', async () => { + const { r1, r2, r3 } = + await callingService.transactionWithFunctionWrapper(); + expect(r1).toEqual(r3); + expect(r2).toBeNull(); + const orm = await mikroOrm; + const users = await orm.em.find(User, {}); + expect(users).toEqual(expect.arrayContaining([r1])); + }); + + it('should rollback a transaction on error', async () => { + await expect( + callingService.transactionWithDecoratorError(), + ).rejects.toThrow(new Error('Rollback')); + const orm = await mikroOrm; + const users = await orm.em.find(User, {}); + expect(users).toEqual( + expect.not.arrayContaining([{ name: 'Nobody' }]), + ); + }); + }); +}); diff --git a/packages/transactional-adapters/transactional-adapter-mikro-orm/tsconfig.json b/packages/transactional-adapters/transactional-adapter-mikro-orm/tsconfig.json new file mode 100644 index 00000000..bbc28fb3 --- /dev/null +++ b/packages/transactional-adapters/transactional-adapter-mikro-orm/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}