Skip to content

Commit

Permalink
feat(core): allow disabling transactions
Browse files Browse the repository at this point in the history
Is it now possible to disable transactions, either globally via `disableTransactions` config option, or locally when using `em.transactional()`.

```ts
// only the outer transaction will be opened
await orm.em.transactional(async em => {
  // but the inner calls to both em.transactional and em.begin will be no-op
  await em.transactional(...);
}, { disableTransactions: true });
```

Alternatively, you can disable transactions when creating new forks:

```ts
const em = await orm.em.fork({ disableTransactions: true });
await em.transactional(...); // no-op
await em.begin(...); // no-op
await em.commit(...); // commit still calls `flush`
```

Closes #3747
Closes #3992
  • Loading branch information
B4nan committed Apr 23, 2023
1 parent 0693029 commit 4636d92
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 4 deletions.
23 changes: 22 additions & 1 deletion docs/docs/transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ const res = await em.find(User, { name: 'Jon' }, {
// for update of "e0" skip locked
```

### Isolation levels
## Isolation levels

We can set the transaction isolation levels:

Expand All @@ -288,4 +288,25 @@ Available isolation levels:
- `IsolationLevel.REPEATABLE_READ`
- `IsolationLevel.SERIALIZABLE`

## Disabling transactions

Since v5.7 is it possible to disable transactions, either globally via `disableTransactions` config option, or locally when using `em.transactional()`.

```ts
// only the outer transaction will be opened
await orm.em.transactional(async em => {
// but the inner calls to both em.transactional and em.begin will be no-op
await em.transactional(...);
}, { disableTransactions: true });
```

Alternatively, you can disable transactions when creating new forks:

```ts
const em = await orm.em.fork({ disableTransactions: true });
await em.transactional(...); // no-op
await em.begin(...); // no-op
await em.commit(...); // commit still calls `flush`
```

> This part of documentation is highly inspired by [doctrine internals docs](https://www.doctrine-project.org/projects/doctrine-orm/en/latest/reference/transactions-and-concurrency.html) as the behaviour here is pretty much the same.
26 changes: 25 additions & 1 deletion packages/core/src/EntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
private filters: Dictionary<FilterDef> = {};
private filterParams: Dictionary<Dictionary> = {};
private transactionContext?: Transaction;
private disableTransactions = this.config.get('disableTransactions');
private flushMode?: FlushMode;

/**
Expand Down Expand Up @@ -824,10 +825,16 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
*/
async transactional<T>(cb: (em: D[typeof EntityManagerType]) => Promise<T>, options: TransactionOptions = {}): Promise<T> {
const em = this.getContext(false);

if (this.disableTransactions) {
return cb(em);
}

const fork = em.fork({
clear: false, // state will be merged once resolves
flushMode: options.flushMode,
cloneEventManager: true,
disableTransactions: options.ignoreNestedTransactions,
});
options.ctx ??= em.transactionContext;

Expand Down Expand Up @@ -858,7 +865,11 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
/**
* Starts new transaction bound to this EntityManager. Use `ctx` parameter to provide the parent when nesting transactions.
*/
async begin(options: TransactionOptions = {}): Promise<void> {
async begin(options: Omit<TransactionOptions, 'ignoreNestedTransactions'> = {}): Promise<void> {
if (this.disableTransactions) {
return;
}

const em = this.getContext(false);
em.transactionContext = await em.getConnection('write').begin({ ...options, eventBroadcaster: new TransactionEventBroadcaster(em) });
}
Expand All @@ -869,6 +880,11 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
async commit(): Promise<void> {
const em = this.getContext(false);

if (this.disableTransactions) {
await em.flush();
return;
}

if (!em.transactionContext) {
throw ValidationError.transactionRequired();
}
Expand All @@ -882,6 +898,10 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
* Rollbacks the transaction bound to this EntityManager.
*/
async rollback(): Promise<void> {
if (this.disableTransactions) {
return;
}

const em = this.getContext(false);

if (!em.transactionContext) {
Expand Down Expand Up @@ -1357,6 +1377,7 @@ export class EntityManager<D extends IDatabaseDriver = IDatabaseDriver> {
em.config.set('allowGlobalContext', true);
const fork = new (em.constructor as typeof EntityManager)(em.config, em.driver, em.metadata, options.useContext, eventManager);
fork.setFlushMode(options.flushMode ?? em.flushMode);
fork.disableTransactions = options.disableTransactions ?? this.disableTransactions ?? this.config.get('disableTransactions');
em.config.set('allowGlobalContext', allowGlobalContext);

fork.filters = { ...em.filters };
Expand Down Expand Up @@ -1677,5 +1698,8 @@ export interface ForkOptions {
cloneEventManager?: boolean;
/** use this flag to ignore current async context - this is required if we want to call `em.fork()` inside the `getContext` handler */
disableContextResolution?: boolean;
/** set flush mode for this fork, overrides the global option, can be overridden locally via FindOptions */
flushMode?: FlushMode;
/** disable transactions for this fork */
disableTransactions?: boolean;
}
1 change: 1 addition & 0 deletions packages/core/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ export interface TransactionOptions {
ctx?: Transaction;
isolationLevel?: IsolationLevel;
flushMode?: FlushMode;
ignoreNestedTransactions?: boolean;
}

export abstract class PlainObject {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/platforms/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export abstract class Platform {
}

supportsTransactions(): boolean {
return true;
return !this.config.get('disableTransactions');
}

usesImplicitTransactions(): boolean {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ export interface MikroORMOptions<D extends IDatabaseDriver = IDatabaseDriver> ex
driverOptions: Dictionary;
namingStrategy?: { new(): NamingStrategy };
implicitTransactions?: boolean;
disableTransactions?: boolean;
connect: boolean;
verbose: boolean;
autoJoinOneToOneOwner: boolean;
Expand Down
2 changes: 1 addition & 1 deletion packages/mongodb/src/MongoEntityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class MongoEntityManager<D extends MongoDriver = MongoDriver> extends Ent
/**
* @inheritDoc
*/
async begin(options: TransactionOptions & MongoTransactionOptions = {}): Promise<void> {
async begin(options: Omit<TransactionOptions, 'ignoreNestedTransactions'> & MongoTransactionOptions = {}): Promise<void> {
return super.begin(options);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { MikroORM } from '@mikro-orm/sqlite';
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { mockLogger } from '../../helpers';

@Entity()
export class Example {

@PrimaryKey()
id!: number;

@Property()
name!: string;

}

let orm: MikroORM;

beforeEach(async () => {
orm = await MikroORM.init({
entities: [Example],
dbName: ':memory:',
});
await orm.schema.refreshDatabase();
});

afterEach(async () => {
await orm.close(true);
});

test('should only commit once', async () => {
const mock = mockLogger(orm, ['query']);

await orm.em.transactional(async em => {
await em.transactional(async () => {
em.create(Example, { name: 'foo' });

await em.flush();

const count = await em.count(Example);

expect(count).toBe(1);
});

em.create(Example, { name: 'bar' });

await em.flush();

const count = await em.count(Example);

expect(count).toBe(2);
}, { ignoreNestedTransactions: true });

const count = await orm.em.count(Example);

expect(count).toBe(2);

expect(mock.mock.calls).toHaveLength(7);
expect(mock.mock.calls[0][0]).toMatch('begin');
expect(mock.mock.calls[1][0]).toMatch('insert into `example` (`name`) values (?) returning `id`');
expect(mock.mock.calls[2][0]).toMatch('select count(*) as `count` from `example` as `e0`');
expect(mock.mock.calls[3][0]).toMatch('insert into `example` (`name`) values (?) returning `id`');
expect(mock.mock.calls[4][0]).toMatch('select count(*) as `count` from `example` as `e0`');
expect(mock.mock.calls[5][0]).toMatch('commit');
expect(mock.mock.calls[6][0]).toMatch('select count(*) as `count` from `example` as `e0`');
});

test('should handle rollback in real transaction', async () => {
const mock = mockLogger(orm, ['query']);

const transaction = orm.em.transactional(async em => {
await em.transactional(async () => {
em.create(Example, { name: 'foo' });

await em.flush();

const count = await em.count(Example);

expect(count).toBe(1);
});

em.create(Example, { name: 'bar' });

await em.flush();

const count = await em.count(Example);

expect(count).toBe(2);

throw new Error('roll me back');
}, { ignoreNestedTransactions: true });

await expect(transaction).rejects.toThrowError('roll me back');

const count = await orm.em.count(Example);

expect(count).toBe(0);

expect(mock.mock.calls).toHaveLength(7);
expect(mock.mock.calls[0][0]).toMatch('begin');
expect(mock.mock.calls[1][0]).toMatch('insert into `example` (`name`) values (?) returning `id`');
expect(mock.mock.calls[2][0]).toMatch('select count(*) as `count` from `example` as `e0`');
expect(mock.mock.calls[3][0]).toMatch('insert into `example` (`name`) values (?) returning `id`');
expect(mock.mock.calls[4][0]).toMatch('select count(*) as `count` from `example` as `e0`');
expect(mock.mock.calls[5][0]).toMatch('rollback');
expect(mock.mock.calls[6][0]).toMatch('select count(*) as `count` from `example` as `e0`');
});

test('should handle rollback in no-op transaction', async () => {
const mock = mockLogger(orm, ['query']);

const transaction = orm.em.transactional(async em => {
await em.transactional(async () => {
em.create(Example, { name: 'foo' });

await em.flush();

const count = await em.count(Example);

expect(count).toBe(1);

throw new Error('roll me back');
});

throw new Error('should not get here');
}, { ignoreNestedTransactions: true });

await expect(transaction).rejects.toThrowError('roll me back');

const count = await orm.em.count(Example);

expect(count).toBe(0);

expect(mock.mock.calls).toHaveLength(5);
expect(mock.mock.calls[0][0]).toMatch('begin');
expect(mock.mock.calls[1][0]).toMatch('insert into `example` (`name`) values (?) returning `id`');
expect(mock.mock.calls[2][0]).toMatch('select count(*) as `count` from `example` as `e0`');
expect(mock.mock.calls[3][0]).toMatch('rollback');
expect(mock.mock.calls[4][0]).toMatch('select count(*) as `count` from `example` as `e0`');
});

0 comments on commit 4636d92

Please sign in to comment.