Assemblers
Assemblers are used to translate your DTO into the underlying database type and back.
When to use Assemblers
Section intitulée « When to use Assemblers »In most cases an Assembler will not be required because your Entity and DTO will be the same shape.
The only time you need to define an assembler is when the DTO and Entity are different. The most common scenarios are:
- 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.
- Transform the create or update DTO before being passed to your persistence QueryService
Separation of concerns.
Resolvers
Section intitulée « Resolvers »Your resolvers only concern is dealing with GraphQL and translating the request (a DTO) into something the service cares about.
The resolver should not care about how it is persisted. The underlying Entity could have additional fields that you do not want to expose in your API, or it may be persisted into multiple stores.
By separating the resolver from the persistence layer you can evolve your API separate from your database model.
Services
Section intitulée « Services »The services concern are operating on a DTO, preventing the leaking of persistence details to the API.
In type-graphql-query services can be composed. In the case of assemblers information is translated using the assembler and delegated to an underlying service.
This alleviates any awkwardness around passing in a DTO and receiving a different object type back. Instead, your service can use an assembler to alleviate these concerns.
Assemblers
Section intitulée « Assemblers »The assembler provides a single, testable, place to provide a translation between the DTO and entity, and vice versa.
Why not use the assembler in the resolver?
Section intitulée « Why not use the assembler in the resolver? »The resolvers concern is translating GraphQL requests into the specified DTO.
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.
ClassTransformerAssembler
Section intitulée « ClassTransformerAssembler »In most cases the class-transformer package will properly map back and forth. Because of this there is a ClassTransformerAssembler that leverages the plainToClass method.
If you find yourself in a scenario where you need to compute values and you don’t want to add the business logic to your DTO you can extend the ClassTransformerAssembler.
Let’s take a simple example, where we have ProductDTO and we want to compute the age (time since creation).
import { Assembler, ClassTransformerAssembler } from '@lib/type-graphql-query';import { ProductDTO } from './dto/product.dto';import { ProductEntity } from './product.entity';
// `@Assembler` decorator will register the assembler with `type-graphql-query` and// the QueryService will be able to auto discover it.@Assembler(ProductDTO, ProductEntity)export class ProductAssembler extends ClassTransformerAssembler<ProductDTO, ProductEntity> { convertToDTO(entity: ProductEntity): ProductDTO { const dto = super.convertToDTO(entity); // compute the age in days const ageInMs = Date.now() - entity.createdAt.getTime(); dto.age = Math.floor(ageInMs / (1000 * 60 * 60 * 24)); return dto; }}While this example is fairly trivial, the same pattern should apply for more complex scenarios.
AbstractAssembler
Section intitulée « AbstractAssembler »To create your own Assembler extend the AbstractAssembler.
Let’s assume we have the following UserDTO.
import { EnhancedField, IDField } from '@lib/type-graphql-query';import { ObjectType, ID } from '@lib/type-graphql';
@ObjectType('User')class UserDTO { @IDField(() => ID) id!: string;
@EnhancedField(() => String) firstName!: string;
@EnhancedField(() => String) lastName!: string;
@EnhancedField(() => String) emailAddress!: string;}But you inherited a DB schema that has names that are not as user friendly.
// Example with Drizzleexport const users = pgTable('users', { id: uuid('id').primaryKey(), first: varchar('first', { length: 255 }), last: varchar('last', { length: 255 }), email: varchar('email', { length: 255 }),});
export type UserEntity = typeof users.$inferSelect;To properly translate the UserDTO into the UserEntity and back you can extend an Assembler that the QueryService will use.
import { AbstractAssembler, Assembler, Query, transformQuery, transformAggregateQuery, transformAggregateResponse,} from '@lib/type-graphql-query';import { UserDTO } from './dto/user.dto';import { UserEntity } from './user.entity';
// `@Assembler` decorator will register the assembler with `type-graphql-query` and// the QueryService will be able to auto discover it.@Assembler(UserDTO, UserEntity)export class UserAssembler extends AbstractAssembler<UserDTO, UserEntity> { convertQuery(query: Query<UserDTO>): Query<UserEntity> { return transformQuery(query, { firstName: 'first', lastName: 'last', emailAddress: 'email', }); }
convertToDTO(entity: UserEntity): UserDTO { const dto = new UserDTO(); dto.id = entity.id; dto.firstName = entity.first; dto.lastName = entity.last; dto.emailAddress = entity.email; return dto; }
convertToEntity(dto: UserDTO): UserEntity { const entity: UserEntity = { id: dto.id, first: dto.firstName, last: dto.lastName, email: dto.emailAddress, }; return entity; }
convertAggregateQuery(aggregate: AggregateQuery<UserDTO>): AggregateQuery<UserEntity> { return transformAggregateQuery(aggregate, { firstName: 'first', lastName: 'last', emailAddress: 'email', }); }
convertAggregateResponse(aggregate: AggregateResponse<UserEntity>): AggregateResponse<UserDTO> { return transformAggregateResponse(aggregate, { first: 'firstName', last: 'lastName', email: 'emailAddress', }); }
convertToCreateEntity({ firstName, lastName, emailAddress, }: DeepPartial<UserDTO>): DeepPartial<UserEntity> { return { first: firstName, last: lastName, email: emailAddress, }; }
convertToUpdateEntity({ firstName, lastName, emailAddress, }: DeepPartial<UserDTO>): DeepPartial<UserEntity> { return { first: firstName, last: lastName, email: emailAddress, }; }}The first thing to look at is the @Assembler decorator. It will register the assembler with type-graphql-query so QueryServices can look it up later.
@Assembler(UserDTO, UserEntity)Converting the Query
Section intitulée « Converting the Query »Next the convertQuery method.
convertQuery(query: Query<UserDTO>): Query<UserEntity> { return transformQuery(query, { firstName: 'first', lastName: 'last', emailAddress: 'email', });}This method leverages the transformQuery function from @lib/type-graphql-query. This method will remap all fields specified in the field map to correct field name.
In this example
{ filter: { firstName: { eq: 'Bob' }, lastName: { eq: 'Yukon' }, emailAddress: { eq: 'bob@yukon.com' } }}Would be transformed into.
{ filter: { first: { eq: 'Bob' }, last: { eq: 'Yukon' }, email: { eq: 'bob@yukon.com' } }}Converting the DTO
Section intitulée « Converting the DTO »The next piece is the convertToDTO, which will convert the entity into the correct DTO.
convertToDTO(entity: UserEntity): UserDTO { const dto = new UserDTO(); dto.id = entity.id; dto.firstName = entity.first; dto.lastName = entity.last; dto.emailAddress = entity.email; return dto;}Converting the Entity
Section intitulée « Converting the Entity »The next piece is the convertToEntity, which will convert the DTO into the correct entity.
convertToEntity(dto: UserDTO): UserEntity { const entity: UserEntity = { id: dto.id, first: dto.firstName, last: dto.lastName, email: dto.emailAddress, }; return entity;}Converting Aggregate Query
Section intitulée « Converting Aggregate Query »The convertAggregateQuery is used to convert an AggregateQuery. This example uses the transformAggregateQuery helper to map aggregate query fields.
convertAggregateQuery(aggregate: AggregateQuery<UserDTO>): AggregateQuery<UserEntity> { return transformAggregateQuery(aggregate, { firstName: 'first', lastName: 'last', emailAddress: 'email', });}Converting Aggregate Response
Section intitulée « Converting Aggregate Response »The convertAggregateResponse is used to convert an AggregateResponse. This example uses the transformAggregateResponse helper to map aggregate response fields.
convertAggregateResponse(aggregate: AggregateResponse<UserEntity>): AggregateResponse<UserDTO> { return transformAggregateResponse(aggregate, { first: 'firstName', last: 'lastName', email: 'emailAddress' });}Converting Create DTO
Section intitulée « Converting Create DTO »The convertToCreateEntity is used to convert an incoming create DTO to the appropriate create entity, in this case partial.
convertToCreateEntity({ firstName, lastName, emailAddress }: DeepPartial<UserDTO>): DeepPartial<UserEntity> { return { first: firstName, last: lastName, email: emailAddress, };}Converting Update DTO
Section intitulée « Converting Update DTO »The convertToUpdateEntity is used to convert an incoming update DTO to the appropriate update entity, in this case a partial.
convertToUpdateEntity({ firstName, lastName, emailAddress }: DeepPartial<UserDTO>): DeepPartial<UserEntity> { return { first: firstName, last: lastName, email: emailAddress, };}This is a pretty basic example but the same pattern should apply to more complex scenarios.
AssemblerQueryService
Section intitulée « AssemblerQueryService »An AssemblerQueryService is a special type of QueryService that uses the Assembler to translate between the DTO and Entity.
The AssemblerQueryService wraps an underlying QueryService<Entity> and uses an Assembler<DTO, Entity> to translate all operations between DTOs and Entities.
Using with Resolvers
Section intitulée « Using with Resolvers »When you use an assembler with a resolver, the AssemblerQueryService is automatically created and used. You just need to register your assembler.
Example: Auto-Generated Resolver
Section intitulée « Example: Auto-Generated Resolver »If you want your assembler to be used by the auto-generated resolver, you can specify the AssemblerClass option when building your schema.
import { buildSchema } from '@lib/type-graphql-query';import { UserDTO } from './dto/user.dto';import { UserEntity } from './user.entity';import { UserAssembler } from './user.assembler';
const schema = await buildSchema({ resolvers: [ { DTOClass: UserDTO, EntityClass: UserEntity, AssemblerClass: UserAssembler, // Specify your assembler }, ], // ... other options});Example: Manual Resolver
Section intitulée « Example: Manual Resolver »If you are manually defining your resolver or want to use the AssemblerQueryService in another service, you can inject it using the @InjectAssemblerQueryService decorator.
import { Resolver, Query } from '@lib/type-graphql';import { InjectAssemblerQueryService, QueryService } from '@lib/type-graphql-query';import { UserDTO } from './dto/user.dto';import { UserAssembler } from './user.assembler';
@Resolver(() => UserDTO)export class UserResolver { constructor( @InjectAssemblerQueryService(UserAssembler) readonly service: QueryService<UserDTO>, ) {}
@Query(() => [UserDTO]) async users(): Promise<UserDTO[]> { return this.service.query({ context: this.context, query: {}, }); }}Notice QueryService<UserDTO> - the service works with DTOs, not entities.
How it Works
Section intitulée « How it Works »The AssemblerQueryService automatically:
- Converts queries from DTO field names to Entity field names using
convertQuery - Converts results from Entities to DTOs using
convertToDTO - Converts create/update inputs from DTOs to Entities using
convertToCreateEntity/convertToUpdateEntity - Converts aggregate queries and responses using
convertAggregateQueryandconvertAggregateResponse
All of this happens transparently, so your resolver code only deals with DTOs.
Default Assembler
Section intitulée « Default Assembler »If no assembler is registered for a DTO/Entity pair, type-graphql-query will use a DefaultAssembler which extends ClassTransformerAssembler. This uses class-transformer’s plainToClass to automatically map between DTOs and Entities when they have the same field names.
Interface Methods
Section intitulée « Interface Methods »The Assembler interface defines the following methods:
convertToDTO(entity: Entity): Promise<DTO> | DTO
Section intitulée « convertToDTO(entity: Entity): Promise<DTO> | DTO »Convert an entity to a DTO.
convertToEntity(dto: DTO): Promise<Entity> | Entity
Section intitulée « convertToEntity(dto: DTO): Promise<Entity> | Entity »Convert a DTO to an entity.
convertQuery(query: Query<DTO>): Query<Entity>
Section intitulée « convertQuery(query: Query<DTO>): Query<Entity> »Convert a DTO query to an entity query. This includes transforming filters, sorting, and paging field names.
convertAggregateQuery(aggregate: AggregateQuery<DTO>): AggregateQuery<Entity>
Section intitulée « convertAggregateQuery(aggregate: AggregateQuery<DTO>): AggregateQuery<Entity> »Convert a DTO aggregate query to an entity aggregate query.
convertAggregateResponse(aggregate: AggregateResponse<Entity>): AggregateResponse<DTO>
Section intitulée « convertAggregateResponse(aggregate: AggregateResponse<Entity>): AggregateResponse<DTO> »Convert an entity aggregate response to a DTO aggregate response.
convertToCreateEntity(createDTO: CreateDTO): Promise<CreateEntity> | CreateEntity
Section intitulée « convertToCreateEntity(createDTO: CreateDTO): Promise<CreateEntity> | CreateEntity »Convert a create DTO input to the equivalent create entity type.
convertToUpdateEntity(updateDTO: UpdateDTO): Promise<UpdateEntity> | UpdateEntity
Section intitulée « convertToUpdateEntity(updateDTO: UpdateDTO): Promise<UpdateEntity> | UpdateEntity »Convert an update DTO input to the equivalent update entity type.
convertToDTOs(entities: Entity[]): Promise<DTO[]>
Section intitulée « convertToDTOs(entities: Entity[]): Promise<DTO[]> »Convert an array of entities to an array of DTOs. Default implementation calls convertToDTO for each entity.
convertToEntities(dtos: DTO[]): Promise<Entity[]>
Section intitulée « convertToEntities(dtos: DTO[]): Promise<Entity[]> »Convert an array of DTOs to an array of entities. Default implementation calls convertToEntity for each DTO.
convertToCreateEntities(createDtos: CreateDTO[]): Promise<CreateEntity[]>
Section intitulée « convertToCreateEntities(createDtos: CreateDTO[]): Promise<CreateEntity[]> »Convert an array of create DTOs to an array of create entities. Default implementation calls convertToCreateEntity for each DTO.
Helper Functions
Section intitulée « Helper Functions »transformQuery
Section intitulée « transformQuery »Transforms a query by remapping field names using a field map.
import { transformQuery } from '@lib/type-graphql-query';
const entityQuery = transformQuery(dtoQuery, { firstName: 'first', lastName: 'last', emailAddress: 'email',});transformAggregateQuery
Section intitulée « transformAggregateQuery »Transforms an aggregate query by remapping field names.
import { transformAggregateQuery } from '@lib/type-graphql-query';
const entityAggregate = transformAggregateQuery(dtoAggregate, { firstName: 'first', lastName: 'last',});transformAggregateResponse
Section intitulée « transformAggregateResponse »Transforms an aggregate response by remapping field names.
import { transformAggregateResponse } from '@lib/type-graphql-query';
const dtoAggregate = transformAggregateResponse(entityAggregate, { first: 'firstName', last: 'lastName',});Next Steps
Section intitulée « Next Steps »- QueryService - The service interface that uses assemblers
- DTOs - Data Transfer Objects
- Resolvers - GraphQL resolvers