Skip to content

Commit

Permalink
feat(transactional-adapter-mongoose): add mongoose adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
Papooch committed Jun 27, 2024
1 parent f8c6584 commit 4108061
Show file tree
Hide file tree
Showing 9 changed files with 499 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @nestjs-cls/transactional-adapter-knex

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

### ➡️ [Go to the documentation website](https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/mongoose-adapter) 📖
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,73 @@
{
"name": "@nestjs-cls/transactional-adapter-mongoose",
"version": "1.0.0",
"description": "A mongoose adapter for @nestjs-cls/transactional",
"author": "papooch",
"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",
"transaction",
"transactional",
"transactional decorator",
"aop",
"mongoose"
],
"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",
"mongoose": "> 8",
"nestjs-cls": "workspace:^4.3.0"
},
"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",
"mongodb-memory-server": "^9.4.0",
"mongoose": "^8.4.4",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.5",
"sqlite3": "^5.1.7",
"ts-jest": "^29.1.2",
"ts-loader": "^9.3.0",
"ts-node": "^10.8.1",
"tsconfig-paths": "^4.0.0",
"typescript": "5.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './lib/transactional-adapter-mongoose';
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { TransactionalAdapter } from '@nestjs-cls/transactional';
import mongoose, { ClientSession, Connection } from 'mongoose';

type MongooseTransactionOptions = Parameters<Connection['transaction']>[1];

export interface MongoDBTransactionalAdapterOptions {
/**
* The injection token for the mongoose Connection instance.
*/
mongooseConnectionToken: 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<MongooseTransactionOptions>;

/**
* Only supported for `mongoose >= 8.4`
*
* Whether to automatically enable the
* [native AsyncLocalStorage integration](https://mongoosejs.com/docs/transactions.html#asynclocalstorage)
* for transactions. This will set the `transactionAsyncLocalStorage` option to `true` in Mongoose.
*
* If enabled, there is no need to pass the session (`tx`) of `TransactionHost` to queries.
* All queries executed within a `TransactionalHost#withTransaction` or the `@Transactional` decorator
* will be executed within the same transaction.
*/
enableNativeAsyncLocalStorage?: boolean;
}

export class TransactionalAdapterMongoose
implements
TransactionalAdapter<
Connection,
ClientSession | null,
MongooseTransactionOptions
>
{
connectionToken: any;

defaultTxOptions?: Partial<MongooseTransactionOptions>;

constructor(options: MongoDBTransactionalAdapterOptions) {
this.connectionToken = options.mongooseConnectionToken;
this.defaultTxOptions = options.defaultTxOptions;
if (options.enableNativeAsyncLocalStorage) {
mongoose.set('transactionAsyncLocalStorage', true);
}
}

optionsFactory(connection: Connection) {
return {
wrapWithTransaction: async (
options: MongooseTransactionOptions,
fn: (...args: any[]) => Promise<any>,
setTx: (tx?: ClientSession) => void,
) => {
return connection.transaction((session) => {
setTx(session);
return fn();
}, options);
},
getFallbackInstance: () => null,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
ClsPluginTransactional,
Transactional,
TransactionHost,
} from '@nestjs-cls/transactional';
import { Injectable, Module } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { ObjectId, WriteConcern } from 'mongodb';
import { MongoMemoryReplSet } from 'mongodb-memory-server';
import mongoose, { Connection, Schema } from 'mongoose';
import { ClsModule, UseCls } from 'nestjs-cls';
import { TransactionalAdapterMongoose } from '../src';

const MONGOOSE_CONNECTION = 'MONGOOSE_CONNECTION';

const userSchema = new Schema({
name: String,
email: String,
});

const User = mongoose.model('user', userSchema);

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

async getUserById(id: ObjectId) {
const user = await User.findById(id).session(this.txHost.tx).lean();
return user;
}

async createUser(name: string) {
const user = new User({ name: name, email: `${name}@email.com` });
await user.save({ session: this.txHost.tx });
return user.toObject();
}
}

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

@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<TransactionalAdapterMongoose>({
writeConcern: new WriteConcern('majority'),
})
async transactionWithDecoratorWithOptions() {
const r1 = await this.userRepository.createUser('James');
const r2 = await User.findOne({ _id: r1._id }).lean();
const r3 = await this.userRepository.getUserById(r1!._id);
return { r1, r2, r3 };
}

async transactionWithFunctionWrapper() {
return this.txHost.withTransaction(
{
writeConcern: new WriteConcern('majority'),
},
async () => {
const r1 = await this.userRepository.createUser('Joe');
const r2 = await User.findOne({ _id: r1!._id }).lean();
const r3 = await this.userRepository.getUserById(r1!._id);
return { r1, r2, r3 };
},
);
}

@Transactional()
async transactionWithDecoratorError() {
await this.userRepository.createUser('Nobody');
throw new Error('Rollback');
}
}

const replSet = new MongoMemoryReplSet({
replSet: { count: 2, dbName: 'default' },
});

@Module({
providers: [
{
provide: MONGOOSE_CONNECTION,
useFactory: async () => {
const mongo = await mongoose.connect(replSet.getUri());
return mongo.connection;
},
},
],
exports: [MONGOOSE_CONNECTION],
})
class MongooseModule {}

@Module({
imports: [
MongooseModule,
ClsModule.forRoot({
plugins: [
new ClsPluginTransactional({
imports: [MongooseModule],
adapter: new TransactionalAdapterMongoose({
mongooseConnectionToken: MONGOOSE_CONNECTION,
}),
enableTransactionProxy: true,
}),
],
}),
],
providers: [UserService, UserRepository],
})
class AppModule {}

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

beforeAll(async () => {
await replSet.start();
});

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

afterEach(async () => {
await User.deleteMany();
});

afterAll(async () => {
await mongo.destroy();
await replSet.stop({ force: true });
});

describe('TransactionalAdapterKnex', () => {
it('should work without an active transaction', async () => {
const { r1, r2 } = await callingService.withoutTransaction();
expect(r1).toEqual(r2);
const users = await User.find().lean();
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 users = await User.find().lean();
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 User.find().lean();
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 User.find().lean();
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 User.find().lean();
expect(users).toEqual(
expect.not.arrayContaining([{ name: 'Nobody' }]),
);
});
});
});

describe('Default options', () => {
it('Should correctly set default options on the adapter instance', async () => {
const adapter = new TransactionalAdapterMongoose({
mongooseConnectionToken: MONGOOSE_CONNECTION,
defaultTxOptions: {
readConcern: { level: 'snapshot' },
},
});

expect(adapter.defaultTxOptions).toEqual({
readConcern: { level: 'snapshot' },
});
});
});
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

0 comments on commit 4108061

Please sign in to comment.