Skip to content

Commit

Permalink
feat(core): Create unit-of-work infrastructure for transactions
Browse files Browse the repository at this point in the history
Relates to #242
  • Loading branch information
michaelbromley committed Sep 1, 2020
1 parent 5f47773 commit 82b54e6
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 5 deletions.
191 changes: 191 additions & 0 deletions packages/core/e2e/database-transactions.e2e-spec.ts
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
}
}
}
`;
4 changes: 3 additions & 1 deletion packages/core/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
@thomas-advantitge

thomas-advantitge Oct 13, 2020

Contributor

@michaelbromley any particular reason to exclude this service? Creating a channel e.g. requires passing a zone id.

This comment has been minimized.

Copy link
@michaelbromley

michaelbromley Oct 13, 2020

Author Member

Nope, that's a mistake! Thanks for pointing it out. I'll restore that export in the next patch release.

export * from './services/user.service';
export * from './transaction/unit-of-work';
export * from './transaction/transactional-connection';
4 changes: 4 additions & 0 deletions packages/core/src/service/service.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import { TaxCategoryService } from './services/tax-category.service';
import { TaxRateService } from './services/tax-rate.service';
import { UserService } from './services/user.service';
import { ZoneService } from './services/zone.service';
import { TransactionalConnection } from './transaction/transactional-connection';
import { UnitOfWork } from './transaction/unit-of-work';

const services = [
AdministratorService,
Expand Down Expand Up @@ -102,6 +104,8 @@ const helpers = [
ShippingConfiguration,
SlugValidator,
ExternalAuthenticationService,
UnitOfWork,
TransactionalConnection,
];

const workerControllers = [CollectionController, TaxRateController];
Expand Down
40 changes: 40 additions & 0 deletions packages/core/src/service/transaction/transactional-connection.ts
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);
}
}
42 changes: 42 additions & 0 deletions packages/core/src/service/transaction/unit-of-work.ts
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function populateForTesting(
await populateInitialData(app, options.initialData, logFn);
await populateProducts(app, options.productsCsvPath, logging);
await populateCollections(app, options.initialData, logFn);
await populateCustomers(options.customerCount || 10, config, logging);
await populateCustomers(options.customerCount ?? 10, config, logging);

config.authOptions.requireVerification = originalRequireVerification;
return [app, worker];
Expand Down
6 changes: 3 additions & 3 deletions scripts/codegen/generate-graphql-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const CLIENT_QUERY_FILES = path.join(
);
const E2E_ADMIN_QUERY_FILES = path.join(
__dirname,
'../../packages/core/e2e/**/!(import.e2e-spec|plugin.e2e-spec|shop-definitions|custom-fields.e2e-spec|price-calculation-strategy.e2e-spec|list-query-builder.e2e-spec|shop-order.e2e-spec).ts',
'../../packages/core/e2e/**/!(import.e2e-spec|plugin.e2e-spec|shop-definitions|custom-fields.e2e-spec|price-calculation-strategy.e2e-spec|list-query-builder.e2e-spec|shop-order.e2e-spec|database-transactions.e2e-spec).ts',
);
const E2E_SHOP_QUERY_FILES = [path.join(__dirname, '../../packages/core/e2e/graphql/shop-definitions.ts')];
const E2E_ELASTICSEARCH_PLUGIN_QUERY_FILES = path.join(
Expand Down Expand Up @@ -128,10 +128,10 @@ Promise.all([
});
})
.then(
(result) => {
result => {
process.exit(0);
},
(err) => {
err => {
console.error(err);
process.exit(1);
},
Expand Down

0 comments on commit 82b54e6

Please sign in to comment.