feat: select via query param (#463)

This commit is contained in:
xMase 2023-02-15 10:16:35 +01:00 committed by GitHub
parent 9ffd74e87e
commit dbf6786010
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 87 additions and 44 deletions

View File

@ -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

View File

@ -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'],
})
})
})

View File

@ -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<string>(param.split(','))
set.forEach((item) => res.push(item))
}
function parseParam<T>(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<string>(query.searchBy, singleSplit)
const sortBy = parseParam<[string, string]>(query.sortBy, multipleSplit)
const select = parseParam<string>(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,
}
})

View File

@ -102,16 +102,9 @@ export function addWhereCondition<T>(qb: SelectQueryBuilder<T>, 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,

View File

@ -1876,6 +1876,36 @@ describe('paginate', () => {
})
})
it('should return selected columns', async () => {
const config: PaginateConfig<CatEntity> = {
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<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],

View File

@ -167,16 +167,20 @@ export async function paginate<T extends ObjectLiteral>(
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) {