NestJS에서 TypeORM을 사용할 경우, 검색을 위해서 Input을 받아서, find나 QueryBuilder를 통해서 Where을 넣는경우가 많을 것이다.
이런 경우에 Input에 대한 Class와 Service에서 또 Where이 각각의 컬럼이 어디에 어떻게 연결될 지 정해줘야되는데, 여러 곳에서 사용하다 보니, 설정이 귀찮다.
이번 글은 조금이라도 코드를 줄이고, 중복으로 다시 입력을 없애기 위해서 해보았다.
GraphQL용으로 제작하였다.
@InputType()
export class Where {
@Field(() => String, { nullable: true })
test?: string;
}
const filter = {};
for (const key in where) {
if (key === 'test') {
filter['test'] = Equal(where[key]);
}
}
const test = await this.testRepository.find({
relations: ['company'],
where: filter,
});
기존에는 이런식으로 Field를 지정하여 Type을 정하고, 아래에서 여러종류의 key마다 Equal, LIKE, Between 등의 함수를 사용하여 where 조건을 추가하는 경우가 있다.
이런식으로 다시 key를 찾아서 하니, 일을 2번 하는 느낌이라 조금 변경해보았다.
// src/common/decorators/search-field.decorators.ts
import 'reflect-metadata';
import { Field, FieldOptions, ReturnTypeFunc } from '@nestjs/graphql';
import { SearchType } from '../enum/search-type.enum';
export const SEARCH_TYPE_KEY = 'searchType';
export const SEARCH_FIELD_KEY = 'searchField';
export const JOIN_COLUMN_KEY = 'joinColumn'; // 추가
export interface SearchFieldOptions {
type: SearchType;
targetField?: string;
graphqlType?: ReturnTypeFunc;
joinColumn?: string; // 예: 'user'
}
export function SearchField(
searchOptions: SearchFieldOptions,
fieldOptions: FieldOptions = {},
): PropertyDecorator {
return function (target: any, propertyKey: string) {
const graphqlType = searchOptions.graphqlType ?? (() => String);
Field(graphqlType, { ...fieldOptions, nullable: true })(
target,
propertyKey as string,
);
Reflect.defineMetadata(
SEARCH_TYPE_KEY,
searchOptions.type,
target,
propertyKey,
);
if (searchOptions.targetField) {
Reflect.defineMetadata(
SEARCH_FIELD_KEY,
searchOptions.targetField,
target,
propertyKey,
);
}
// joinColumn 메타데이터 저장 (추가)
if (searchOptions.joinColumn) {
Reflect.defineMetadata(
JOIN_COLUMN_KEY,
searchOptions.joinColumn,
target,
propertyKey,
);
}
};
}
우선 GraphQL에서 사용을 해야하니, Field 의 기능도 그래로 적용 시켜주고, Metadata에서 Type, target, join에 대한 데이터도 쓸수있게 추가를 해주었다.
실제로 사용할때에는
@InputType()
export class Where {
@SearchField(
{
type: SearchType.EQUAL,
graphqlType: () => [String],
},
{ nullable: true },
)
test: string;
}
function isDateRange(value: any): value is { start: Date; end: Date } {
return value && typeof value === 'object' && 'start' in value && 'end' in value;
}
export function searchAndFilter<T extends object>(
where: T,
whereClass: new () => T,
): Record<string, FindOperator<any>> {
const filter: Record<string, FindOperator<any>> = {};
if (!where) return filter;
const prototype = whereClass.prototype;
for (const key in where) {
const value = where[key];
if (value === undefined || value === null) continue;
const searchType = Reflect.getMetadata(SEARCH_TYPE_KEY, prototype, key);
const targetField = Reflect.getMetadata(SEARCH_FIELD_KEY, prototype, key) || key;
const joinColumn = Reflect.getMetadata(JOIN_COLUMN_KEY, prototype, key);
// joinColumn이 있으면 joinColumn.targetField 형식으로 조합
const finalField = joinColumn && targetField ? `${joinColumn}.${targetField}` : targetField;
switch (searchType) {
case SearchType.DATE_BETWEEN:
if (isDateRange(value)) {
filter[finalField] = Between(value.start, value.end);
}
break;
case SearchType.LIKE:
filter[finalField] = Like(`%${String(value)}%`);
break;
case SearchType.IN:
filter[finalField] = In(Array.isArray(value) ? value : [value]);
break;
case SearchType.IS_NULL:
filter[finalField] = IsNull();
break;
case SearchType.EQUAL:
default:
filter[finalField] = value ? Equal(value) : IsNull();
break;
}
}
return filter;
}
이렇게 위의 class처럼 설정을 하고, 그 밑의 함수를 사용하여, metadata에서 데이터를 출력 후 각 메타데이터에 따라 조인이되고, where의 조건을 정하는 함수를 만들수있다.
이렇게 사용할 경우, service단에서의 조건을 줄일 수 있어서 가독성이 좋아지고, 실제로 조건을 걸기위한 중복 코드들이 줄어든다.
하지만 join 후 join의 검색을 하는 경우 find에서 동작하지 않는 문제가 있어서
export function applyWhereFromDecorators<T extends object>(
qb: SelectQueryBuilder<any>,
where: T,
whereClass: new () => T,
): SelectQueryBuilder<any> {
if (!where) return qb;
const prototype = whereClass.prototype;
for (const key in where) {
const value = where[key];
if (value === undefined || value === null) continue;
const searchType = Reflect.getMetadata(SEARCH_TYPE_KEY, prototype, key);
const targetField = Reflect.getMetadata(SEARCH_FIELD_KEY, prototype, key) || key;
const joinColumn = Reflect.getMetadata(JOIN_COLUMN_KEY, prototype, key);
const finalField = joinColumn && targetField ? `${joinColumn}.${targetField}` : targetField;
switch (searchType) {
case SearchType.DATE_BETWEEN:
if (isDateRange(value) && value.start && value.end) {
qb.andWhere(`${finalField} BETWEEN :start AND :end`, {
start: value.start,
end: value.end,
});
}
break;
case SearchType.LIKE:
qb.andWhere(`${finalField} LIKE :${key}`, {
[key]: `%${value}%`,
});
break;
case SearchType.IN:
qb.andWhere(`${finalField} IN (:...${key})`, {
[key]: Array.isArray(value) ? value : [value],
});
break;
case SearchType.IS_NULL:
qb.andWhere(`${finalField} IS NULL`);
break;
case SearchType.EQUAL:
default:
qb.andWhere(`${finalField} = :${key}`, { [key]: value });
break;
}
}
return qb;
}
QueryBuilder에 직접 넣어주는 함수도 따로 만들어서 이제 동작이 된다!
NPM 제공
https://www.npmjs.com/package/@jaeyeong/nestjs-graphql-search-field