Skip to content

Commit

Permalink
feat: pg-promise adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
sam-artuso committed Jan 28, 2024
1 parent 8ef1e40 commit 06bbb18
Show file tree
Hide file tree
Showing 10 changed files with 541 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @nestjs-cls/transactional-adapter-pg-promise

`pg-promise` 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',
},
collectCoverageFrom: ['src/**/*.ts'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
globals: {
'ts-jest': {
isolatedModules: true,
maxWorkers: 1,
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"name": "@nestjs-cls/transactional-adapter-pg-promise",
"version": "1.0.0",
"description": "A pg-promise adapter for @nestjs-cls/transactional",
"author": "Sam Artuso <[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"
],
"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:^1.0.1",
"nestjs-cls": "workspace:^4.0.1",
"pg-promise": "^11"
},
"devDependencies": {
"@nestjs/cli": "^10.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/jest": "^28.1.2",
"@types/node": "^18.0.0",
"jest": "^28.1.1",
"pg-promise": "^11",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.5",
"ts-jest": "^28.0.5",
"ts-loader": "^9.3.0",
"ts-node": "^10.8.1",
"tsconfig-paths": "^4.0.0",
"typescript": "~4.8.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/transactional-adapter-pg-promise';
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { TransactionalAdapter } from '@nestjs-cls/transactional';
import { ITask } from 'pg-promise';

export type Task = ITask<unknown>;

type TxOptions = Parameters<Task['tx']>[0];

export interface PgPromiseTransactionalAdapterOptions {
/**
* The injection token for the pg-promise instance.
*/
dbInstanceToken: any;
}

export class TransactionalAdapterPgPromise
implements TransactionalAdapter<Task, Task, any>
{
connectionToken: any;

constructor(options: PgPromiseTransactionalAdapterOptions) {
this.connectionToken = options.dbInstanceToken;
}

optionsFactory = (pgPromiseDbInstance: Task) => ({
wrapWithTransaction: async (
options: TxOptions | null,
fn: (...args: any[]) => Promise<any>,
setClient: (client?: Task) => void,
) => {
return pgPromiseDbInstance.tx(options ?? {}, (tx: Task) => {
setClient(tx);
return fn();
});
},
getFallbackInstance: () => pgPromiseDbInstance,
});
}
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,207 @@
import {
ClsPluginTransactional,
Transactional,
TransactionHost,
} from '@nestjs-cls/transactional';
import { Inject, Injectable, Module } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ClsModule } from 'nestjs-cls';
import { Task, TransactionalAdapterPgPromise } from '../src';
import pgPromise from 'pg-promise';
import { execSync } from 'node:child_process';

type UserRecord = { id: number; name: string; email: string };

const PG_PROMISE = 'PG_PROMISE';

const pgp = pgPromise();
const db = pgp({
host: 'localhost',
port: 5444,
user: 'postgres',
password: 'postgres',
database: 'postgres',
});

const { TransactionMode, isolationLevel } = pgp.txMode;
const transactionMode = new TransactionMode({
tiLevel: isolationLevel.serializable,
});

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

async getUserById(id: number) {
return this.txHost.tx.one<UserRecord>(
'SELECT * FROM public.user WHERE id = $1',
[id],
);
}

async createUser(name: string) {
const created = await this.txHost.tx.one<UserRecord>(
'INSERT INTO public.user (name, email) VALUES ($1, $2) RETURNING *',
[name, `${name}@email.com`],
);
return created;
}
}

@Injectable()
class UserService {
constructor(
private readonly userRepository: UserRepository,
private readonly txHost: TransactionHost<TransactionalAdapterPgPromise>,
@Inject(PG_PROMISE)
private readonly db: Task,
) {}

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

@Transactional<TransactionalAdapterPgPromise>({ mode: transactionMode })
async transactionWithDecoratorWithOptions() {
const r1 = await this.userRepository.createUser('James');
const r2 = await this.db.oneOrNone<UserRecord>(
'SELECT * FROM public.user WHERE id = $1',
[r1.id],
);
const r3 = await this.userRepository.getUserById(r1.id);
return { r1, r2, r3 };
}

async transactionWithFunctionWrapper() {
return this.txHost.withTransaction(
{ mode: transactionMode },
async () => {
const r1 = await this.userRepository.createUser('Joe');
const r2 = await this.db.oneOrNone<UserRecord>(
'SELECT * FROM public.user WHERE id = $1',
[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: PG_PROMISE,
useValue: db,
},
],
exports: [PG_PROMISE],
})
class PgPromiseModule {}

@Module({
imports: [
PgPromiseModule,
ClsModule.forRoot({
plugins: [
new ClsPluginTransactional({
imports: [PgPromiseModule],
adapter: new TransactionalAdapterPgPromise({
dbInstanceToken: PG_PROMISE,
}),
}),
],
}),
],
providers: [UserService, UserRepository],
})
class AppModule {}

describe('Transactional', () => {
let module: TestingModule;
let callingService: UserService;

beforeAll(async () => {
execSync('docker-compose -f test/docker-compose.yml up -d --wait', {
stdio: 'inherit',
});
await db.query('DROP TABLE IF EXISTS public.user');
await db.query(`CREATE TABLE public.user (
id serial NOT NULL,
name varchar NOT NULL,
email varchar NOT NULL,
CONSTRAINT user_pk PRIMARY KEY (id)
);`);
});

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

afterAll(async () => {
pgp.end();
execSync('docker-compose -f test/docker-compose.yml down', {
stdio: 'inherit',
});
});

describe('TransactionalAdapterPgPromise', () => {
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 db.many<UserRecord>(
'SELECT * FROM public.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 db.many<UserRecord>(
'SELECT * FROM public.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 db.many<UserRecord>(
'SELECT * FROM public.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 db.many<UserRecord>(
'SELECT * FROM public.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"]
}
5 changes: 4 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
},
{
"path": "packages/transactional-adapters/transactional-adapter-prisma"
},
{
"path": "packages/transactional-adapters/transactional-adapter-pg-promise"
}
]
}
}
Loading

0 comments on commit 06bbb18

Please sign in to comment.