Skip to content

Commit

Permalink
feat(core): Create Transaction decorator
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Sep 2, 2020
1 parent 09e48ac commit 4040089
Show file tree
Hide file tree
Showing 20 changed files with 202 additions and 144 deletions.
27 changes: 14 additions & 13 deletions packages/core/e2e/database-transactions.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import { Injectable } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
Administrator,
Ctx,
InternalServerError,
mergeConfig,
NativeAuthenticationMethod,
PluginCommonModule,
RequestContext,
Transaction,
TransactionalConnection,
UnitOfWork,
User,
VendurePlugin,
} from '@vendure/core';
Expand All @@ -22,8 +24,8 @@ import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-conf
class TestUserService {
constructor(private connection: TransactionalConnection) {}

async createUser(identifier: string) {
const authMethod = await this.connection.getRepository(NativeAuthenticationMethod).save(
async createUser(ctx: RequestContext, identifier: string) {
const authMethod = await this.connection.getRepository(ctx, NativeAuthenticationMethod).save(
new NativeAuthenticationMethod({
identifier,
passwordHash: 'abc',
Expand All @@ -45,12 +47,12 @@ class TestUserService {
class TestAdminService {
constructor(private connection: TransactionalConnection, private userService: TestUserService) {}

async createAdministrator(emailAddress: string, fail: boolean) {
const user = await this.userService.createUser(emailAddress);
async createAdministrator(ctx: RequestContext, emailAddress: string, fail: boolean) {
const user = await this.userService.createUser(ctx, emailAddress);
if (fail) {
throw new InternalServerError('Failed!');
}
const admin = await this.connection.getRepository(Administrator).save(
const admin = await this.connection.getRepository(ctx, Administrator).save(
new Administrator({
emailAddress,
user,
Expand All @@ -64,19 +66,18 @@ class TestAdminService {

@Resolver()
class TestResolver {
constructor(private uow: UnitOfWork, private testAdminService: TestAdminService) {}
constructor(private testAdminService: TestAdminService, private connection: TransactionalConnection) {}

@Mutation()
createTestAdministrator(@Args() args: any) {
return this.uow.withTransaction(() => {
return this.testAdminService.createAdministrator(args.emailAddress, args.fail);
});
@Transaction
createTestAdministrator(@Ctx() ctx: RequestContext, @Args() args: any) {
return this.testAdminService.createAdministrator(ctx, 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();
const admins = await this.connection.getRepository(Administrator).find();
const users = await this.connection.getRepository(User).find();
return {
admins,
users,
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/api/common/parse-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import { GqlExecutionContext } from '@nestjs/graphql';
import { Request, Response } from 'express';
import { GraphQLResolveInfo } from 'graphql';

export type RestContext = { req: Request; res: Response; isGraphQL: false; info: undefined };
export type GraphQLContext = {
req: Request;
res: Response;
isGraphQL: true;
info: GraphQLResolveInfo;
};

/**
* Parses in the Nest ExecutionContext of the incoming request, accounting for both
* GraphQL & REST requests.
*/
export function parseContext(
context: ExecutionContext | ArgumentsHost,
): { req: Request; res: Response; isGraphQL: boolean; info?: GraphQLResolveInfo } {
export function parseContext(context: ExecutionContext | ArgumentsHost): RestContext | GraphQLContext {
const graphQlContext = GqlExecutionContext.create(context as ExecutionContext);
const restContext = GqlExecutionContext.create(context as ExecutionContext);
const info = graphQlContext.getInfo();
Expand Down
4 changes: 1 addition & 3 deletions packages/core/src/api/common/request-context.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import { ChannelService } from '../../service/services/channel.service';
import { getApiType } from './get-api-type';
import { RequestContext } from './request-context';

export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';

/**
* Creates new RequestContext instances.
*/
Expand Down Expand Up @@ -79,7 +77,7 @@ export class RequestContextService {
if (!user || !channel) {
return false;
}
const permissionsOnChannel = user.channelPermissions.find((c) => idsAreEqual(c.id, channel.id));
const permissionsOnChannel = user.channelPermissions.find(c => idsAreEqual(c.id, channel.id));
if (permissionsOnChannel) {
return this.arraysIntersect(permissionsOnChannel.permissions, permissions);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ContextType, createParamDecorator, ExecutionContext } from '@nestjs/common';

import { REQUEST_CONTEXT_KEY } from '../common/request-context.service';
import { REQUEST_CONTEXT_KEY } from '../../common/constants';

/**
* @description
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/api/decorators/transaction.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { applyDecorators, UseInterceptors } from '@nestjs/common';

import { TransactionInterceptor } from '../middleware/transaction-interceptor';

/**
* @description
* Runs the decorated method in a TypeORM transaction. It works by creating a transctional
* QueryRunner which gets attached to the RequestContext object. When the RequestContext
* is the passed to the {@link TransactionalConnection} `getRepository()` method, this
* QueryRunner is used to execute the queries within this transaction.
*
* @docsCategory request
* @docsPage Decorators
*/
export const Transaction = applyDecorators(UseInterceptors(TransactionInterceptor));
1 change: 1 addition & 0 deletions packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { ApiType } from './common/get-api-type';
export * from './common/request-context';
export * from './decorators/allow.decorator';
export * from './decorators/transaction.decorator';
export * from './decorators/api.decorator';
export * from './decorators/request-context.decorator';
export * from './resolvers/admin/search.resolver';
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/api/middleware/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { Reflector } from '@nestjs/core';
import { Permission } from '@vendure/common/lib/generated-types';
import { Request, Response } from 'express';

import { REQUEST_CONTEXT_KEY } from '../../common/constants';
import { ForbiddenError } from '../../common/error/errors';
import { ConfigService } from '../../config/config.service';
import { CachedSession } from '../../config/session-cache/session-cache-strategy';
import { SessionService } from '../../service/services/session.service';
import { extractSessionToken } from '../common/extract-session-token';
import { parseContext } from '../common/parse-context';
import { REQUEST_CONTEXT_KEY, RequestContextService } from '../common/request-context.service';
import { RequestContextService } from '../common/request-context.service';
import { setSessionToken } from '../common/set-session-token';
import { PERMISSIONS_METADATA_KEY } from '../decorators/allow.decorator';

Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/api/middleware/id-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,9 @@ export class IdInterceptor implements NestInterceptor {
constructor(private idCodecService: IdCodecService) {}

intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const { isGraphQL, req } = parseContext(context);
if (isGraphQL) {
const { isGraphQL, req, info } = parseContext(context);
if (isGraphQL && info) {
const args = GqlExecutionContext.create(context).getArgs();
const info = GqlExecutionContext.create(context).getInfo();
const transformer = this.getTransformerForSchema(info.schema);
this.decodeIdArguments(transformer, info.operation, args);
}
Expand Down
59 changes: 59 additions & 0 deletions packages/core/src/api/middleware/transaction-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';

import { REQUEST_CONTEXT_KEY, TRANSACTION_MANAGER_KEY } from '../../common/constants';
import { TransactionalConnection } from '../../service/transaction/transactional-connection';
import { parseContext } from '../common/parse-context';
import { RequestContext } from '../common/request-context';

/**
* @description
* Used by the {@link Transaction} decorator to create a transactional query runner
* and attach it to the RequestContext.
*/
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
constructor(private connection: TransactionalConnection) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const { isGraphQL, req } = parseContext(context);
const ctx = (req as any)[REQUEST_CONTEXT_KEY];
if (ctx) {
return of(this.withTransaction(ctx, () => next.handle().toPromise()));
} else {
return next.handle();
}
}

/**
* @description
* Executes the `work` function within the context of a transaction.
*/
private async withTransaction<T>(ctx: RequestContext, work: () => T): Promise<T> {
const queryRunnerExists = !!(ctx as any)[TRANSACTION_MANAGER_KEY];
if (queryRunnerExists) {
// If a QueryRunner already exists on the RequestContext, there must be an existing
// outer transaction in progress. In that case, we just execute the work function
// as usual without needing to further wrap in a transaction.
return work();
}
const queryRunner = this.connection.rawConnection.createQueryRunner();
await queryRunner.startTransaction();
(ctx as any)[TRANSACTION_MANAGER_KEY] = queryRunner.manager;

try {
const result = await work();
if (queryRunner.isTransactionActive) {
await queryRunner.commitTransaction();
}
return result;
} catch (error) {
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { LanguageCode } from '@vendure/common/lib/generated-types';
import { assertNever } from '@vendure/common/lib/shared-utils';
import {
DefinitionNode,
GraphQLInputType,
GraphQLList,
GraphQLNonNull,
GraphQLResolveInfo,
GraphQLSchema,
OperationDefinitionNode,
TypeNode,
} from 'graphql';

import { UserInputError } from '../../common/error/errors';
import { REQUEST_CONTEXT_KEY } from '../../common/constants';
import { ConfigService } from '../../config/config.service';
import {
CustomFieldConfig,
CustomFields,
LocaleStringCustomFieldConfig,
StringCustomFieldConfig,
} from '../../config/custom-field/custom-field-types';
import { CustomFieldConfig, CustomFields } from '../../config/custom-field/custom-field-types';
import { parseContext } from '../common/parse-context';
import { RequestContext } from '../common/request-context';
import { REQUEST_CONTEXT_KEY } from '../common/request-context.service';
import { validateCustomFieldValue } from '../common/validate-custom-field-value';

/**
Expand All @@ -44,12 +35,12 @@ export class ValidateCustomFieldsInterceptor implements NestInterceptor {
}

intercept(context: ExecutionContext, next: CallHandler<any>) {
const { isGraphQL } = parseContext(context);
if (isGraphQL) {
const parsedContext = parseContext(context);
if (parsedContext.isGraphQL) {
const gqlExecutionContext = GqlExecutionContext.create(context);
const { operation, schema } = gqlExecutionContext.getInfo<GraphQLResolveInfo>();
const { operation, schema } = parsedContext.info;
const variables = gqlExecutionContext.getArgs();
const ctx: RequestContext = gqlExecutionContext.getContext().req[REQUEST_CONTEXT_KEY];
const ctx: RequestContext = (parsedContext.req as any)[REQUEST_CONTEXT_KEY];

if (operation.operation === 'mutation') {
const inputTypeNames = this.getArgumentMap(operation, schema);
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ import { LanguageCode } from '@vendure/common/lib/generated-types';
* VendureConfig to ensure at least a valid LanguageCode is available.
*/
export const DEFAULT_LANGUAGE_CODE = LanguageCode.en;
export const TRANSACTION_MANAGER_KEY = Symbol('TRANSACTION_MANAGER');
export const REQUEST_CONTEXT_KEY = 'vendureRequestContext';
15 changes: 6 additions & 9 deletions packages/core/src/entity/order/order.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';

import { Calculated } from '../../common/calculated-decorator';
import { ChannelAware } from '../../common/types/common-types';
import { HasCustomFields } from '../../config/custom-field/custom-field-types';
import { OrderState } from '../../service/helpers/order-state-machine/order-state';
import { VendureEntity } from '../base/base.entity';
import { Channel } from '../channel/channel.entity';
import { CustomOrderFields } from '../custom-entity-fields';
import { Customer } from '../customer/customer.entity';
import { EntityId } from '../entity-id.decorator';
Expand All @@ -14,8 +16,6 @@ import { OrderLine } from '../order-line/order-line.entity';
import { Payment } from '../payment/payment.entity';
import { Promotion } from '../promotion/promotion.entity';
import { ShippingMethod } from '../shipping-method/shipping-method.entity';
import { ChannelAware } from '../../common/types/common-types';
import { Channel } from '../channel/channel.entity';

/**
* @description
Expand Down Expand Up @@ -96,7 +96,7 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
@EntityId({ nullable: true })
taxZoneId?: ID;

@ManyToMany((type) => Channel)
@ManyToMany(type => Channel)
@JoinTable()
channels: Channel[];

Expand Down Expand Up @@ -134,11 +134,8 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
}

getOrderItems(): OrderItem[] {
return this.lines.reduce(
(items, line) => {
return [...items, ...line.items];
},
[] as OrderItem[],
);
return this.lines.reduce((items, line) => {
return [...items, ...line.items];
}, [] as OrderItem[]);
}
}
1 change: 0 additions & 1 deletion packages/core/src/service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,4 @@ export * from './services/tax-category.service';
export * from './services/tax-rate.service';
export * from './services/user.service';
export * from './services/user.service';
export * from './transaction/unit-of-work';
export * from './transaction/transactional-connection';
42 changes: 42 additions & 0 deletions packages/core/src/service/initializer.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';

import { AdministratorService } from './services/administrator.service';
import { ChannelService } from './services/channel.service';
import { GlobalSettingsService } from './services/global-settings.service';
import { PaymentMethodService } from './services/payment-method.service';
import { RoleService } from './services/role.service';
import { ShippingMethodService } from './services/shipping-method.service';
import { TaxRateService } from './services/tax-rate.service';

/**
* Only used internally to run the various service init methods in the correct
* sequence on bootstrap.
*/
@Injectable()
export class InitializerService {
constructor(
private channelService: ChannelService,
private roleService: RoleService,
private administratorService: AdministratorService,
private taxRateService: TaxRateService,
private shippingMethodService: ShippingMethodService,
private paymentMethodService: PaymentMethodService,
private globalSettingsService: GlobalSettingsService,
) {}

async onModuleInit() {
// IMPORTANT - why manually invoke these init methods rather than just relying on
// Nest's "onModuleInit" lifecycle hook within each individual service class?
// The reason is that the order of invokation matters. By explicitly invoking the
// methods below, we can e.g. guarantee that the default channel exists
// (channelService.initChannels()) before we try to create any roles (which assume that
// there is a default Channel to work with.
await this.globalSettingsService.initGlobalSettings();
await this.channelService.initChannels();
await this.roleService.initRoles();
await this.administratorService.initAdministrators();
await this.taxRateService.initTaxRates();
await this.shippingMethodService.initShippingMethods();
await this.paymentMethodService.initPaymentMethods();
}
}
Loading

0 comments on commit 4040089

Please sign in to comment.