-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): Create unit-of-work infrastructure for transactions
Relates to #242
- Loading branch information
1 parent
5f47773
commit 82b54e6
Showing
7 changed files
with
284 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
import { Injectable } from '@nestjs/common'; | ||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; | ||
import { | ||
Administrator, | ||
InternalServerError, | ||
mergeConfig, | ||
NativeAuthenticationMethod, | ||
PluginCommonModule, | ||
TransactionalConnection, | ||
UnitOfWork, | ||
User, | ||
VendurePlugin, | ||
} from '@vendure/core'; | ||
import { createTestEnvironment } from '@vendure/testing'; | ||
import gql from 'graphql-tag'; | ||
import path from 'path'; | ||
|
||
import { initialData } from '../../../e2e-common/e2e-initial-data'; | ||
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config'; | ||
|
||
@Injectable() | ||
class TestUserService { | ||
constructor(private connection: TransactionalConnection) {} | ||
|
||
async createUser(identifier: string) { | ||
const authMethod = await this.connection.getRepository(NativeAuthenticationMethod).save( | ||
new NativeAuthenticationMethod({ | ||
identifier, | ||
passwordHash: 'abc', | ||
}), | ||
); | ||
const user = this.connection.getRepository(User).save( | ||
new User({ | ||
authenticationMethods: [authMethod], | ||
identifier, | ||
roles: [], | ||
verified: true, | ||
}), | ||
); | ||
return user; | ||
} | ||
} | ||
|
||
@Injectable() | ||
class TestAdminService { | ||
constructor(private connection: TransactionalConnection, private userService: TestUserService) {} | ||
|
||
async createAdministrator(emailAddress: string, fail: boolean) { | ||
const user = await this.userService.createUser(emailAddress); | ||
if (fail) { | ||
throw new InternalServerError('Failed!'); | ||
} | ||
const admin = await this.connection.getRepository(Administrator).save( | ||
new Administrator({ | ||
emailAddress, | ||
user, | ||
firstName: 'jim', | ||
lastName: 'jiminy', | ||
}), | ||
); | ||
return admin; | ||
} | ||
} | ||
|
||
@Resolver() | ||
class TestResolver { | ||
constructor(private uow: UnitOfWork, private testAdminService: TestAdminService) {} | ||
|
||
@Mutation() | ||
createTestAdministrator(@Args() args: any) { | ||
return this.uow.withTransaction(() => { | ||
return this.testAdminService.createAdministrator(args.emailAddress, args.fail); | ||
}); | ||
} | ||
|
||
@Query() | ||
async verify() { | ||
const admins = await this.uow.getConnection().getRepository(Administrator).find(); | ||
const users = await this.uow.getConnection().getRepository(User).find(); | ||
return { | ||
admins, | ||
users, | ||
}; | ||
} | ||
} | ||
|
||
@VendurePlugin({ | ||
imports: [PluginCommonModule], | ||
providers: [TestAdminService, TestUserService], | ||
adminApiExtensions: { | ||
schema: gql` | ||
extend type Mutation { | ||
createTestAdministrator(emailAddress: String!, fail: Boolean!): Administrator | ||
} | ||
type VerifyResult { | ||
admins: [Administrator!]! | ||
users: [User!]! | ||
} | ||
extend type Query { | ||
verify: VerifyResult! | ||
} | ||
`, | ||
resolvers: [TestResolver], | ||
}, | ||
}) | ||
class TransactionTestPlugin {} | ||
|
||
describe('Transaction infrastructure', () => { | ||
const { server, adminClient } = createTestEnvironment( | ||
mergeConfig(testConfig, { | ||
plugins: [TransactionTestPlugin], | ||
}), | ||
); | ||
|
||
beforeAll(async () => { | ||
await server.init({ | ||
initialData, | ||
productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'), | ||
customerCount: 0, | ||
}); | ||
await adminClient.asSuperAdmin(); | ||
}, TEST_SETUP_TIMEOUT_MS); | ||
|
||
afterAll(async () => { | ||
await server.destroy(); | ||
}); | ||
|
||
it('non-failing mutation', async () => { | ||
const { createTestAdministrator } = await adminClient.query(CREATE_ADMIN, { | ||
emailAddress: 'test1', | ||
fail: false, | ||
}); | ||
|
||
expect(createTestAdministrator.emailAddress).toBe('test1'); | ||
expect(createTestAdministrator.user.identifier).toBe('test1'); | ||
|
||
const { verify } = await adminClient.query(VERIFY_TEST); | ||
|
||
expect(verify.admins.length).toBe(2); | ||
expect(verify.users.length).toBe(2); | ||
expect(!!verify.admins.find((a: any) => a.emailAddress === 'test1')).toBe(true); | ||
expect(!!verify.users.find((u: any) => u.identifier === 'test1')).toBe(true); | ||
}); | ||
|
||
it('failing mutation', async () => { | ||
try { | ||
await adminClient.query(CREATE_ADMIN, { | ||
emailAddress: 'test2', | ||
fail: true, | ||
}); | ||
fail('Should have thrown'); | ||
} catch (e) { | ||
expect(e.message).toContain('Failed!'); | ||
} | ||
|
||
const { verify } = await adminClient.query(VERIFY_TEST); | ||
|
||
expect(verify.admins.length).toBe(2); | ||
expect(verify.users.length).toBe(2); | ||
expect(!!verify.admins.find((a: any) => a.emailAddress === 'test2')).toBe(false); | ||
expect(!!verify.users.find((u: any) => u.identifier === 'test2')).toBe(false); | ||
}); | ||
}); | ||
|
||
const CREATE_ADMIN = gql` | ||
mutation CreateTestAdmin($emailAddress: String!, $fail: Boolean!) { | ||
createTestAdministrator(emailAddress: $emailAddress, fail: $fail) { | ||
id | ||
emailAddress | ||
user { | ||
id | ||
identifier | ||
} | ||
} | ||
} | ||
`; | ||
|
||
const VERIFY_TEST = gql` | ||
query VerifyTest { | ||
verify { | ||
admins { | ||
id | ||
emailAddress | ||
} | ||
users { | ||
id | ||
identifier | ||
} | ||
} | ||
} | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,4 +32,6 @@ export * from './services/shipping-method.service'; | |
export * from './services/tax-category.service'; | ||
export * from './services/tax-rate.service'; | ||
export * from './services/user.service'; | ||
export * from './services/zone.service'; | ||
This comment has been minimized.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
michaelbromley
Author
Member
|
||
export * from './services/user.service'; | ||
export * from './transaction/unit-of-work'; | ||
export * from './transaction/transactional-connection'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
packages/core/src/service/transaction/transactional-connection.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
import { Injectable, Scope } from '@nestjs/common'; | ||
import { EntitySchema, getRepository, ObjectType, Repository } from 'typeorm'; | ||
import { RepositoryFactory } from 'typeorm/repository/RepositoryFactory'; | ||
|
||
import { UnitOfWork } from './unit-of-work'; | ||
|
||
/** | ||
* @description | ||
* The TransactionalConnection is a wrapper around the TypeORM `Connection` object which works in conjunction | ||
* with the {@link UnitOfWork} class to implement per-request transactions. All services which access the | ||
* database should use this class rather than the raw TypeORM connection, to ensure that db changes can be | ||
* easily wrapped in transactions when required. | ||
* | ||
* The service layer does not need to know about the scope of a transaction, as this is covered at the | ||
* API level depending on the nature of the request. | ||
* | ||
* Based on the pattern outlined in | ||
* [this article](https://aaronboman.com/programming/2020/05/15/per-request-database-transactions-with-nestjs-and-typeorm/) | ||
* | ||
* @docsCategory data-access | ||
*/ | ||
@Injectable({ scope: Scope.REQUEST }) | ||
export class TransactionalConnection { | ||
constructor(private uow: UnitOfWork) {} | ||
|
||
/** | ||
* @description | ||
* Gets a repository bound to the current transaction manager | ||
* or defaults to the current connection's call to getRepository(). | ||
*/ | ||
getRepository<Entity>(target: ObjectType<Entity> | EntitySchema<Entity> | string): Repository<Entity> { | ||
const transactionManager = this.uow.getTransactionManager(); | ||
if (transactionManager) { | ||
const connection = this.uow.getConnection(); | ||
const metadata = connection.getMetadata(target); | ||
return new RepositoryFactory().create(transactionManager, metadata); | ||
} | ||
return getRepository(target); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import { Injectable, Scope } from '@nestjs/common'; | ||
import { InjectConnection } from '@nestjs/typeorm'; | ||
import { Connection, EntityManager } from 'typeorm'; | ||
|
||
/** | ||
* @description | ||
* This class is used to wrap an entire request in a database transaction. It should | ||
* generally be injected at the API layer and wrap the service-layer call(s) so that | ||
* all DB access within the `withTransaction()` method takes place within a transaction. | ||
* | ||
* @docsCategory data-access | ||
*/ | ||
@Injectable({ scope: Scope.REQUEST }) | ||
export class UnitOfWork { | ||
private transactionManager: EntityManager | null; | ||
constructor(@InjectConnection() private connection: Connection) {} | ||
|
||
getTransactionManager(): EntityManager | null { | ||
return this.transactionManager; | ||
} | ||
|
||
getConnection(): Connection { | ||
return this.connection; | ||
} | ||
|
||
async withTransaction<T>(work: () => T): Promise<T> { | ||
const queryRunner = this.connection.createQueryRunner(); | ||
await queryRunner.startTransaction(); | ||
this.transactionManager = queryRunner.manager; | ||
try { | ||
const result = await work(); | ||
await queryRunner.commitTransaction(); | ||
return result; | ||
} catch (error) { | ||
await queryRunner.rollbackTransaction(); | ||
throw error; | ||
} finally { | ||
await queryRunner.release(); | ||
this.transactionManager = null; | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@michaelbromley any particular reason to exclude this service? Creating a channel e.g. requires passing a zone id.