Skip to content

Commit 839df61

Browse files
authored
feat: add support for multiple transactional adapters (#114)
* fix: rework how plugins are registered (internals) Previously all plugins' providers were mixed into one module, now each plugin gets its own module. * feat: add multiple transactional adapters support * Add tests for multiple named connections * Add docs for multiple connections
1 parent 1408009 commit 839df61

File tree

11 files changed

+670
-77
lines changed

11 files changed

+670
-77
lines changed

docs/docs/06_plugins/01_available-plugins/01-transactional/index.md

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,76 @@ The `@Transactional` decorator can be used to wrap a method call in the `withTra
305305
- **_`@Transactional`_**`(options)`
306306
- **_`@Transactional`_**`(propagation, options)`
307307

308-
## Considerations
308+
Or when using named connections:
309309

310-
Please note that at this time, the `@nestjs-cls/transactional` plugin only supports a _single_ database connection per application. This means that if you have multiple databases, you can only use one of them with the transactional plugin.
310+
- **_`@Transactional`_**`(connectionName, propagation?, options?)`
311311

312-
This is a subject to change in the future, as there are plans to support multiple `TransactionHost` instances, each with their own adapter and a database connection.
312+
## Multiple databases
313+
314+
Similar to other `@nestjs/<orm>` libraries, the `@nestjs-cls/transactional` plugin can be used to manage transactions for multiple database connections, or even multiple database libraries altogether.
315+
316+
### Registration
317+
318+
To use multiple connections, register multiple instances of the `ClsPluginTransactional`, each with an unique `connectionName`:
319+
320+
```ts
321+
ClsModule.forRoot({
322+
plugins: [
323+
new ClsPluginTransactional({
324+
// highlight-start
325+
connectionName: 'prisma-connection',
326+
// highlight-end
327+
imports: [PrismaModule],
328+
adapter: new TransactionalAdapterPrisma({
329+
prismaInjectionToken: PrismaClient,
330+
}),
331+
}),
332+
new ClsPluginTransactional({
333+
// highlight-start
334+
connectionName: 'knex-connection',
335+
// highlight-end
336+
imports: [KnexModule],
337+
adapter: new TransactionalAdapterKnex({
338+
knexInstanceToken: KNEX,
339+
}),
340+
}),
341+
],
342+
}),
343+
```
344+
345+
This works for any number of connections and any number of database libraries.
346+
347+
### Usage
348+
349+
To use the `TransactionHost` for a specific connection, you _need to_ use `@InjectTransactionHost('connectionName')` decorator to inject the `TransactionHost`. Otherwise Nest will try to inject the default unnamed instance which will result in an injection error.
350+
351+
```ts
352+
@Injectable()
353+
class UserService {
354+
constructor(
355+
// highlight-start
356+
@InjectTransactionHost('prisma-connection')
357+
private readonly // highlight-end
358+
private readonly txHost: TransactionHost<TransactionalAdapterPrisma>,
359+
) {}
360+
361+
// ...
362+
}
363+
364+
```
365+
366+
:::note
367+
368+
`@InjectTransactionHost('connectionName')` is a short for `@Inject(getTransactionHostToken('connectionName'))`. The `getTransactionHostToken` function is useful for when you need to mock the `TransactionHost` in unit tests.
369+
370+
:::
371+
372+
In a similar fashion, using the `@Transactional` decorator requires the `connectionName` to be passed as the first argument.
373+
374+
```ts
375+
@Transactional('prisma-connection')
376+
async createUser(name: string): Promise<User> {
377+
await this.accountService.createAccountForUser(user.id);
378+
return user;
379+
}
380+
```

packages/core/src/lib/cls.module.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
ClsModuleOptions,
3737
} from './cls.options';
3838
import { ClsService } from './cls.service';
39-
import { ClsPluginModule } from './plugin/cls-plugin.module';
39+
import { ClsPluginManager } from './plugin/cls-plugin-manager';
40+
4041
import { ProxyProviderManager } from './proxy-provider/proxy-provider-manager';
4142
import { ClsModuleProxyProviderOptions } from './proxy-provider/proxy-provider.interfaces';
4243

@@ -103,7 +104,7 @@ export class ClsModule implements NestModule {
103104

104105
return {
105106
module: ClsModule,
106-
imports: [ClsPluginModule.forRoot(options.plugins)],
107+
imports: ClsPluginManager.registerPlugins(options.plugins),
107108
providers: [
108109
{
109110
provide: CLS_MODULE_OPTIONS,
@@ -132,7 +133,7 @@ export class ClsModule implements NestModule {
132133
module: ClsModule,
133134
imports: [
134135
...(asyncOptions.imports ?? []),
135-
ClsPluginModule.forRoot(asyncOptions.plugins),
136+
...ClsPluginManager.registerPlugins(asyncOptions.plugins),
136137
],
137138
providers: [
138139
{
Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,23 @@
11
import { globalClsService } from '../cls-service.globals';
22
import { ClsPlugin } from './cls-plugin.interface';
3+
import { createClsPluginModule } from './cls-plugin.module';
34

45
export class ClsPluginManager {
56
private static clsService = globalClsService;
67
private static plugins: ClsPlugin[] = [];
78

8-
static add(plugins: ClsPlugin[] = []) {
9+
static registerPlugins(plugins: ClsPlugin[] = []) {
910
this.plugins.push(...plugins);
11+
return plugins.map((plugin) => createClsPluginModule(plugin));
1012
}
1113

1214
static getPlugins() {
1315
return this.plugins;
1416
}
1517

16-
static getPluginImports() {
17-
return this.plugins.flatMap((plugin) => plugin.imports ?? []);
18-
}
19-
20-
static getPluginProviders() {
21-
return this.plugins.flatMap((plugin) => plugin.providers ?? []);
22-
}
23-
2418
static async onClsInit() {
2519
for (const plugin of this.plugins) {
2620
await plugin.onClsInit?.(this.clsService);
2721
}
2822
}
29-
30-
static async onModuleInit() {
31-
for (const plugin of this.plugins) {
32-
await plugin.onModuleInit?.();
33-
}
34-
}
35-
36-
static async onModuleDestroy() {
37-
for (const plugin of this.plugins) {
38-
await plugin.onModuleDestroy?.();
39-
}
40-
}
4123
}
Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,26 @@
11
import { Global } from '@nestjs/common';
2-
import { ClsPluginManager } from './cls-plugin-manager';
32
import { ClsPlugin } from './cls-plugin.interface';
43

5-
@Global()
6-
export class ClsPluginModule {
7-
static forRoot(plugins: ClsPlugin[] = []) {
8-
ClsPluginManager.add(plugins);
9-
const imports = ClsPluginManager.getPluginImports();
10-
const providers = ClsPluginManager.getPluginProviders();
4+
export function createClsPluginModule(plugin: ClsPlugin) {
5+
@Global()
6+
class ClsPluginModule {
7+
static forRoot() {
8+
return {
9+
module: ClsPluginModule,
10+
imports: plugin.imports,
11+
providers: plugin.providers,
12+
exports: plugin.exports,
13+
};
14+
}
1115

12-
return {
13-
module: ClsPluginModule,
14-
imports: imports,
15-
providers: providers,
16-
exports: providers,
17-
};
18-
}
16+
async onModuleInit() {
17+
await plugin.onModuleInit?.();
18+
}
1919

20-
async onModuleInit() {
21-
await ClsPluginManager.onModuleInit();
20+
async onModuleDestroy() {
21+
await plugin.onModuleDestroy?.();
22+
}
2223
}
2324

24-
async onModuleDestroy() {
25-
await ClsPluginManager.onModuleDestroy();
26-
}
25+
return ClsPluginModule.forRoot();
2726
}

packages/transactional/src/lib/interfaces.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export interface TransactionalAdapterOptions<TTx, TOptions> {
77
getFallbackInstance: () => TTx;
88
}
99

10+
export interface TransactionalAdapterOptionsWithName<TTx, TOptions>
11+
extends TransactionalAdapterOptions<TTx, TOptions> {
12+
connectionName: string;
13+
}
14+
1015
export type TransactionalOptionsAdapterFactory<TConnection, TTx, TOptions> = (
1116
connection: TConnection,
1217
) => TransactionalAdapterOptions<TTx, TOptions>;
@@ -32,8 +37,18 @@ export interface TransactionalAdapter<TConnection, TTx, TOptions> {
3237
}
3338

3439
export interface TransactionalPluginOptions<TConnection, TTx, TOptions> {
40+
/**
41+
* An instance of the transactional adapter.
42+
*/
3543
adapter: TransactionalAdapter<TConnection, TTx, TOptions>;
44+
/**
45+
* An array of modules that export providers required by the adapter.
46+
*/
3647
imports?: any[];
48+
/**
49+
* An optional name of the connection. Useful when there are multiple TransactionalPlugins registered in the app.
50+
*/
51+
connectionName?: string;
3752
}
3853

3954
export type TTxFromAdapter<TAdapter> = TAdapter extends TransactionalAdapter<

packages/transactional/src/lib/plugin-transactional.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,44 @@ import {
55
TRANSACTIONAL_ADAPTER_OPTIONS,
66
TRANSACTION_CONNECTION,
77
} from './symbols';
8-
import { TransactionHost } from './transaction-host';
8+
import { getTransactionHostToken, TransactionHost } from './transaction-host';
99

1010
export class ClsPluginTransactional implements ClsPlugin {
11-
name: 'cls-plugin-transactional';
11+
name: string;
1212
providers: Provider[];
1313
imports?: any[];
14+
exports?: any[];
1415

1516
constructor(options: TransactionalPluginOptions<any, any, any>) {
17+
this.name = options.connectionName
18+
? `cls-plugin-transactional-${options.connectionName}`
19+
: 'cls-plugin-transactional';
1620
this.imports = options.imports;
21+
const transactionHostToken = getTransactionHostToken(
22+
options.connectionName,
23+
);
1724
this.providers = [
18-
TransactionHost,
1925
{
2026
provide: TRANSACTION_CONNECTION,
2127
useExisting: options.adapter.connectionToken,
2228
},
2329
{
2430
provide: TRANSACTIONAL_ADAPTER_OPTIONS,
2531
inject: [TRANSACTION_CONNECTION],
26-
useFactory: options.adapter.optionsFactory,
32+
useFactory: (connection: any) => {
33+
const adapterOptions =
34+
options.adapter.optionsFactory(connection);
35+
return {
36+
...adapterOptions,
37+
connectionName: options.connectionName,
38+
};
39+
},
40+
},
41+
{
42+
provide: transactionHostToken,
43+
useClass: TransactionHost,
2744
},
2845
];
46+
this.exports = [transactionHostToken];
2947
}
3048
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
export const TRANSACTION_CONNECTION = Symbol('TRANSACTION_CONNECTION');
2-
export const TRANSACTIONAL_INSTANCE = Symbol('TRANSACTIONAL_CLIENT');
32
export const TRANSACTIONAL_ADAPTER_OPTIONS = Symbol('TRANSACTIONAL_OPTIONS');
3+
4+
const TRANSACTIONAL_INSTANCE = Symbol('TRANSACTIONAL_CLIENT');
5+
6+
export const getTransactionalInstanceSymbol = (connectionName?: string) =>
7+
connectionName
8+
? Symbol.for(`${TRANSACTIONAL_INSTANCE.toString()}_${connectionName}`)
9+
: TRANSACTIONAL_INSTANCE;

packages/transactional/src/lib/transaction-host.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Inject, Injectable, Logger } from '@nestjs/common';
22
import { ClsServiceManager } from 'nestjs-cls';
33
import {
4-
TTxFromAdapter,
54
TOptionsFromAdapter,
6-
TransactionalAdapterOptions,
5+
TransactionalAdapterOptionsWithName,
6+
TTxFromAdapter,
77
} from './interfaces';
88
import {
99
Propagation,
@@ -12,22 +12,27 @@ import {
1212
TransactionPropagationError,
1313
} from './propagation';
1414
import {
15+
getTransactionalInstanceSymbol,
1516
TRANSACTIONAL_ADAPTER_OPTIONS,
16-
TRANSACTIONAL_INSTANCE,
1717
} from './symbols';
1818

1919
@Injectable()
2020
export class TransactionHost<TAdapter = never> {
2121
private readonly cls = ClsServiceManager.getClsService();
2222
private readonly logger = new Logger(TransactionHost.name);
23+
private readonly transactionalInstanceSymbol: symbol;
2324

2425
constructor(
2526
@Inject(TRANSACTIONAL_ADAPTER_OPTIONS)
26-
private readonly _options: TransactionalAdapterOptions<
27+
private readonly _options: TransactionalAdapterOptionsWithName<
2728
TTxFromAdapter<TAdapter>,
2829
TOptionsFromAdapter<TAdapter>
2930
>,
30-
) {}
31+
) {
32+
this.transactionalInstanceSymbol = getTransactionalInstanceSymbol(
33+
this._options.connectionName,
34+
);
35+
}
3136

3237
/**
3338
* The instance of the transaction object.
@@ -42,7 +47,7 @@ export class TransactionHost<TAdapter = never> {
4247
return this._options.getFallbackInstance();
4348
}
4449
return (
45-
this.cls.get(TRANSACTIONAL_INSTANCE) ??
50+
this.cls.get(this.transactionalInstanceSymbol) ??
4651
this._options.getFallbackInstance()
4752
);
4853
}
@@ -211,14 +216,33 @@ export class TransactionHost<TAdapter = never> {
211216
if (!this.cls.isActive()) {
212217
return false;
213218
}
214-
return !!this.cls.get(TRANSACTIONAL_INSTANCE);
219+
return !!this.cls.get(this.transactionalInstanceSymbol);
215220
}
216221

217222
private setTxInstance(txInstance?: TTxFromAdapter<TAdapter>) {
218-
this.cls.set(TRANSACTIONAL_INSTANCE, txInstance);
223+
this.cls.set(this.transactionalInstanceSymbol, txInstance);
219224
}
220225
}
221226

222227
function isNotEmpty(obj: any) {
223228
return obj && Object.keys(obj).length > 0;
224229
}
230+
231+
/**
232+
* Get the injection token for a TransactionHost for a named connection.
233+
* If name is omitted, the default instance is used.
234+
*/
235+
export function getTransactionHostToken(connectionName?: string) {
236+
return connectionName
237+
? Symbol.for(`${TransactionHost.name}_${connectionName}`)
238+
: TransactionHost;
239+
}
240+
241+
/**
242+
* Inject a TransactionHost for a named connection. Only needed if you want to inject a named instance.
243+
*
244+
* A shorthand for `Inject(getTransactionHostToken(connectionName))`
245+
*/
246+
export function InjectTransactionHost(connectionName?: string) {
247+
return Inject(getTransactionHostToken(connectionName));
248+
}

0 commit comments

Comments
 (0)