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

feat(core): allow disabling transactions #4260

Merged
merged 1 commit into from
Apr 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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`');
});