feat: select via query param (#463)
This commit is contained in:
parent
9ffd74e87e
commit
dbf6786010
@ -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
|
||||
|
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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'],
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user