From b9f8b21274098a945ec7b2176e4c94fd91c56826 Mon Sep 17 00:00:00 2001 From: Biniam Admikew Date: Wed, 31 Jul 2019 13:03:36 -0400 Subject: [PATCH] feat(repository): expose beginTransaction() API Add TransactionalRepository for use with datasources that support transactions. Create DefaultTransactionRepository with CRUD methods and beginTransaction() method. Add Transaction interface to describe transaction objects with commit() and rollback() actions. --- .../src/__tests__/mongodb.datasource.ts | 1 + .../mysql-default-repository.acceptance.ts | 6 +- .../src/__tests__/mysql.datasource.ts | 1 + docs/site/Transactions.md | 200 ++++++++++++++++++ docs/site/sidebars/lb4_sidebar.yml | 5 + .../default-repository.memory.acceptance.ts | 1 + .../repository-tests/src/crud-test-suite.ts | 1 + .../src/crud/transactions.suite.ts | 176 +++++++++++++++ .../src/types.repository-tests.ts | 22 ++ .../src/__tests__/unit/crud-connector.stub.ts | 92 ++++++++ .../unit/repositories/crud.repository.unit.ts | 79 +------ .../legacy-juggler-bridge.unit.ts | 49 +++++ packages/repository/src/index.ts | 11 +- .../src/repositories/legacy-juggler-bridge.ts | 33 ++- .../repository/src/repositories/repository.ts | 29 +++ packages/repository/src/transaction.ts | 34 +++ 16 files changed, 654 insertions(+), 86 deletions(-) create mode 100644 docs/site/Transactions.md create mode 100644 packages/repository-tests/src/crud/transactions.suite.ts create mode 100644 packages/repository/src/__tests__/unit/crud-connector.stub.ts create mode 100644 packages/repository/src/transaction.ts 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', +}