Skip to content

Commit 5085d11

Browse files
committed
feat(core): Update assemblers to allow transforming create/update dtos
1 parent 7fc7fe3 commit 5085d11

17 files changed

+192
-62
lines changed

documentation/docs/concepts/assemblers.mdx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The only time you need to define an assembler is when the DTO and Entity are dif
1414

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

1819
## Why?
1920

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

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

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

4748
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.
4849

@@ -206,6 +207,20 @@ export class UserAssembler extends AbstractAssembler<UserDTO, UserEntity> {
206207
email: 'emailAddress'
207208
});
208209
}
210+
211+
convertToCreateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
212+
return {
213+
first: firstName,
214+
last: lastName,
215+
};
216+
}
217+
218+
convertToUpdateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
219+
return {
220+
first: firstName,
221+
last: lastName,
222+
};
223+
}
209224
}
210225

211226
```
@@ -310,6 +325,34 @@ convertAggregateResponse(aggregate: AggregateResponse<TestEntity>): AggregateRes
310325
}
311326
```
312327

328+
### Converting Create DTO
329+
330+
The `convertToCreateEntity` is used to convert an incoming create DTO to the appropriate create entity, in this case
331+
partial.
332+
333+
```ts
334+
convertToCreateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
335+
return {
336+
first: firstName,
337+
last: lastName,
338+
};
339+
}
340+
```
341+
342+
### Converting Update DTO
343+
344+
The `convertToUpdateEntity` is used to convert an incoming update DTO to the appropriate update entity, in this case a
345+
partial.
346+
347+
```ts
348+
convertToUpdateEntity({firstName, lastName}: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
349+
return {
350+
first: firstName,
351+
last: lastName,
352+
};
353+
}
354+
```
355+
313356
This is a pretty basic example but the same pattern should apply to more complex scenarios.
314357

315358
## AssemblerQueryService

documentation/docs/concepts/services.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ The following methods are defined on the `QueryService`
1212
* find a record by its id.
1313
* `getById(id: string | number): Promise<DTO>`
1414
* get a record by its id or return a rejected promise with a NotFound error.
15-
* `createMany<C extends DeepPartial<DTO>>(items: C[]): Promise<DTO[]>`
15+
* `createMany(items: DeepPartial<DTO>[]): Promise<DTO[]>`
1616
* create multiple records.
17-
* `createOne<C extends DeepPartial<DTO>>(item: C): Promise<DTO>`
17+
* `createOne(item: DeepPartial<DTO>): Promise<DTO>`
1818
* create one record.
19-
* `updateMany<U extends DeepPartial<DTO>>(update: U, filter: Filter<DTO>): Promise<UpdateManyResponse>`
19+
* `updateMany(update: DeepPartial<DTO>, filter: Filter<DTO>): Promise<UpdateManyResponse>`
2020
* update many records.
21-
* `updateOne<U extends DeepPartial<DTO>>(id: string | number, update: U): Promise<DTO>`
21+
* `updateOne(id: string | number, update: DeepPartial<DTO>): Promise<DTO>`
2222
* update a single record.
2323
* `deleteMany(filter: Filter<DTO>): Promise<DeleteManyResponse>`
2424
* delete multiple records.

examples/custom-service/e2e/todo-item.resolver.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
252252
query: `mutation {
253253
createOneTodoItem(
254254
input: {
255-
todoItem: { name: "Test Todo", completed: false }
255+
todoItem: { name: "Test Todo", isCompleted: false }
256256
}
257257
) {
258258
id
@@ -281,7 +281,7 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
281281
query: `mutation {
282282
createOneTodoItem(
283283
input: {
284-
todoItem: { name: "Test Todo with a too long title!", completed: false }
284+
todoItem: { name: "Test Todo with a too long title!", isCompleted: false }
285285
}
286286
) {
287287
id
@@ -309,8 +309,8 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
309309
createManyTodoItems(
310310
input: {
311311
todoItems: [
312-
{ name: "Many Test Todo 1", completed: false },
313-
{ name: "Many Test Todo 2", completed: true }
312+
{ name: "Many Test Todo 1", isCompleted: false },
313+
{ name: "Many Test Todo 2", isCompleted: true }
314314
]
315315
}
316316
) {
@@ -339,7 +339,7 @@ describe('TodoItemResolver (custom-service - e2e)', () => {
339339
query: `mutation {
340340
createManyTodoItems(
341341
input: {
342-
todoItems: [{ name: "Test Todo With A Really Long Title", completed: false }]
342+
todoItems: [{ name: "Test Todo With A Really Long Title", isCompleted: false }]
343343
}
344344
) {
345345
id

examples/custom-service/src/todo-item/dto/todo-item-input.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ export class TodoItemInputDTO {
1010

1111
@IsBoolean()
1212
@Field()
13-
completed!: boolean;
13+
isCompleted!: boolean;
1414
}

examples/custom-service/src/todo-item/todo-item.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ export class TodoItemService extends NoOpQueryService<TodoItemDTO, TodoItemInput
1717
super();
1818
}
1919

20-
createOne({ name, ...item }: TodoItemInputDTO): Promise<TodoItemDTO> {
21-
return this.queryService.createOne({ title: name, ...item });
20+
createOne({ name: title, isCompleted: completed }: TodoItemInputDTO): Promise<TodoItemDTO> {
21+
return this.queryService.createOne({ title, completed });
2222
}
2323

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

packages/core/__tests__/assemblers/abstract.assembler.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AggregateResponse,
88
transformAggregateQuery,
99
transformAggregateResponse,
10+
DeepPartial,
1011
} from '../../src';
1112

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

2526
@Assembler(TestDTO, TestEntity)
2627
class TestAssembler extends AbstractAssembler<TestDTO, TestEntity> {
28+
convertToCreateEntity(create: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
29+
return {
30+
first: create.firstName,
31+
last: create.lastName,
32+
};
33+
}
34+
35+
convertToUpdateEntity(update: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
36+
return {
37+
first: update.firstName,
38+
last: update.lastName,
39+
};
40+
}
41+
2742
convertToDTO(entity: TestEntity): TestDTO {
2843
return {
2944
firstName: entity.first,

packages/core/__tests__/services/assembler-query.service.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AggregateQuery,
55
AggregateResponse,
66
AssemblerQueryService,
7+
DeepPartial,
78
Query,
89
QueryService,
910
transformAggregateQuery,
@@ -54,6 +55,14 @@ describe('AssemblerQueryService', () => {
5455
bar: 'foo',
5556
});
5657
}
58+
59+
convertToCreateEntity(create: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
60+
return { bar: create.foo };
61+
}
62+
63+
convertToUpdateEntity(update: DeepPartial<TestDTO>): DeepPartial<TestEntity> {
64+
return { bar: update.foo };
65+
}
5766
}
5867

5968
describe('query', () => {

packages/core/src/assemblers/abstract.assembler.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Class } from '../common';
1+
import { Class, DeepPartial } from '../common';
22
import { AggregateQuery, Query, AggregateResponse } from '../interfaces';
33
import { Assembler, getAssemblerClasses } from './assembler';
44

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

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

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

46+
abstract convertToCreateEntity(create: C): CE;
47+
48+
abstract convertToUpdateEntity(update: U): UE;
49+
4550
convertToDTOs(entities: Entity[]): DTO[] {
4651
return entities.map((e) => this.convertToDTO(e));
4752
}
@@ -50,6 +55,10 @@ export abstract class AbstractAssembler<DTO, Entity> implements Assembler<DTO, E
5055
return dtos.map((dto) => this.convertToEntity(dto));
5156
}
5257

58+
convertToCreateEntities(createDtos: C[]): CE[] {
59+
return createDtos.map((c) => this.convertToCreateEntity(c));
60+
}
61+
5362
async convertAsyncToDTO(entity: Promise<Entity>): Promise<DTO> {
5463
const e = await entity;
5564
return this.convertToDTO(e);
Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1-
import { Class } from '../common';
1+
import { Class, DeepPartial } from '../common';
22
import { Assembler, getAssembler } from './assembler';
33
import { DefaultAssembler } from './default.assembler';
44

55
/**
66
* Assembler Service used by query services to look up Assemblers.
77
*/
88
export class AssemblerFactory {
9-
static getAssembler<From, To>(FromClass: Class<From>, ToClass: Class<To>): Assembler<From, To> {
10-
const AssemblerClass = getAssembler(FromClass, ToClass);
9+
static getAssembler<From, To, C = DeepPartial<From>, CE = DeepPartial<To>, U = C, UE = CE>(
10+
FromClass: Class<From>,
11+
ToClass: Class<To>,
12+
): Assembler<From, To, C, CE, U, UE> {
13+
const AssemblerClass = getAssembler<From, To, C, CE, U, UE>(FromClass, ToClass);
1114
if (AssemblerClass) {
1215
return new AssemblerClass();
1316
}
14-
return new DefaultAssembler(FromClass, ToClass);
17+
const defaultAssember = new DefaultAssembler(FromClass, ToClass);
18+
// if its a default just assume the types can be converted for all types
19+
return (defaultAssember as unknown) as Assembler<From, To, C, CE, U, UE>;
1520
}
1621
}

packages/core/src/assemblers/assembler.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { Class, MapReflector, MetaValue, ValueReflector } from '../common';
1+
import { Class, DeepPartial, MapReflector, MetaValue, ValueReflector } from '../common';
22
import { AggregateQuery, AggregateResponse, Query } from '../interfaces';
33
import { ASSEMBLER_CLASSES_KEY, ASSEMBLER_KEY } from './constants';
44

5-
export interface Assembler<DTO, Entity> {
5+
export interface Assembler<
6+
DTO,
7+
Entity,
8+
CreateDTO = DeepPartial<DTO>,
9+
CreateEntity = DeepPartial<Entity>,
10+
UpdateDTO = CreateDTO,
11+
UpdateEntity = CreateEntity
12+
> {
613
/**
714
* Convert an entity to a DTO
815
* @param entity - the entity to convert
@@ -33,6 +40,18 @@ export interface Assembler<DTO, Entity> {
3340
*/
3441
convertAggregateResponse(aggregate: AggregateResponse<Entity>): AggregateResponse<DTO>;
3542

43+
/**
44+
* Convert a create dto input to the equivalent create entity type
45+
* @param createDTO
46+
*/
47+
convertToCreateEntity(createDTO: CreateDTO): CreateEntity;
48+
49+
/**
50+
* Convert a update dto input to the equivalent update entity type
51+
* @param createDTO
52+
*/
53+
convertToUpdateEntity(createDTO: UpdateDTO): UpdateEntity;
54+
3655
/**
3756
* Convert an array of entities to a an of DTOs
3857
* @param entities - the entities to convert.
@@ -66,6 +85,12 @@ export interface Assembler<DTO, Entity> {
6685
* @param dtos - the promise that should resolve with the dtos.
6786
*/
6887
convertAsyncToEntities(dtos: Promise<DTO[]>): Promise<Entity[]>;
88+
89+
/**
90+
* Convert an array of create DTOs to an array of create entities
91+
* @param createDtos
92+
*/
93+
convertToCreateEntities(createDtos: CreateDTO[]): CreateEntity[];
6994
}
7095

7196
const assemblerReflector = new ValueReflector(ASSEMBLER_CLASSES_KEY);
@@ -81,8 +106,15 @@ export interface AssemblerClasses<DTO, Entity> {
81106
* @param DTOClass - the DTO class.
82107
* @param EntityClass - The entity class.
83108
*/
84-
export function Assembler<DTO, Entity>(DTOClass: Class<DTO>, EntityClass: Class<Entity>) {
85-
return <Cls extends Class<Assembler<DTO, Entity>>>(cls: Cls): Cls | void => {
109+
export function Assembler<
110+
DTO,
111+
Entity,
112+
C = DeepPartial<DTO>,
113+
CE = DeepPartial<Entity>,
114+
U = DeepPartial<DTO>,
115+
UE = DeepPartial<Entity>
116+
>(DTOClass: Class<DTO>, EntityClass: Class<Entity>) {
117+
return <Cls extends Class<Assembler<DTO, Entity, C, CE, U, UE>>>(cls: Cls): Cls | void => {
86118
if (reflector.has(DTOClass, EntityClass)) {
87119
throw new Error(`Assembler already registered for ${DTOClass.name} ${EntityClass.name}`);
88120
}
@@ -92,15 +124,15 @@ export function Assembler<DTO, Entity>(DTOClass: Class<DTO>, EntityClass: Class<
92124
};
93125
}
94126

95-
export function getAssembler<DTO, Entity>(
127+
export function getAssembler<DTO, Entity, C, CE, U, UE>(
96128
DTOClass: Class<DTO>,
97129
EntityClass: Class<Entity>,
98-
): MetaValue<Class<Assembler<DTO, Entity>>> {
130+
): MetaValue<Class<Assembler<DTO, Entity, C, CE, U, UE>>> {
99131
return reflector.get(DTOClass, EntityClass);
100132
}
101133

102-
export function getAssemblerClasses<DTO, Entity>(
103-
AssemblerClass: Class<Assembler<DTO, Entity>>,
134+
export function getAssemblerClasses<DTO, Entity, C, CE, U, UE>(
135+
AssemblerClass: Class<Assembler<DTO, Entity, C, CE, U, UE>>,
104136
): MetaValue<AssemblerClasses<DTO, Entity>> {
105137
return assemblerReflector.get(AssemblerClass);
106138
}

0 commit comments

Comments
 (0)