Skip to content

Commit

Permalink
feat(core): Update assemblers to allow transforming create/update dtos
Browse files Browse the repository at this point in the history
  • Loading branch information
doug-martin committed Sep 17, 2020
1 parent 7fc7fe3 commit 5085d11
Show file tree
Hide file tree
Showing 17 changed files with 192 additions and 62 deletions.
45 changes: 44 additions & 1 deletion documentation/docs/concepts/assemblers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The only time you need to define an assembler is when the DTO and Entity are dif

* Additional computed fields and you do not want to include the business logic in your DTO definition.
* Different field names because of poorly named columns in the database or to make a DB change passive to the end user.
* You need to transform the create or update DTO before being passed to your persistence QueryService

## Why?

Expand Down Expand Up @@ -42,7 +43,7 @@ The assembler provides a single, testable, place to provide a translation betwee

The resolvers concern is translating graphql requests into the specified DTO.

The services concern is accepting and returning a DTO based contract. when using an assembler to translate between the DTO and underlying entities.
The services concern is accepting and returning a DTO based contract. Then using an assembler to translate between the DTO and underlying entities.

If you follow this pattern you **could** use the same service with other transports (rest, microservices, etc) as long as the request can be translated into a DTO.

Expand Down Expand Up @@ -206,6 +207,20 @@ export class UserAssembler extends AbstractAssembler<UserDTO, UserEntity> {
email: 'emailAddress'
});
}

convertToCreateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return {
first: firstName,
last: lastName,
};
}

convertToUpdateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return {
first: firstName,
last: lastName,
};
}
}

```
Expand Down Expand Up @@ -310,6 +325,34 @@ convertAggregateResponse(aggregate: AggregateResponse<TestEntity>): AggregateRes
}
```

### Converting Create DTO

The `convertToCreateEntity` is used to convert an incoming create DTO to the appropriate create entity, in this case
partial.

```ts
convertToCreateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return {
first: firstName,
last: lastName,
};
}
```

### Converting Update DTO

The `convertToUpdateEntity` is used to convert an incoming update DTO to the appropriate update entity, in this case a
partial.

```ts
convertToUpdateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return {
first: firstName,
last: lastName,
};
}
```

This is a pretty basic example but the same pattern should apply to more complex scenarios.

## AssemblerQueryService
Expand Down
8 changes: 4 additions & 4 deletions documentation/docs/concepts/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ The following methods are defined on the `QueryService`
* find a record by its id.
* `getById(id: string | number): Promise<DTO>`
* get a record by its id or return a rejected promise with a NotFound error.
* `createMany<C extends DeepPartial<DTO>>(items: C[]): Promise<DTO[]>`
* `createMany(items: DeepPartial<DTO>[]): Promise<DTO[]>`
* create multiple records.
* `createOne<C extends DeepPartial<DTO>>(item: C): Promise<DTO>`
* `createOne(item: DeepPartial<DTO>): Promise<DTO>`
* create one record.
* `updateMany<U extends DeepPartial<DTO>>(update: U, filter: Filter<DTO>): Promise<UpdateManyResponse>`
* `updateMany(update: DeepPartial<DTO>, filter: Filter<DTO>): Promise<UpdateManyResponse>`
* update many records.
* `updateOne<U extends DeepPartial<DTO>>(id: string | number, update: U): Promise<DTO>`
* `updateOne(id: string | number, update: DeepPartial<DTO>): Promise<DTO>`
* update a single record.
* `deleteMany(filter: Filter<DTO>): Promise<DeleteManyResponse>`
* delete multiple records.
Expand Down
10 changes: 5 additions & 5 deletions examples/custom-service/e2e/todo-item.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
query: `mutation {
createOneTodoItem(
input: {
todoItem: { name: "Test Todo", completed: false }
todoItem: { name: "Test Todo", isCompleted: false }
}
) {
id
Expand Down Expand Up @@ -281,7 +281,7 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
query: `mutation {
createOneTodoItem(
input: {
todoItem: { name: "Test Todo with a too long title!", completed: false }
todoItem: { name: "Test Todo with a too long title!", isCompleted: false }
}
) {
id
Expand Down Expand Up @@ -309,8 +309,8 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
createManyTodoItems(
input: {
todoItems: [
{ name: "Many Test Todo 1", completed: false },
{ name: "Many Test Todo 2", completed: true }
{ name: "Many Test Todo 1", isCompleted: false },
{ name: "Many Test Todo 2", isCompleted: true }
]
}
) {
Expand Down Expand Up @@ -339,7 +339,7 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
query: `mutation {
createManyTodoItems(
input: {
todoItems: [{ name: "Test Todo With A Really Long Title", completed: false }]
todoItems: [{ name: "Test Todo With A Really Long Title", isCompleted: false }]
}
) {
id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export class TodoItemInputDTO {

@IsBoolean()
@Field()
completed!: boolean;
isCompleted!: boolean;
}
6 changes: 3 additions & 3 deletions examples/custom-service/src/todo-item/todo-item.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export class TodoItemService extends NoOpQueryService<TodoItemDTO, TodoItemInput
super();
}

createOne({ name, ...item }: TodoItemInputDTO): Promise<TodoItemDTO> {
return this.queryService.createOne({ title: name, ...item });
createOne({ name: title, isCompleted: completed }: TodoItemInputDTO): Promise<TodoItemDTO> {
return this.queryService.createOne({ title, completed });
}

createMany(items: TodoItemInputDTO[]): Promise<TodoItemDTO[]> {
const newItems = items.map(({ name: title, ...item }) => ({ title, ...item }));
const newItems = items.map(({ name: title, isCompleted: completed }) => ({ title, completed }));
return this.queryService.createMany(newItems);
}

Expand Down
15 changes: 15 additions & 0 deletions packages/core/__tests__/assemblers/abstract.assembler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AggregateResponse,
transformAggregateQuery,
transformAggregateResponse,
DeepPartial,
} from '../../src';

describe('ClassTransformerAssembler', () => {
Expand All @@ -24,6 +25,20 @@ describe('ClassTransformerAssembler', () => {

@Assembler(TestDTO, TestEntity)
class TestAssembler extends AbstractAssembler<TestDTO, TestEntity> {
convertToCreateEntity(create: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return {
first: create.firstName,
last: create.lastName,
};
}

convertToUpdateEntity(update: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return {
first: update.firstName,
last: update.lastName,
};
}

convertToDTO(entity: TestEntity): TestDTO {
return {
firstName: entity.first,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
AggregateQuery,
AggregateResponse,
AssemblerQueryService,
DeepPartial,
Query,
QueryService,
transformAggregateQuery,
Expand Down Expand Up @@ -54,6 +55,14 @@ describe('AssemblerQueryService', () => {
bar: 'foo',
});
}

convertToCreateEntity(create: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return { bar: create.foo };
}

convertToUpdateEntity(update: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
return { bar: update.foo };
}
}

describe('query', () => {
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/assemblers/abstract.assembler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Class } from '../common';
import { Class, DeepPartial } from '../common';
import { AggregateQuery, Query, AggregateResponse } from '../interfaces';
import { Assembler, getAssemblerClasses } from './assembler';

Expand All @@ -9,7 +9,8 @@ import { Assembler, getAssemblerClasses } from './assembler';
* * convertQuery
*
*/
export abstract class AbstractAssembler<DTO, Entity> implements Assembler<DTO, Entity> {
export abstract class AbstractAssembler<DTO, Entity, C = DeepPartial<DTO>, CE = DeepPartial<Entity>, U = C, UE = CE>
implements Assembler<DTO, Entity, C, CE, U, UE> {
readonly DTOClass: Class<DTO>;

readonly EntityClass: Class<Entity>;
Expand All @@ -19,7 +20,7 @@ export abstract class AbstractAssembler<DTO, Entity> implements Assembler<DTO, E
* @param EntityClass - Optional class definition for the entity. If not provided it will be looked up from the \@Assembler annotation.
*/
constructor(DTOClass?: Class<DTO>, EntityClass?: Class<Entity>) {
const classes = getAssemblerClasses(this.constructor as Class<Assembler<DTO, Entity>>);
const classes = getAssemblerClasses(this.constructor as Class<Assembler<DTO, Entity, C, CE, U, UE>>);
const DTOClas = DTOClass ?? classes?.DTOClass;
const EntityClas = EntityClass ?? classes?.EntityClass;
if (!DTOClas || !EntityClas) {
Expand All @@ -42,6 +43,10 @@ export abstract class AbstractAssembler<DTO, Entity> implements Assembler<DTO, E

abstract convertAggregateResponse(aggregate: AggregateResponse<Entity>): AggregateResponse<DTO>;

abstract convertToCreateEntity(create: C): CE;

abstract convertToUpdateEntity(update: U): UE;

convertToDTOs(entities: Entity[]): DTO[] {
return entities.map((e) => this.convertToDTO(e));
}
Expand All @@ -50,6 +55,10 @@ export abstract class AbstractAssembler<DTO, Entity> implements Assembler<DTO, E
return dtos.map((dto) => this.convertToEntity(dto));
}

convertToCreateEntities(createDtos: C[]): CE[] {
return createDtos.map((c) => this.convertToCreateEntity(c));
}

async convertAsyncToDTO(entity: Promise<Entity>): Promise<DTO> {
const e = await entity;
return this.convertToDTO(e);
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/assemblers/assembler.factory.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Class } from '../common';
import { Class, DeepPartial } from '../common';
import { Assembler, getAssembler } from './assembler';
import { DefaultAssembler } from './default.assembler';

/**
* Assembler Service used by query services to look up Assemblers.
*/
export class AssemblerFactory {
static getAssembler<From, To>(FromClass: Class<From>, ToClass: Class<To>): Assembler<From, To> {
const AssemblerClass = getAssembler(FromClass, ToClass);
static getAssembler<From, To, C = DeepPartial<From>, CE = DeepPartial<To>, U = C, UE = CE>(
FromClass: Class<From>,
ToClass: Class<To>,
): Assembler<From, To, C, CE, U, UE> {
const AssemblerClass = getAssembler<From, To, C, CE, U, UE>(FromClass, ToClass);
if (AssemblerClass) {
return new AssemblerClass();
}
return new DefaultAssembler(FromClass, ToClass);
const defaultAssember = new DefaultAssembler(FromClass, ToClass);
// if its a default just assume the types can be converted for all types
return (defaultAssember as unknown) as Assembler<From, To, C, CE, U, UE>;
}
}
48 changes: 40 additions & 8 deletions packages/core/src/assemblers/assembler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { Class, MapReflector, MetaValue, ValueReflector } from '../common';
import { Class, DeepPartial, MapReflector, MetaValue, ValueReflector } from '../common';
import { AggregateQuery, AggregateResponse, Query } from '../interfaces';
import { ASSEMBLER_CLASSES_KEY, ASSEMBLER_KEY } from './constants';

export interface Assembler<DTO, Entity> {
export interface Assembler<
DTO,
Entity,
CreateDTO = DeepPartial<DTO>,
CreateEntity = DeepPartial<Entity>,
UpdateDTO = CreateDTO,
UpdateEntity = CreateEntity
> {
/**
* Convert an entity to a DTO
* @param entity - the entity to convert
Expand Down Expand Up @@ -33,6 +40,18 @@ export interface Assembler<DTO, Entity> {
*/
convertAggregateResponse(aggregate: AggregateResponse<Entity>): AggregateResponse<DTO>;

/**
* Convert a create dto input to the equivalent create entity type
* @param createDTO
*/
convertToCreateEntity(createDTO: CreateDTO): CreateEntity;

/**
* Convert a update dto input to the equivalent update entity type
* @param createDTO
*/
convertToUpdateEntity(createDTO: UpdateDTO): UpdateEntity;

/**
* Convert an array of entities to a an of DTOs
* @param entities - the entities to convert.
Expand Down Expand Up @@ -66,6 +85,12 @@ export interface Assembler<DTO, Entity> {
* @param dtos - the promise that should resolve with the dtos.
*/
convertAsyncToEntities(dtos: Promise<DTO[]>): Promise<Entity[]>;

/**
* Convert an array of create DTOs to an array of create entities
* @param createDtos
*/
convertToCreateEntities(createDtos: CreateDTO[]): CreateEntity[];
}

const assemblerReflector = new ValueReflector(ASSEMBLER_CLASSES_KEY);
Expand All @@ -81,8 +106,15 @@ export interface AssemblerClasses<DTO, Entity> {
* @param DTOClass - the DTO class.
* @param EntityClass - The entity class.
*/
export function Assembler<DTO, Entity>(DTOClass: Class<DTO>, EntityClass: Class<Entity>) {
return <Cls extends Class<Assembler<DTO, Entity>>>(cls: Cls): Cls | void => {
export function Assembler<
DTO,
Entity,
C = DeepPartial<DTO>,
CE = DeepPartial<Entity>,
U = DeepPartial<DTO>,
UE = DeepPartial<Entity>
>(DTOClass: Class<DTO>, EntityClass: Class<Entity>) {
return <Cls extends Class<Assembler<DTO, Entity, C, CE, U, UE>>>(cls: Cls): Cls | void => {
if (reflector.has(DTOClass, EntityClass)) {
throw new Error(`Assembler already registered for ${DTOClass.name} ${EntityClass.name}`);
}
Expand All @@ -92,15 +124,15 @@ export function Assembler<DTO, Entity>(DTOClass: Class<DTO>, EntityClass: Class<
};
}

export function getAssembler<DTO, Entity>(
export function getAssembler<DTO, Entity, C, CE, U, UE>(
DTOClass: Class<DTO>,
EntityClass: Class<Entity>,
): MetaValue<Class<Assembler<DTO, Entity>>> {
): MetaValue<Class<Assembler<DTO, Entity, C, CE, U, UE>>> {
return reflector.get(DTOClass, EntityClass);
}

export function getAssemblerClasses<DTO, Entity>(
AssemblerClass: Class<Assembler<DTO, Entity>>,
export function getAssemblerClasses<DTO, Entity, C, CE, U, UE>(
AssemblerClass: Class<Assembler<DTO, Entity, C, CE, U, UE>>,
): MetaValue<AssemblerClasses<DTO, Entity>> {
return assemblerReflector.get(AssemblerClass);
}
Loading

0 comments on commit 5085d11

Please sign in to comment.