diff --git a/acceptance/repository-mongodb/src/__tests__/mongodb.datasource.ts b/acceptance/repository-mongodb/src/__tests__/mongodb.datasource.ts index 1f872af2a0d5..4a0866757122 100644 --- a/acceptance/repository-mongodb/src/__tests__/mongodb.datasource.ts +++ b/acceptance/repository-mongodb/src/__tests__/mongodb.datasource.ts @@ -16,4 +16,5 @@ export const MONGODB_CONFIG: DataSourceOptions = { export const MONGODB_FEATURES: Partial = { idType: 'string', + supportsTransactions: false, }; diff --git a/acceptance/repository-mysql/src/__tests__/mysql-default-repository.acceptance.ts b/acceptance/repository-mysql/src/__tests__/mysql-default-repository.acceptance.ts index 7a8ae693776a..81e200a42f65 100644 --- a/acceptance/repository-mysql/src/__tests__/mysql-default-repository.acceptance.ts +++ b/acceptance/repository-mysql/src/__tests__/mysql-default-repository.acceptance.ts @@ -3,18 +3,18 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {DefaultCrudRepository} from '@loopback/repository'; +import {DefaultTransactionalRepository} from '@loopback/repository'; import { CrudRepositoryCtor, crudRepositoryTestSuite, } from '@loopback/repository-tests'; import {MYSQL_CONFIG, MYSQL_FEATURES} from './mysql.datasource'; -describe('MySQL + DefaultCrudRepository', () => { +describe('MySQL + DefaultTransactionalRepository', () => { crudRepositoryTestSuite( MYSQL_CONFIG, // Workaround for https://github.com/microsoft/TypeScript/issues/31840 - DefaultCrudRepository as CrudRepositoryCtor, + DefaultTransactionalRepository as CrudRepositoryCtor, MYSQL_FEATURES, ); }); diff --git a/acceptance/repository-mysql/src/__tests__/mysql.datasource.ts b/acceptance/repository-mysql/src/__tests__/mysql.datasource.ts index d7109e6c9db6..015b738159a0 100644 --- a/acceptance/repository-mysql/src/__tests__/mysql.datasource.ts +++ b/acceptance/repository-mysql/src/__tests__/mysql.datasource.ts @@ -21,4 +21,5 @@ export const MYSQL_FEATURES: Partial = { idType: 'number', freeFormProperties: false, emptyValue: null, + supportsTransactions: true, }; diff --git a/docs/site/Transactions.md b/docs/site/Transactions.md new file mode 100644 index 000000000000..e17b2584cc30 --- /dev/null +++ b/docs/site/Transactions.md @@ -0,0 +1,200 @@ +--- +title: 'Using database transactions' +lang: en +layout: page +keywords: LoopBack 4.0, LoopBack 4, Transactions, Transaction +tags: +sidebar: lb4_sidebar +permalink: /doc/en/lb4/Using-database-transactions.html +summary: Transactional API usage in LoopBack 4 +--- + +## Overview + +A *transaction* is a sequence of data operations performed as a single logical +unit of work. Many relational databases support transactions to help enforce +data consistency and business logic requirements. + +A repository can perform operations in a transaction when the backing datasource +is attached to one of the following connectors: + +- [MySQL connector](MySQL-connector.html) (IMPORTANT: Only with InnoDB as the + storage engine). +- [PostgreSQL connector](PostgreSQL-connector.html) +- [SQL Server connector](SQL-Server-connector.html) +- [Oracle connector](Oracle-connector.html) +- [DB2 Connector](DB2-connector.html) +- [DashDB Connector](DashDB.html) +- [DB2 iSeries Connector](DB2-iSeries-connector.html) +- [DB2 for z/OS connector](DB2-for-z-OS.html) +- [Informix connector](Informix.html) + +The repository class needs to extend from `TransactionalRepository` repository +interface which exposes the `beginTransaction()` method. Note that LoopBack only +supports database local transactions - only operations against the same +transaction-capable datasource can be grouped into a transaction. + +## Transaction APIs + +The `@loopback/repository` package includes `TransactionalRepository` interface +based on `EntityCrudRepository` interface. The `TransactionalRepository` +interface adds a `beginTransaction()` API that, for connectors that allow it, +will start a new Transaction. The `beginTransaction()` function gives access to +the lower-level transaction API, leaving it up to the user to create and manage +transaction objects, commit them on success or roll them back at the end of all +intended operations. See [Handling Transactions](#handling-transactions) below +for more details. + +## Handling Transactions + +See +the [API reference](https://apidocs.strongloop.com/loopback-datasource-juggler/#datasource-prototype-begintransaction) for +full transaction lower-level API documentation. + +Performing operations in a transaction typically involves the following steps: + +- Start a new transaction. +- Perform create, read, update, and delete operations in the transaction. +- Commit or rollback the transaction. + +### Start transaction + +Use the `beginTransaction()` method to start a new transaction from a repository +class using `DefaultTransactionalRepository` as a base class. + +Here is an example: + +```ts +import { + Transaction, + DefaultTransactionalRepository, + IsolationLevel, +} from '@loopback/repository'; +// assuming there is a Note model extending Entity class, and +// ds datasource which is backed by a transaction enabled +// connector +const repo = new DefaultTransactionalRepository(Note, ds); +// Now we have a transaction (tx) +const tx = await repo.beginTransaction(IsolationLevel.READ_COMMITTED); +``` + +You can also extend `DefaultTransactionalRepository` for custom classes: + +```ts +import {inject} from '@loopback/core'; +import { + juggler, + Transaction, + DefaultTransactionalRepository, + IsolationLevel, +} from '@loopback/repository'; +import {Note, NoteRelations} from '../models'; + +export class NoteRepository extends DefaultTransactionalRepository< + Note, + typeof Note.prototype.id, + NoteRelations +> { + constructor(@inject('datasources.ds') ds: juggler.DataSource) { + super(Note, ds); + } +} +``` + +#### Isolation levels + +When you call `beginTransaction()`, you can optionally specify a transaction +isolation level. LoopBack transactions support the following isolation levels: + +- `Transaction.READ_UNCOMMITTED` +- `Transaction.READ_COMMITTED` (default) +- `Transaction.REPEATABLE_READ` +- `Transaction.SERIALIZABLE` + +If you don't specify an isolation level, the transaction uses READ_COMMITTED . + +{% include important.html content=" + +**Oracle only supports READ_COMMITTED and SERIALIZABLE.** + +" %} + +For more information about database-specific isolation levels, see: + +- [MySQL SET TRANSACTION Syntax](https://dev.mysql.com/doc/refman/5.7/en/set-transaction.html) +- [Oracle Isolation Levels](http://docs.oracle.com/cd/B14117_01/server.101/b10743/consist.htm#i17856) +- [PostgreSQL Transaction Isolation](http://www.postgresql.org/docs/9.4/static/transaction-iso.html) +- [SQL Server SET TRANSACTION ISOLATION LEVEL](https://msdn.microsoft.com/en-us/library/ms173763.aspx) + +### Perform operations in a transaction + +To perform create, retrieve, update, and delete operations in the transaction, +add the transaction object to the `Options` parameter of the standard  +[`create()`](https://loopback.io/doc/en/lb4/apidocs.repository.defaultcrudrepository.create.html), +[`update()`](https://loopback.io/doc/en/lb4/apidocs.repository.defaultcrudrepository.update.html), +[`deleteAll()`](https://loopback.io/doc/en/lb4/apidocs.repository.defaultcrudrepository.deleteall.html) +(and so on) methods. + +For example, again assuming a `Note` model, `repo` transactional repository, and +transaction object `tx` created as demonstrated in +[Start transaction](#start-transaction) section: + +```ts +const created = await repo.create({title: 'Groceries'}, {transaction: tx}); +const updated = await repo.update( + {title: 'Errands', id: created.id}, + {transaction: tx}, +); + +// commit the transaction to persist the changes +await tx.commit(); +``` + +Propagating a transaction is explicit by passing the transaction object via the +options argument for all create, retrieve, update, and delete and relation +methods. + +### Commit or rollback + +Transactions allow you either to commit the transaction and persist the CRUD +behaviour onto the database or rollback the changes. The two methods available +on transaction objects are as follows: + +```ts + /** + * Commit the transaction + */ + commit(): Promise; + + /** + * Rollback the transaction + */ + rollback(): Promise; +``` + +## Set up timeout + +You can specify a timeout (in milliseconds) to begin a transaction. If a +transaction is not finished (committed or rolled back) before the timeout, it +will be automatically rolled back upon timeout by default. + +For example, again assuming a `Note` model and `repo` transactional repository, +the `timeout` can be specified as part of the `Options` object passed into the +`beginTransaction` method. + +```ts +const tx: Transaction = await repo.beginTransaction({ + isolationLevel: IsolationLevel.READ_COMMITTED, + timeout: 30000, // 30000ms = 30s +}); +``` + +## Avoid long waits or deadlocks + +Please be aware that a transaction with certain isolation level will lock +database objects. Performing multiple methods within a transaction +asynchronously has the great potential to block other transactions (explicit or +implicit). To avoid long waits or even deadlocks, you should: + +1. Keep the transaction as short-lived as possible +2. Don't serialize execution of methods across multiple transactions diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index 212383d32946..43c9559dbfdf 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -93,6 +93,11 @@ children: - title: 'Repositories' url: Repositories.html output: 'web, pdf' + children: + + - title: 'Database Transactions' + url: Using-database-transactions.html + output: 'web, pdf' - title: 'Relations' url: Relations.html diff --git a/packages/repository-tests/src/__tests__/acceptance/default-repository.memory.acceptance.ts b/packages/repository-tests/src/__tests__/acceptance/default-repository.memory.acceptance.ts index b2738d1e5ea6..6f538e789326 100644 --- a/packages/repository-tests/src/__tests__/acceptance/default-repository.memory.acceptance.ts +++ b/packages/repository-tests/src/__tests__/acceptance/default-repository.memory.acceptance.ts @@ -16,6 +16,7 @@ describe('DefaultCrudRepository + memory connector', () => { DefaultCrudRepository as CrudRepositoryCtor, { idType: 'number', + supportsTransactions: false, }, ); }); diff --git a/packages/repository-tests/src/crud-test-suite.ts b/packages/repository-tests/src/crud-test-suite.ts index 50f7a88fa87f..57ff976c234c 100644 --- a/packages/repository-tests/src/crud-test-suite.ts +++ b/packages/repository-tests/src/crud-test-suite.ts @@ -32,6 +32,7 @@ export function crudRepositoryTestSuite( idType: 'string', freeFormProperties: true, emptyValue: undefined, + supportsTransactions: true, ...partialFeatures, }; diff --git a/packages/repository-tests/src/crud/transactions.suite.ts b/packages/repository-tests/src/crud/transactions.suite.ts new file mode 100644 index 000000000000..73bc79dd1f24 --- /dev/null +++ b/packages/repository-tests/src/crud/transactions.suite.ts @@ -0,0 +1,176 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + Entity, + IsolationLevel, + juggler, + model, + property, + Transaction, + TransactionalEntityRepository, +} from '@loopback/repository'; +import {expect, skipIf, toJSON} from '@loopback/testlab'; +import {Suite} from 'mocha'; +import {withCrudCtx} from '../helpers.repository-tests'; +import { + CrudFeatures, + CrudTestContext, + DataSourceOptions, + TransactionalRepositoryCtor, +} from '../types.repository-tests'; + +// Core scenarios for testing CRUD functionalities of Transactional connectors +// Please keep this file short, put any advanced scenarios to other files +export function transactionSuite( + dataSourceOptions: DataSourceOptions, + repositoryClass: TransactionalRepositoryCtor, + connectorFeatures: CrudFeatures, +) { + skipIf<[(this: Suite) => void], void>( + !connectorFeatures.supportsTransactions, + describe, + `transactions`, + () => { + @model() + class Product extends Entity { + @property({ + type: connectorFeatures.idType, + id: true, + generated: true, + description: 'The unique identifier for a product', + }) + id: number | string; + + @property({type: 'string', required: true}) + name: string; + + constructor(data?: Partial) { + super(data); + } + } + + describe('create-retrieve with transactions', () => { + let repo: TransactionalEntityRepository< + Product, + typeof Product.prototype.id + >; + let tx: Transaction | undefined; + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + repo = new repositoryClass(Product, ctx.dataSource); + await ctx.dataSource.automigrate(Product.name); + }), + ); + beforeEach(() => { + tx = undefined; + }); + afterEach(async () => { + // FIXME: replace tx.connection with tx.isActive when it become + // available + // see https://github.com/strongloop/loopback-next/issues/3471 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (tx !== undefined && (tx as any).connection) { + await tx.rollback(); + } + }); + + it('retrieves model instance once transaction is committed', async () => { + tx = await repo.beginTransaction(IsolationLevel.READ_COMMITTED); + const created = await repo.create( + {name: 'Pencil'}, + {transaction: tx}, + ); + expect(created.toObject()).to.have.properties('id', 'name'); + expect(created.id).to.be.ok(); + + await tx.commit(); + const foundOutsideTransaction = await repo.findById(created.id, {}); + expect(toJSON(created)).to.deepEqual(toJSON(foundOutsideTransaction)); + }); + + it('can rollback transaction', async () => { + tx = await repo.beginTransaction(IsolationLevel.READ_COMMITTED); + + const created = await repo.create( + {name: 'Pencil'}, + {transaction: tx}, + ); + expect(created.toObject()).to.have.properties('id', 'name'); + expect(created.id).to.be.ok(); + + const foundInsideTransaction = await repo.findById( + created.id, + {}, + { + transaction: tx, + }, + ); + expect(toJSON(created)).to.deepEqual(toJSON(foundInsideTransaction)); + await tx.rollback(); + await expect(repo.findById(created.id, {})).to.be.rejectedWith({ + code: 'ENTITY_NOT_FOUND', + }); + }); + + it('ensures transactions are isolated', async () => { + tx = await repo.beginTransaction(IsolationLevel.READ_COMMITTED); + const created = await repo.create( + {name: 'Pencil'}, + {transaction: tx}, + ); + expect(created.toObject()).to.have.properties('id', 'name'); + expect(created.id).to.be.ok(); + + const foundInsideTransaction = await repo.findById( + created.id, + {}, + { + transaction: tx, + }, + ); + expect(toJSON(created)).to.deepEqual(toJSON(foundInsideTransaction)); + await expect(repo.findById(created.id, {})).to.be.rejectedWith({ + code: 'ENTITY_NOT_FOUND', + }); + }); + + it('should not use transaction with another repository', async () => { + const ds2Options = Object.assign({}, dataSourceOptions); + ds2Options.name = 'anotherDataSource'; + ds2Options.database = ds2Options.database + '-new'; + const ds2 = new juggler.DataSource(ds2Options); + const anotherRepo = new repositoryClass(Product, ds2); + await ds2.automigrate(Product.name); + + tx = await repo.beginTransaction(IsolationLevel.READ_COMMITTED); + + // we should reject this call with a clear error message + // stating that transaction doesn't belong to the repository + // and that only local transactions are supported + // expect( + // await anotherRepo.create({name: 'Pencil'}, {transaction: tx}), + // ).to.throw(/some error/); + // see https://github.com/strongloop/loopback-next/issues/3483 + const created = await anotherRepo.create( + {name: 'Pencil'}, + {transaction: tx}, + ); + expect(created.toObject()).to.have.properties('id', 'name'); + expect(created.id).to.be.ok(); + + // for now, LB ignores the transaction so the instance + // should be created regardless + await tx.rollback(); + const foundOutsideTransaction = await anotherRepo.findById( + created.id, + {}, + ); + expect(toJSON(created)).to.deepEqual(toJSON(foundOutsideTransaction)); + }); + }); + }, + ); +} diff --git a/packages/repository-tests/src/types.repository-tests.ts b/packages/repository-tests/src/types.repository-tests.ts index 2f73fb5cc720..67c59aec15de 100644 --- a/packages/repository-tests/src/types.repository-tests.ts +++ b/packages/repository-tests/src/types.repository-tests.ts @@ -8,6 +8,7 @@ import { EntityCrudRepository, juggler, Options, + TransactionalEntityRepository, } from '@loopback/repository'; /** @@ -46,6 +47,14 @@ export interface CrudFeatures { * Default: `undefined` */ emptyValue: undefined | null; + /** + * Does the connector support using transactions for performing CRUD + * operations atomically and being able to commit or rollback the changes? + * SQL databases usually support transactions + * + * Default: `false` + */ + supportsTransactions: boolean; } /** @@ -61,6 +70,19 @@ export type CrudRepositoryCtor = new < dataSource: juggler.DataSource, ) => EntityCrudRepository; +/** + * A constructor of a class implementing TransactionalRepository interface, + * accepting the Entity class (constructor) and a dataSource instance. + */ +export type TransactionalRepositoryCtor = new < + T extends Entity, + ID, + Relations extends object +>( + entityClass: typeof Entity & {prototype: T}, + dataSource: juggler.DataSource, +) => TransactionalEntityRepository; + /** * Additional properties added to Mocha TestContext/SuiteContext. * @internal diff --git a/packages/repository/src/__tests__/unit/crud-connector.stub.ts b/packages/repository/src/__tests__/unit/crud-connector.stub.ts new file mode 100644 index 000000000000..25dd614c3e30 --- /dev/null +++ b/packages/repository/src/__tests__/unit/crud-connector.stub.ts @@ -0,0 +1,92 @@ +import {Callback} from 'loopback-datasource-juggler'; +import { + AnyObject, + Class, + Count, + CrudConnector, + Entity, + EntityData, + Filter, + Options, + Where, +} from '../..'; + +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * A mock up connector implementation + */ +export class CrudConnectorStub implements CrudConnector { + private entities: EntityData[] = []; + name: 'my-connector'; + + connect() { + return Promise.resolve(); + } + + disconnect() { + return Promise.resolve(); + } + + ping() { + return Promise.resolve(); + } + + create( + modelClass: Class, + entity: EntityData, + options?: Options, + ): Promise { + this.entities.push(entity); + return Promise.resolve(entity); + } + + find( + modelClass: Class, + filter?: Filter, + options?: Options, + ): Promise { + return Promise.resolve(this.entities); + } + + updateAll( + modelClass: Class, + data: EntityData, + where?: Where, + options?: Options, + ): Promise { + for (const p in data) { + for (const e of this.entities) { + (e as AnyObject)[p] = (data as AnyObject)[p]; + } + } + return Promise.resolve({count: this.entities.length}); + } + + deleteAll( + modelClass: Class, + where?: Where, + options?: Options, + ): Promise { + const items = this.entities.splice(0, this.entities.length); + return Promise.resolve({count: items.length}); + } + + count( + modelClass: Class, + where?: Where, + options?: Options, + ): Promise { + return Promise.resolve({count: this.entities.length}); + } + + // Promises are not allowed yet + // See https://github.com/strongloop/loopback-datasource-juggler/issues/1659 + // for tracking support + beginTransaction(options: Options, cb: Callback) { + return cb(null, {}); + } +} diff --git a/packages/repository/src/__tests__/unit/repositories/crud.repository.unit.ts b/packages/repository/src/__tests__/unit/repositories/crud.repository.unit.ts index 601d0b006151..7da6f8303e0d 100644 --- a/packages/repository/src/__tests__/unit/repositories/crud.repository.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/crud.repository.unit.ts @@ -4,89 +4,14 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; - import { + CrudConnector, CrudRepository, CrudRepositoryImpl, DataSource, Entity, - EntityData, - Class, - Filter, - Where, - Options, - CrudConnector, - AnyObject, - Count, } from '../../..'; - -/** - * A mock up connector implementation - */ -class CrudConnectorStub implements CrudConnector { - private entities: EntityData[] = []; - name: 'my-connector'; - - connect() { - return Promise.resolve(); - } - - disconnect() { - return Promise.resolve(); - } - - ping() { - return Promise.resolve(); - } - - create( - modelClass: Class, - entity: EntityData, - options?: Options, - ): Promise { - this.entities.push(entity); - return Promise.resolve(entity); - } - - find( - modelClass: Class, - filter?: Filter, - options?: Options, - ): Promise { - return Promise.resolve(this.entities); - } - - updateAll( - modelClass: Class, - data: EntityData, - where?: Where, - options?: Options, - ): Promise { - for (const p in data) { - for (const e of this.entities) { - (e as AnyObject)[p] = (data as AnyObject)[p]; - } - } - return Promise.resolve({count: this.entities.length}); - } - - deleteAll( - modelClass: Class, - where?: Where, - options?: Options, - ): Promise { - const items = this.entities.splice(0, this.entities.length); - return Promise.resolve({count: items.length}); - } - - count( - modelClass: Class, - where?: Where, - options?: Options, - ): Promise { - return Promise.resolve({count: this.entities.length}); - } -} +import {CrudConnectorStub} from '../crud-connector.stub'; /** * A mock up model class diff --git a/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts b/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts index 5cafd3c795b9..5a87eb38f548 100644 --- a/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts @@ -7,6 +7,7 @@ import {expect} from '@loopback/testlab'; import { bindModel, DefaultCrudRepository, + DefaultTransactionalRepository, Entity, EntityNotFoundError, juggler, @@ -14,6 +15,8 @@ import { ModelDefinition, property, } from '../../..'; +import {CrudConnectorStub} from '../crud-connector.stub'; +const TransactionClass = require('loopback-datasource-juggler').Transaction; describe('legacy loopback-datasource-juggler', () => { let ds: juggler.DataSource; @@ -417,3 +420,49 @@ describe('DefaultCrudRepository', () => { ); }); }); + +describe('DefaultTransactionalRepository', () => { + let ds: juggler.DataSource; + class Note extends Entity { + static definition = new ModelDefinition({ + name: 'Note', + properties: { + title: 'string', + content: 'string', + id: {name: 'id', type: 'number', id: true}, + }, + }); + + title?: string; + content?: string; + id: number; + + constructor(data: Partial) { + super(data); + } + } + + beforeEach(() => { + ds = new juggler.DataSource({ + name: 'db', + connector: 'memory', + }); + }); + + it('throws an error when beginTransaction() not implemented', async () => { + const repo = new DefaultTransactionalRepository(Note, ds); + await expect(repo.beginTransaction({})).to.be.rejectedWith( + 'beginTransaction must be function implemented by the connector', + ); + }); + it('calls connector beginTransaction() when available', async () => { + const crudDs = new juggler.DataSource({ + name: 'db', + connector: CrudConnectorStub, + }); + + const repo = new DefaultTransactionalRepository(Note, crudDs); + const res = await repo.beginTransaction(); + expect(res).to.be.instanceOf(TransactionClass); + }); +}); diff --git a/packages/repository/src/index.ts b/packages/repository/src/index.ts index acc08105802c..5a7a10685c98 100644 --- a/packages/repository/src/index.ts +++ b/packages/repository/src/index.ts @@ -3,15 +3,16 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +export * from './common-types'; export * from './connectors'; +export * from './datasource'; export * from './decorators'; export * from './errors'; export * from './mixins'; -export * from './repositories'; -export * from './types'; -export * from './common-types'; -export * from './datasource'; export * from './model'; export * from './query'; -export * from './type-resolver'; export * from './relations'; +export * from './repositories'; +export * from './transaction'; +export * from './type-resolver'; +export * from './types'; diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 90ad0fa57848..03da88d77edf 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -29,8 +29,12 @@ import { HasOneDefinition, HasOneRepositoryFactory, } from '../relations'; +import {IsolationLevel, Transaction} from '../transaction'; import {isTypeResolver, resolveType} from '../type-resolver'; -import {EntityCrudRepository} from './repository'; +import { + EntityCrudRepository, + TransactionalEntityRepository, +} from './repository'; export namespace juggler { /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -40,6 +44,10 @@ export namespace juggler { export import PersistedModel = legacy.PersistedModel; export import KeyValueModel = legacy.KeyValueModel; export import PersistedModelClass = legacy.PersistedModelClass; + // eslint-disable-next-line no-shadow + export import Transaction = legacy.Transaction; + // eslint-disable-next-line no-shadow + export import IsolationLevel = legacy.IsolationLevel; } function isModelClass( @@ -461,3 +469,26 @@ export class DefaultCrudRepository< return models.map(m => this.toEntity(m)); } } + +/** + * Default implementation of CRUD repository using legacy juggler model + * and data source with beginTransaction() method for connectors which + * support Transactions + */ + +export class DefaultTransactionalRepository< + T extends Entity, + ID, + Relations extends object = {} +> extends DefaultCrudRepository + implements TransactionalEntityRepository { + async beginTransaction( + options?: IsolationLevel | Options, + ): Promise { + const dsOptions: juggler.IsolationLevel | Options = options || {}; + // juggler.Transaction still has the Promise/Callback variants of the + // Transaction methods + // so we need it cast it back + return (await this.dataSource.beginTransaction(dsOptions)) as Transaction; + } +} diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index 739a9e7c9f2e..1592100062a8 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -17,6 +17,7 @@ import {DataSource} from '../datasource'; import {EntityNotFoundError} from '../errors'; import {Entity, Model, ValueObject} from '../model'; import {Filter, Where} from '../query'; +import {IsolationLevel, Transaction} from '../transaction'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -37,6 +38,34 @@ export interface ExecutableRepository extends Repository { ): Promise; } +/** + * A type for CRUD repositories that are backed by IDs and support + * Transactions + */ +export type TransactionalEntityRepository< + T extends Entity, + ID, + Relations extends object = {} +> = TransactionalRepository & EntityCrudRepository; +/** + * Repository Interface for Repositories that support Transactions + * + * @export + * @interface TransactionalRepository + * @extends {Repository} + * @template T + */ +export interface TransactionalRepository + extends Repository { + /** + * Begin a new Transaction + * @param options - Options for the operations + * @returns Promise Promise that resolves to a new Transaction + * object + */ + beginTransaction(options?: IsolationLevel | Options): Promise; +} + /** * Basic CRUD operations for ValueObject and Entity. No ID is required. */ diff --git a/packages/repository/src/transaction.ts b/packages/repository/src/transaction.ts new file mode 100644 index 000000000000..e8eab2f92e6b --- /dev/null +++ b/packages/repository/src/transaction.ts @@ -0,0 +1,34 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Local transaction + */ +export interface Transaction { + /** + * Commit the transaction + */ + commit(): Promise; + + /** + * Rollback the transaction + */ + rollback(): Promise; + + /** + * The transaction Identifier + */ + id: string; +} + +/** + * Isolation level + */ +export enum IsolationLevel { + READ_COMMITTED = 'READ COMMITTED', // default + READ_UNCOMMITTED = 'READ UNCOMMITTED', + SERIALIZABLE = 'SERIALIZABLE', + REPEATABLE_READ = 'REPEATABLE READ', +}