Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add typeorm Adapter for @nestjs-cls/transactional #140 #141

Merged
merged 9 commits into from
Apr 22, 2024
7 changes: 4 additions & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.tsdk": "node_modules/typescript/lib"
"editor.formatOnSave": true,
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.tsdk": "node_modules/typescript/lib",
"conventionalCommits.scopes": ["transactional-adapter-typeorm"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @nestjs-cls/transactional-adapter-typeorm

`typeorm` adapter for the `@nestjs-cls/transactional` plugin.

### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional) 📖
Original file line number Diff line number Diff line change
@@ -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',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "@nestjs-cls/transactional-adapter-typeorm",
"version": "1.2.0",
"description": "A typeorm adapter for @nestjs-cls/transactional",
"author": "Giosuè Delgado S.Z. - Relybytes Srl <[email protected]>",
"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",
"typeorm"
],
"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": {
"@nestjs-cls/transactional": "workspace:^2.2.2",
"nestjs-cls": "workspace:^4.3.0",
"typeorm": "0.3.20"
},
"devDependencies": {
"@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",
"typeorm": "0.3.20",
"typescript": "5.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/transactional-adapter-typeorm';
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { DataSource, EntityManager } from 'typeorm';
import {
TypeOrmTransactionOptions,
TypeOrmTransactionalAdapterOptions,
} from './transactional-options';
import { TransactionalAdapter } from '@nestjs-cls/transactional';

export class TransactionalAdapterTypeOrm
implements
TransactionalAdapter<
DataSource,
DataSource | EntityManager,
TypeOrmTransactionOptions
>
{
connectionToken: any;

constructor(options: TypeOrmTransactionalAdapterOptions) {
this.connectionToken = options.typeOrmInstanceToken;
}

optionsFactory = (typeORMInstance: DataSource) => ({
wrapWithTransaction: async (
options: TypeOrmTransactionOptions,
fn: (...args: any[]) => Promise<any>,
setClient: (client?: EntityManager) => void,
) => {
return typeORMInstance.transaction(
options?.isolationLevel,
(trx) => {
setClient(trx);
return fn();
},
);
},
getFallbackInstance: () => typeORMInstance,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel';

export interface TypeOrmTransactionalAdapterOptions {
/**
* The injection token for the typeOrm instance.
*/
typeOrmInstanceToken: any;
}

export interface TypeOrmTransactionOptions {
isolationLevel: IsolationLevel;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
services:
db:
image: postgres:15
ports:
- 5444: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
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import {
ClsPluginTransactional,
Transactional,
TransactionHost,
} from '@nestjs-cls/transactional';
import { Injectable, Module } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ClsModule } from 'nestjs-cls';
import { execSync } from 'node:child_process';
import { DataSource, Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { TransactionalAdapterTypeOrm } from '../src';

@Entity()
class User {
@PrimaryGeneratedColumn()
id!: number;

@Column()
name?: string;

@Column()
email?: string;
}

const dataSource = new DataSource({
type: 'postgres',
host: 'localhost',
port: 5444,
username: 'postgres',
password: 'postgres',
database: 'postgres',
entities: [User],
synchronize: true,
});

@Injectable()
class UserRepository {
constructor(
private readonly transactionHost: TransactionHost<TransactionalAdapterTypeOrm>,
) {}

async getUserById(id: number) {
return await this.transactionHost.tx
.getRepository(User)
.findOneBy({ id });
}

async createUser(name: string) {
return await this.transactionHost.tx.getRepository(User).save({
name,
email: `${name}@email.com`,
});
}
}

@Injectable()
class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly transactionHost: TransactionHost<TransactionalAdapterTypeOrm>,

private datasource: DataSource,
) {}

@Transactional()
async transactionWithDecorator() {
const r1 = await this.userRepository.createUser('John');
const r2 = await this.userRepository.getUserById(r1.id);
return { r1, r2 };
}

@Transactional<TransactionalAdapterTypeOrm>({
isolationLevel: 'SERIALIZABLE',
})
async transactionWithDecoratorWithOptions() {
const r1 = await this.userRepository.createUser('James');
const r2 = await this.datasource
.getRepository(User)
.createQueryBuilder('user')
.where('user.id = :id', { id: r1.id })
.getOne();
const r3 = await this.userRepository.getUserById(r1.id);
return { r1, r2, r3 };
}

async transactionWithFunctionWrapper() {
return this.transactionHost.withTransaction(
{ isolationLevel: 'SERIALIZABLE' },
async () => {
const r1 = await this.userRepository.createUser('Joe');
const r2 = await this.datasource
.getRepository(User)
.createQueryBuilder('user')
.where('user.id = :id', { id: r1.id })
.getOne();
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: DataSource,
useValue: dataSource,
},
UserRepository,
UserService,
],
exports: [DataSource],
})
class TypeOrmModule {}

@Module({
imports: [
TypeOrmModule,
ClsModule.forRoot({
plugins: [
new ClsPluginTransactional({
imports: [TypeOrmModule],
adapter: new TransactionalAdapterTypeOrm({
typeOrmInstanceToken: DataSource,
}),
}),
],
}),
],
providers: [UserRepository, UserService],
})
class AppModule {}

describe('Transactional', () => {
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(),
},
);
await dataSource.initialize();

await dataSource.query('DROP TABLE IF EXISTS "User"');
await dataSource.synchronize();
}, 60_000);

beforeEach(async () => {
module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
await module.init();
callingService = module.get(UserService);
});

afterAll(async () => {
await dataSource.destroy();
execSync('docker compose -f test/docker-compose.yml down', {
stdio: 'inherit',
});
}, 60_000);

describe('TransactionalAdapterTypeOrmPromise', () => {
it('should run a transaction with the default options with a decorator', async () => {
const { r1, r2 } = await callingService.transactionWithDecorator();
expect(r1).toEqual(r2);
const users = await dataSource.manager.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 users = await dataSource.manager.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 users = await dataSource.manager.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 users = await dataSource.manager.find(User);
expect(users).toEqual(
expect.not.arrayContaining([{ name: 'Nobody' }]),
);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
Loading
Loading