Просмотр исходного кода

feat(core): Create unit-of-work infrastructure for transactions

Relates to #242
Michael Bromley 5 лет назад
Родитель
Сommit
82b54e61e0

+ 191 - 0
packages/core/e2e/database-transactions.e2e-spec.ts

@@ -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
+            }
+        }
+    }
+`;

+ 3 - 1
packages/core/src/service/index.ts

@@ -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';
+export * from './services/user.service';
+export * from './transaction/unit-of-work';
+export * from './transaction/transactional-connection';

+ 4 - 0
packages/core/src/service/service.module.ts

@@ -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,
@@ -102,6 +104,8 @@ const helpers = [
     ShippingConfiguration,
     SlugValidator,
     ExternalAuthenticationService,
+    UnitOfWork,
+    TransactionalConnection,
 ];
 
 const workerControllers = [CollectionController, TaxRateController];

+ 40 - 0
packages/core/src/service/transaction/transactional-connection.ts

@@ -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 - 0
packages/core/src/service/transaction/unit-of-work.ts

@@ -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;
+        }
+    }
+}

+ 1 - 1
packages/testing/src/data-population/populate-for-testing.ts

@@ -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];

+ 3 - 3
scripts/codegen/generate-graphql-types.ts

@@ -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(
@@ -128,10 +128,10 @@ Promise.all([
         });
     })
     .then(
-        (result) => {
+        result => {
             process.exit(0);
         },
-        (err) => {
+        err => {
             console.error(err);
             process.exit(1);
         },