From dbf6786010b1c46ae84d08816b0cbbea41071e94 Mon Sep 17 00:00:00 2001 From: xMase <11925311+xMase@users.noreply.github.com> Date: Wed, 15 Feb 2023 10:16:35 +0100 Subject: [PATCH] feat: select via query param (#463) --- README.md | 3 ++- src/decorator.spec.ts | 6 +++++ src/decorator.ts | 61 +++++++++++++++++++++++++------------------ src/filter.ts | 9 +------ src/paginate.spec.ts | 30 +++++++++++++++++++++ src/paginate.ts | 22 +++++++++------- 6 files changed, 87 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 29f0a14..348e0a2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Pagination and filtering helper method for TypeORM repositories or query builder - Pagination conforms to [JSON:API](https://jsonapi.org/) - Sort by multiple columns - Search across columns +- Select columns - Filter using operators (`$eq`, `$not`, `$null`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$btw`, `$ilike`, `$sw`) - Include relations - Virtual column support @@ -32,7 +33,7 @@ The following code exposes a route that can be utilized like so: #### Endpoint ```url -http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i&filter.age=$gte:3 +http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i&filter.age=$gte:3&select=id,name,color,age ``` #### Result diff --git a/src/decorator.spec.ts b/src/decorator.spec.ts index 7e6ace4..f22499b 100644 --- a/src/decorator.spec.ts +++ b/src/decorator.spec.ts @@ -61,6 +61,7 @@ describe('Decorator', () => { search: undefined, searchBy: undefined, filter: undefined, + select: undefined, path: 'http://localhost/items', }) }) @@ -77,6 +78,7 @@ describe('Decorator', () => { search: undefined, searchBy: undefined, filter: undefined, + select: undefined, path: 'http://localhost/items', }) }) @@ -89,6 +91,7 @@ describe('Decorator', () => { search: 'white', 'filter.name': '$not:$eq:Kitty', 'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'], + select: ['name', 'createdAt'], }) const result: PaginateQuery = decoratorfactory(null, context) @@ -102,6 +105,7 @@ describe('Decorator', () => { ], search: 'white', searchBy: undefined, + select: ['name', 'createdAt'], path: 'http://localhost/items', filter: { name: '$not:$eq:Kitty', @@ -118,6 +122,7 @@ describe('Decorator', () => { search: 'white', 'filter.name': '$not:$eq:Kitty', 'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'], + select: ['name', 'createdAt'], }) const result: PaginateQuery = decoratorfactory(null, context) @@ -136,6 +141,7 @@ describe('Decorator', () => { name: '$not:$eq:Kitty', createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'], }, + select: ['name', 'createdAt'], }) }) }) diff --git a/src/decorator.ts b/src/decorator.ts index 984888e..6687245 100644 --- a/src/decorator.ts +++ b/src/decorator.ts @@ -9,15 +9,43 @@ export interface PaginateQuery { searchBy?: string[] search?: string filter?: { [column: string]: string | string[] } + select?: string[] path: string } +const singleSplit = (param: string, res: any[]) => res.push(param) + +const multipleSplit = (param: string, res: any[]) => { + const items = param.split(':') + if (items.length === 2) { + res.push(items as [string, string]) + } +} + +const multipleAndCommaSplit = (param: string, res: any[]) => { + const set = new Set(param.split(',')) + set.forEach((item) => res.push(item)) +} + +function parseParam(queryParam: unknown, parserLogic: (param: string, res: any[]) => void): T[] | undefined { + const res = [] + if (queryParam) { + const params = !Array.isArray(queryParam) ? [queryParam] : queryParam + for (const param of params) { + if (isString(param)) { + parserLogic(param, res) + } + } + } + return res.length ? res : undefined +} + export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionContext): PaginateQuery => { const request: Request = ctx.switchToHttp().getRequest() const { query } = request // Determine if Express or Fastify to rebuild the original url and reduce down to protocol, host and base url - let originalUrl + let originalUrl: any if (request.originalUrl) { originalUrl = request.protocol + '://' + request.get('host') + request.originalUrl } else { @@ -26,29 +54,9 @@ export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionCont const urlParts = new URL(originalUrl) const path = urlParts.protocol + '//' + urlParts.host + urlParts.pathname - const sortBy: [string, string][] = [] - const searchBy: string[] = [] - - if (query.sortBy) { - const params = !Array.isArray(query.sortBy) ? [query.sortBy] : query.sortBy - for (const param of params) { - if (isString(param)) { - const items = param.split(':') - if (items.length === 2) { - sortBy.push(items as [string, string]) - } - } - } - } - - if (query.searchBy) { - const params = !Array.isArray(query.searchBy) ? [query.searchBy] : query.searchBy - for (const param of params) { - if (isString(param)) { - searchBy.push(param) - } - } - } + const searchBy = parseParam(query.searchBy, singleSplit) + const sortBy = parseParam<[string, string]>(query.sortBy, multipleSplit) + const select = parseParam(query.select, multipleAndCommaSplit) const filter = mapKeys( pickBy( @@ -63,10 +71,11 @@ export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionCont return { page: query.page ? parseInt(query.page.toString(), 10) : undefined, limit: query.limit ? parseInt(query.limit.toString(), 10) : undefined, - sortBy: sortBy.length ? sortBy : undefined, + sortBy, search: query.search ? query.search.toString() : undefined, - searchBy: searchBy.length ? searchBy : undefined, + searchBy, filter: Object.keys(filter).length ? filter : undefined, + select, path, } }) diff --git a/src/filter.ts b/src/filter.ts index 6038a07..d656571 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -102,16 +102,9 @@ export function addWhereCondition(qb: SelectQueryBuilder, column: string, const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties) const isRelation = checkIsRelation(qb, columnProperties.propertyPath) const isEmbedded = checkIsEmbedded(qb, columnProperties.propertyPath) + const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, isEmbedded, virtualQuery) filter[column].forEach((columnFilter: Filter, index: number) => { const columnNamePerIteration = `${column}${index}` - const alias = fixColumnAlias( - columnProperties, - qb.alias, - isRelation, - isVirtualProperty, - isEmbedded, - virtualQuery - ) const condition = generatePredicateCondition(qb, column, columnFilter, alias, isVirtualProperty) const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, { [columnNamePerIteration]: columnFilter.findOperator.value, diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index cd623f1..ab8d661 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -1876,6 +1876,36 @@ describe('paginate', () => { }) }) + it('should return selected columns', async () => { + const config: PaginateConfig = { + sortableColumns: ['id', 'name'], + select: ['id', 'name', 'toys.name', 'toys.size.height', 'toys.size.length'], + relations: ['toys'], + } + const query: PaginateQuery = { + path: '', + select: ['id', 'toys.size.height'], + } + + const result = await paginate(query, catRepo, config) + + result.data.forEach((cat) => { + expect(cat.id).toBeDefined() + expect(cat.name).not.toBeDefined() + }) + + result.data.forEach((cat) => { + if (cat.id === 1 || cat.id === 2) { + const toy = cat.toys[0] + expect(toy.name).not.toBeDefined() + expect(toy.id).not.toBeDefined() + expect(toy.size.height).toBeDefined() + } else { + expect(cat.toys).toHaveLength(0) + } + }) + }) + it('should return the right amount of results if a many to many relation is involved', async () => { const config: PaginateConfig = { sortableColumns: ['id'], diff --git a/src/paginate.ts b/src/paginate.ts index d9d773c..89c77c1 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -167,16 +167,20 @@ export async function paginate( queryBuilder.addOrderBy(alias, order[1], nullSort) } - if (config.select?.length > 0) { - const mappedSelect = config.select.map((col) => { - if (col.includes('.')) { - const [rel, relCol] = col.split('.') - return `${queryBuilder.alias}_${rel}.${relCol}` + // When we partial select the columns (main or relation) we must add the primary key column otherwise + // typeorm will not be able to map the result TODO: write it in the docs + const selectParams = config.select || query.select + if (selectParams?.length > 0) { + const cols: string[] = selectParams.reduce((cols, currentCol) => { + if (query.select?.includes(currentCol) ?? true) { + const columnProperties = getPropertiesByColumnName(currentCol) + const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) + // here we can avoid to manually fix and add the query of virtual columns + cols.push(fixColumnAlias(columnProperties, queryBuilder.alias, isRelation)) } - - return `${queryBuilder.alias}.${col}` - }) - queryBuilder.select(mappedSelect) + return cols + }, []) + queryBuilder.select(cols) } if (config.where) {