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/) - 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

View File

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

View File

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

View File

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

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 () => { 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'],

View File

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