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/)
|
- Pagination conforms to [JSON:API](https://jsonapi.org/)
|
||||||
- Sort by multiple columns
|
- Sort by multiple columns
|
||||||
- Search across columns
|
- Search across columns
|
||||||
|
- Select columns
|
||||||
- Filter using operators (`$eq`, `$not`, `$null`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$btw`, `$ilike`, `$sw`)
|
- Filter using operators (`$eq`, `$not`, `$null`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$btw`, `$ilike`, `$sw`)
|
||||||
- Include relations
|
- Include relations
|
||||||
- Virtual column support
|
- Virtual column support
|
||||||
@ -32,7 +33,7 @@ The following code exposes a route that can be utilized like so:
|
|||||||
#### Endpoint
|
#### Endpoint
|
||||||
|
|
||||||
```url
|
```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
|
#### Result
|
||||||
|
@ -61,6 +61,7 @@ describe('Decorator', () => {
|
|||||||
search: undefined,
|
search: undefined,
|
||||||
searchBy: undefined,
|
searchBy: undefined,
|
||||||
filter: undefined,
|
filter: undefined,
|
||||||
|
select: undefined,
|
||||||
path: 'http://localhost/items',
|
path: 'http://localhost/items',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -77,6 +78,7 @@ describe('Decorator', () => {
|
|||||||
search: undefined,
|
search: undefined,
|
||||||
searchBy: undefined,
|
searchBy: undefined,
|
||||||
filter: undefined,
|
filter: undefined,
|
||||||
|
select: undefined,
|
||||||
path: 'http://localhost/items',
|
path: 'http://localhost/items',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -89,6 +91,7 @@ describe('Decorator', () => {
|
|||||||
search: 'white',
|
search: 'white',
|
||||||
'filter.name': '$not:$eq:Kitty',
|
'filter.name': '$not:$eq:Kitty',
|
||||||
'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'],
|
'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'],
|
||||||
|
select: ['name', 'createdAt'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const result: PaginateQuery = decoratorfactory(null, context)
|
const result: PaginateQuery = decoratorfactory(null, context)
|
||||||
@ -102,6 +105,7 @@ describe('Decorator', () => {
|
|||||||
],
|
],
|
||||||
search: 'white',
|
search: 'white',
|
||||||
searchBy: undefined,
|
searchBy: undefined,
|
||||||
|
select: ['name', 'createdAt'],
|
||||||
path: 'http://localhost/items',
|
path: 'http://localhost/items',
|
||||||
filter: {
|
filter: {
|
||||||
name: '$not:$eq:Kitty',
|
name: '$not:$eq:Kitty',
|
||||||
@ -118,6 +122,7 @@ describe('Decorator', () => {
|
|||||||
search: 'white',
|
search: 'white',
|
||||||
'filter.name': '$not:$eq:Kitty',
|
'filter.name': '$not:$eq:Kitty',
|
||||||
'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'],
|
'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'],
|
||||||
|
select: ['name', 'createdAt'],
|
||||||
})
|
})
|
||||||
|
|
||||||
const result: PaginateQuery = decoratorfactory(null, context)
|
const result: PaginateQuery = decoratorfactory(null, context)
|
||||||
@ -136,6 +141,7 @@ describe('Decorator', () => {
|
|||||||
name: '$not:$eq:Kitty',
|
name: '$not:$eq:Kitty',
|
||||||
createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'],
|
createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'],
|
||||||
},
|
},
|
||||||
|
select: ['name', 'createdAt'],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -9,15 +9,43 @@ export interface PaginateQuery {
|
|||||||
searchBy?: string[]
|
searchBy?: string[]
|
||||||
search?: string
|
search?: string
|
||||||
filter?: { [column: string]: string | string[] }
|
filter?: { [column: string]: string | string[] }
|
||||||
|
select?: string[]
|
||||||
path: 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 => {
|
export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionContext): PaginateQuery => {
|
||||||
const request: Request = ctx.switchToHttp().getRequest()
|
const request: Request = ctx.switchToHttp().getRequest()
|
||||||
const { query } = request
|
const { query } = request
|
||||||
|
|
||||||
// Determine if Express or Fastify to rebuild the original url and reduce down to protocol, host and base url
|
// 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) {
|
if (request.originalUrl) {
|
||||||
originalUrl = request.protocol + '://' + request.get('host') + request.originalUrl
|
originalUrl = request.protocol + '://' + request.get('host') + request.originalUrl
|
||||||
} else {
|
} else {
|
||||||
@ -26,29 +54,9 @@ export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionCont
|
|||||||
const urlParts = new URL(originalUrl)
|
const urlParts = new URL(originalUrl)
|
||||||
const path = urlParts.protocol + '//' + urlParts.host + urlParts.pathname
|
const path = urlParts.protocol + '//' + urlParts.host + urlParts.pathname
|
||||||
|
|
||||||
const sortBy: [string, string][] = []
|
const searchBy = parseParam<string>(query.searchBy, singleSplit)
|
||||||
const searchBy: string[] = []
|
const sortBy = parseParam<[string, string]>(query.sortBy, multipleSplit)
|
||||||
|
const select = parseParam<string>(query.select, multipleAndCommaSplit)
|
||||||
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 filter = mapKeys(
|
const filter = mapKeys(
|
||||||
pickBy(
|
pickBy(
|
||||||
@ -63,10 +71,11 @@ export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionCont
|
|||||||
return {
|
return {
|
||||||
page: query.page ? parseInt(query.page.toString(), 10) : undefined,
|
page: query.page ? parseInt(query.page.toString(), 10) : undefined,
|
||||||
limit: query.limit ? parseInt(query.limit.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,
|
search: query.search ? query.search.toString() : undefined,
|
||||||
searchBy: searchBy.length ? searchBy : undefined,
|
searchBy,
|
||||||
filter: Object.keys(filter).length ? filter : undefined,
|
filter: Object.keys(filter).length ? filter : undefined,
|
||||||
|
select,
|
||||||
path,
|
path,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -102,16 +102,9 @@ export function addWhereCondition<T>(qb: SelectQueryBuilder<T>, column: string,
|
|||||||
const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties)
|
const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties)
|
||||||
const isRelation = checkIsRelation(qb, columnProperties.propertyPath)
|
const isRelation = checkIsRelation(qb, columnProperties.propertyPath)
|
||||||
const isEmbedded = checkIsEmbedded(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) => {
|
filter[column].forEach((columnFilter: Filter, index: number) => {
|
||||||
const columnNamePerIteration = `${column}${index}`
|
const columnNamePerIteration = `${column}${index}`
|
||||||
const alias = fixColumnAlias(
|
|
||||||
columnProperties,
|
|
||||||
qb.alias,
|
|
||||||
isRelation,
|
|
||||||
isVirtualProperty,
|
|
||||||
isEmbedded,
|
|
||||||
virtualQuery
|
|
||||||
)
|
|
||||||
const condition = generatePredicateCondition(qb, column, columnFilter, alias, isVirtualProperty)
|
const condition = generatePredicateCondition(qb, column, columnFilter, alias, isVirtualProperty)
|
||||||
const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, {
|
const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, {
|
||||||
[columnNamePerIteration]: columnFilter.findOperator.value,
|
[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 () => {
|
it('should return the right amount of results if a many to many relation is involved', async () => {
|
||||||
const config: PaginateConfig<CatEntity> = {
|
const config: PaginateConfig<CatEntity> = {
|
||||||
sortableColumns: ['id'],
|
sortableColumns: ['id'],
|
||||||
|
@ -167,16 +167,20 @@ export async function paginate<T extends ObjectLiteral>(
|
|||||||
queryBuilder.addOrderBy(alias, order[1], nullSort)
|
queryBuilder.addOrderBy(alias, order[1], nullSort)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.select?.length > 0) {
|
// When we partial select the columns (main or relation) we must add the primary key column otherwise
|
||||||
const mappedSelect = config.select.map((col) => {
|
// typeorm will not be able to map the result TODO: write it in the docs
|
||||||
if (col.includes('.')) {
|
const selectParams = config.select || query.select
|
||||||
const [rel, relCol] = col.split('.')
|
if (selectParams?.length > 0) {
|
||||||
return `${queryBuilder.alias}_${rel}.${relCol}`
|
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 cols
|
||||||
return `${queryBuilder.alias}.${col}`
|
}, [])
|
||||||
})
|
queryBuilder.select(cols)
|
||||||
queryBuilder.select(mappedSelect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.where) {
|
if (config.where) {
|
||||||
|
Loading…
Reference in New Issue
Block a user