feat: multi filter with $and/$or comperator (#457)

This commit is contained in:
xMase 2023-02-09 09:21:09 +01:00 committed by GitHub
parent 38780469e0
commit 70c24b00ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 474 additions and 212 deletions

View File

@ -301,7 +301,7 @@ const config: PaginateConfig<CatEntity> = {
const result = await paginate<CatEntity>(query, catRepo, config)
```
## Filters
## Single Filters
Filter operators must be whitelisted per column in `PaginateConfig`.
@ -323,6 +323,30 @@ Filter operators must be whitelisted per column in `PaginateConfig`.
`?filter.createdAt=$btw:2022-02-02,2022-02-10` where column `createdAt` is between the dates `2022-02-02` and `2022-02-10`
## Multi Filters
Multi filters are filters that can be applied to a single column with a comparator. As for single filters, multi filters must be whitelisted per column in `PaginateConfig`.
### Examples
`?filter.id=$gt:3&filter.id=$lt:5` where column `id` is greater than `3` **and** less than `5`
`?filter.id=$gt:3&filter.id=$or$lt:5` where column `id` is greater than `3` **or** less than `5`
`?filter.id=$gt:3&filter.id=$and$lt:5&filter.id=$or$eq:7` where column `id` is greater than `3` **and** less than `5` **or** equal to `7`
**Note:** the `and` operators are not required. The above example is equivalent to:
`?filter.id=$gt:3&filter.id=$lt:5&filter.id=$or$eq:7`
**Note:** the first comparator on the the first filter is ingnored becouse the filters are grouped by the column name and chained with an `and` to other filters.
`...&filter.id=5&filter.id=$or:7&filter.name=Milo&...`
is resolved to:
`WHERE ... AND (id = 5 OR id = 7) AND name = 'Milo' AND ...`
## Troubleshooting
The package does not report error reasons in the response bodies. They are instead

View File

@ -1,25 +1,31 @@
import { FindOperator, SelectQueryBuilder } from 'typeorm'
import { Brackets, FindOperator, SelectQueryBuilder } from 'typeorm'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
import { PaginateQuery } from './decorator'
import { checkIsRelation, extractVirtualProperty, fixColumnAlias, getPropertiesByColumnName } from './helper'
import {
FilterComparator,
FilterOperator,
FilterSuffix,
isComparator,
isOperator,
isSuffix,
OperatorSymbolToFunction,
} from './operator'
import { PaginateConfig } from './paginate'
export type Filter = { [columnName: string]: FindOperator<string> }
type Filter = { comparator: FilterComparator; findOperator: FindOperator<string> }
type ColumnsFilters = { [columnName: string]: Filter[] }
export function generatePredicateCondition(
qb: SelectQueryBuilder<unknown>,
column: string,
filter: Filter,
alias: string,
isVirtualProperty = false
): WherePredicateOperator {
return qb['getWherePredicateCondition'](
isVirtualProperty ? column : alias,
filter[column]
) as WherePredicateOperator
export interface FilterToken {
comparator: FilterComparator
suffix?: FilterSuffix
operator: FilterOperator
value: string
}
// This function is used to fix the query parameters when using relation, embeded or virtual properties
// It will replace the column name with the alias name and return the new parameters
function fixQueryParam(
export function fixQueryParam(
alias: string,
column: string,
filter: Filter,
@ -41,8 +47,8 @@ function fixQueryParam(
case 'between':
condition_params = [alias, `:${column}_from`, `:${column}_to`]
params = {
[column + '_from']: filter[column].value[0],
[column + '_to']: filter[column].value[1],
[column + '_from']: filter.findOperator.value[0],
[column + '_to']: filter.findOperator.value[1],
}
break
case 'in':
@ -72,14 +78,160 @@ function fixQueryParam(
return params
}
export function addWhereCondition(qb: SelectQueryBuilder<unknown>, column: string, filter: Filter) {
export function generatePredicateCondition(
qb: SelectQueryBuilder<unknown>,
column: string,
filter: Filter,
alias: string,
isVirtualProperty = false
): WherePredicateOperator {
return qb['getWherePredicateCondition'](
isVirtualProperty ? column : alias,
filter.findOperator
) as WherePredicateOperator
}
export function addWhereCondition<T>(qb: SelectQueryBuilder<T>, column: string, filter: ColumnsFilters) {
const columnProperties = getPropertiesByColumnName(column)
const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties)
const isRelation = checkIsRelation(qb, columnProperties.propertyPath)
filter[column].forEach((columnFilter: Filter, index: number) => {
const columnNamePerIteration = `${column}${index}`
const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, virtualQuery)
const condition = generatePredicateCondition(qb, column, filter, alias, isVirtualProperty)
const parameters = fixQueryParam(alias, column, filter, condition, {
[column]: filter[column].value,
const condition = generatePredicateCondition(qb, column, columnFilter, alias, isVirtualProperty)
const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, {
[columnNamePerIteration]: columnFilter.findOperator.value,
})
if (columnFilter.comparator === FilterComparator.OR) {
qb.orWhere(qb['createWhereConditionExpression'](condition), parameters)
} else {
qb.andWhere(qb['createWhereConditionExpression'](condition), parameters)
}
})
}
export function getFilterTokens(raw?: string): FilterToken | null {
if (raw === undefined || raw === null) {
return null
}
const token: FilterToken = {
comparator: FilterComparator.AND,
suffix: undefined,
operator: FilterOperator.EQ,
value: raw,
}
const MAX_OPERTATOR = 4 // max 4 operator es: $and:$not:$eq:$null
const OPERAND_SEPARATOR = ':'
const matches = raw.split(OPERAND_SEPARATOR)
const maxOperandCount = matches.length > MAX_OPERTATOR ? MAX_OPERTATOR : matches.length
const notValue: (FilterOperator | FilterSuffix | FilterComparator)[] = []
for (let i = 0; i < maxOperandCount; i++) {
const match = matches[i]
if (isComparator(match)) {
token.comparator = match
} else if (isSuffix(match)) {
token.suffix = match
} else if (isOperator(match)) {
token.operator = match
} else {
break
}
notValue.push(match)
}
if (notValue.length) {
token.value =
token.operator === FilterOperator.NULL
? undefined
: raw.replace(`${notValue.join(OPERAND_SEPARATOR)}${OPERAND_SEPARATOR}`, '')
}
return token
}
export function parseFilter<T>(
query: PaginateQuery,
filterableColumns?: PaginateConfig<T>['filterableColumns']
): ColumnsFilters {
const filter: ColumnsFilters = {}
if (!filterableColumns || !query.filter) {
return {}
}
for (const column of Object.keys(query.filter)) {
if (!(column in filterableColumns)) {
continue
}
const allowedOperators = filterableColumns[column]
const input = query.filter[column]
const statements = !Array.isArray(input) ? [input] : input
for (const raw of statements) {
const token = getFilterTokens(raw)
if (
!token ||
!(
allowedOperators.includes(token.operator) ||
(token.suffix === FilterSuffix.NOT &&
allowedOperators.includes(token.suffix) &&
token.operator === FilterOperator.EQ) ||
(token.suffix &&
allowedOperators.includes(token.suffix) &&
allowedOperators.includes(token.operator))
)
) {
continue
}
const params: (typeof filter)[0][0] = {
comparator: token.comparator,
findOperator: undefined,
}
switch (token.operator) {
case FilterOperator.BTW:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(...token.value.split(','))
break
case FilterOperator.IN:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value.split(','))
break
case FilterOperator.ILIKE:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(`%${token.value}%`)
break
case FilterOperator.SW:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(`${token.value}%`)
break
default:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value)
}
filter[column] = [...(filter[column] || []), params]
if (token.suffix) {
const lastFilterElement = filter[column].length - 1
filter[column][lastFilterElement].findOperator = OperatorSymbolToFunction.get(token.suffix)(
filter[column][lastFilterElement].findOperator
)
}
}
}
return filter
}
export function addFilter<T>(
qb: SelectQueryBuilder<T>,
query: PaginateQuery,
filterableColumns?: PaginateConfig<T>['filterableColumns']
): SelectQueryBuilder<T> {
const filter = parseFilter(query, filterableColumns)
return qb.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column in filter) {
addWhereCondition(qb, column, filter)
}
})
)
}

View File

@ -33,7 +33,7 @@ export type SortBy<T> = Order<T>[]
export const positiveNumberOrDefault = (value: number | undefined, defaultValue: number, minValue: 0 | 1 = 0) =>
value === undefined || value < minValue ? defaultValue : value
type ColumnProperties = { propertyPath?: string; propertyName: string }
export type ColumnProperties = { propertyPath?: string; propertyName: string }
export function getPropertiesByColumnName(column: string): ColumnProperties {
const propertyPath = column.split('.')

65
src/operator.ts Normal file
View File

@ -0,0 +1,65 @@
import { values } from 'lodash'
import {
Equal,
FindOperator,
In,
MoreThan,
MoreThanOrEqual,
IsNull,
LessThan,
LessThanOrEqual,
Between,
ILike,
Not,
} from 'typeorm'
export enum FilterOperator {
EQ = '$eq',
GT = '$gt',
GTE = '$gte',
IN = '$in',
NULL = '$null',
LT = '$lt',
LTE = '$lte',
BTW = '$btw',
ILIKE = '$ilike',
SW = '$sw',
}
export function isOperator(value: unknown): value is FilterOperator {
return values(FilterOperator).includes(value as any)
}
export enum FilterSuffix {
NOT = '$not',
}
export function isSuffix(value: unknown): value is FilterSuffix {
return values(FilterSuffix).includes(value as any)
}
export enum FilterComparator {
AND = '$and',
OR = '$or',
}
export function isComparator(value: unknown): value is FilterComparator {
return values(FilterComparator).includes(value as any)
}
export const OperatorSymbolToFunction = new Map<
FilterOperator | FilterSuffix,
(...args: any[]) => FindOperator<string>
>([
[FilterOperator.EQ, Equal],
[FilterOperator.GT, MoreThan],
[FilterOperator.GTE, MoreThanOrEqual],
[FilterOperator.IN, In],
[FilterOperator.NULL, IsNull],
[FilterOperator.LT, LessThan],
[FilterOperator.LTE, LessThanOrEqual],
[FilterOperator.BTW, Between],
[FilterOperator.ILIKE, ILike],
[FilterSuffix.NOT, Not],
[FilterOperator.SW, ILike],
])

View File

@ -1,20 +1,20 @@
import { Repository, In, DataSource } from 'typeorm'
import {
Paginated,
paginate,
PaginateConfig,
FilterOperator,
isOperator,
getFilterTokens,
OperatorSymbolToFunction,
NO_PAGINATION,
} from './paginate'
import { Paginated, paginate, PaginateConfig, NO_PAGINATION } from './paginate'
import { PaginateQuery } from './decorator'
import { HttpException } from '@nestjs/common'
import { CatEntity } from './__tests__/cat.entity'
import { CatToyEntity } from './__tests__/cat-toy.entity'
import { CatHomeEntity } from './__tests__/cat-home.entity'
import { clone } from 'lodash'
import {
FilterComparator,
FilterOperator,
FilterSuffix,
isOperator,
isSuffix,
OperatorSymbolToFunction,
} from './operator'
import { getFilterTokens } from './filter'
describe('paginate', () => {
let dataSource: DataSource
@ -586,7 +586,7 @@ describe('paginate', () => {
color: 'white',
},
filterableColumns: {
name: [FilterOperator.NOT],
name: [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -601,6 +601,7 @@ describe('paginate', () => {
expect(result.meta.filter).toStrictEqual({
name: '$not:Leche',
})
expect(result.data).toStrictEqual([cats[3]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.name=$not:Leche')
})
@ -610,7 +611,7 @@ describe('paginate', () => {
relations: ['cat'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'cat.name': [FilterOperator.NOT],
'cat.name': [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -634,7 +635,7 @@ describe('paginate', () => {
relations: ['toys'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'toys.name': [FilterOperator.NOT],
'toys.name': [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -669,7 +670,7 @@ describe('paginate', () => {
relations: ['cat'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'cat.name': [FilterOperator.NOT],
'cat.name': [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -1025,7 +1026,7 @@ describe('paginate', () => {
sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width'],
searchableColumns: ['size.height'],
filterableColumns: {
'size.height': [FilterOperator.NOT],
'size.height': [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -1046,7 +1047,7 @@ describe('paginate', () => {
sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width'],
searchableColumns: ['size.height'],
filterableColumns: {
'size.height': [FilterOperator.NOT],
'size.height': [FilterSuffix.NOT],
},
relations: ['home'],
}
@ -1078,7 +1079,7 @@ describe('paginate', () => {
relations: ['cat'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'cat.size.height': [FilterOperator.NOT],
'cat.size.height': [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -1108,7 +1109,7 @@ describe('paginate', () => {
const query: PaginateQuery = {
path: '',
filter: {
'toys.size.height': '$eq:1',
'toys.size.height': '1',
},
}
@ -1120,10 +1121,10 @@ describe('paginate', () => {
cat2.toys = [catToys3]
expect(result.meta.filter).toStrictEqual({
'toys.size.height': '$eq:1',
'toys.size.height': '1',
})
expect(result.data).toStrictEqual([cat2])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.toys.size.height=$eq:1')
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.toys.size.height=1')
})
it('should return result based on filter on embedded on one-to-one relation', async () => {
@ -1226,7 +1227,7 @@ describe('paginate', () => {
},
],
filterableColumns: {
name: [FilterOperator.NOT],
name: [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -1249,7 +1250,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
name: [FilterOperator.NOT],
name: [FilterSuffix.NOT],
color: [FilterOperator.EQ],
},
}
@ -1322,7 +1323,7 @@ describe('paginate', () => {
sortableColumns: ['id'],
searchableColumns: ['name', 'color'],
filterableColumns: {
id: [FilterOperator.NOT, FilterOperator.IN],
id: [FilterSuffix.NOT, FilterOperator.IN],
},
}
const query: PaginateQuery = {
@ -1348,7 +1349,7 @@ describe('paginate', () => {
color: In(['black', 'white']),
},
filterableColumns: {
id: [FilterOperator.NOT, FilterOperator.IN],
id: [FilterSuffix.NOT, FilterOperator.IN],
},
}
const query: PaginateQuery = {
@ -1428,7 +1429,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
age: [FilterOperator.NOT, FilterOperator.NULL],
age: [FilterSuffix.NOT, FilterOperator.NULL],
},
}
const query: PaginateQuery = {
@ -1448,7 +1449,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
'home.name': [FilterOperator.NOT, FilterOperator.NULL],
'home.name': [FilterSuffix.NOT, FilterOperator.NULL],
},
relations: ['home'],
}
@ -1474,7 +1475,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
name: [FilterOperator.NOT, FilterOperator.NULL],
name: [FilterSuffix.NOT, FilterOperator.NULL],
},
}
const query: PaginateQuery = {
@ -1494,7 +1495,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
age: [FilterOperator.NOT],
age: [FilterSuffix.NOT],
},
}
const query: PaginateQuery = {
@ -1534,13 +1535,19 @@ describe('paginate', () => {
{ operator: '$lt', result: true },
{ operator: '$lte', result: true },
{ operator: '$btw', result: true },
{ operator: '$not', result: true },
{ operator: '$ilike', result: true },
{ operator: '$fake', result: false },
])('should check operator "$operator" valid is $result', ({ operator, result }) => {
expect(isOperator(operator)).toStrictEqual(result)
})
it.each([{ suffix: '$not', result: true }])(
'should check suffix "$suffix" valid is $result',
({ suffix, result }) => {
expect(isSuffix(suffix)).toStrictEqual(result)
}
)
it.each([
{ operator: '$eq', name: 'Equal' },
{ operator: '$gt', name: 'MoreThan' },
@ -1557,23 +1564,172 @@ describe('paginate', () => {
expect(func.name).toStrictEqual(name)
})
for (const cc of [FilterComparator.AND, FilterComparator.OR, '']) {
const comparator = cc === '' ? FilterComparator.AND : cc
const cSrt = cc === '' ? cc : `${comparator}:`
it.each([
{ string: '$ilike:value', tokens: [null, '$ilike', 'value'] },
{ string: '$eq:value', tokens: [null, '$eq', 'value'] },
{ string: '$eq:val:ue', tokens: [null, '$eq', 'val:ue'] },
{ string: '$in:value1,value2,value3', tokens: [null, '$in', 'value1,value2,value3'] },
{ string: '$not:$in:value1:a,value2:b,value3:c', tokens: ['$not', '$in', 'value1:a,value2:b,value3:c'] },
{ string: 'value', tokens: [null, '$eq', 'value'] },
{ string: 'val:ue', tokens: [null, '$eq', 'val:ue'] },
{ string: '$not:value', tokens: [null, '$not', 'value'] },
{ string: '$eq:$not:value', tokens: ['$eq', '$not', 'value'] },
{ string: '$eq:$null', tokens: ['$eq', '$null'] },
{ string: '$null', tokens: [null, '$null'] },
{ string: '', tokens: [null, '$eq', ''] },
{ string: '$eq:$not:$in:value', tokens: [] },
{
string: cSrt + '$ilike:value',
tokens: { comparator, operator: '$ilike', suffix: undefined, value: 'value' },
},
{ string: cSrt + '$eq:value', tokens: { comparator, operator: '$eq', suffix: undefined, value: 'value' } },
{
string: cSrt + '$eq:val:ue',
tokens: { comparator, operator: '$eq', suffix: undefined, value: 'val:ue' },
},
{
string: cSrt + '$in:value1,value2,value3',
tokens: { comparator, operator: '$in', suffix: undefined, value: 'value1,value2,value3' },
},
{
string: cSrt + '$not:$in:value1:a,value2:b,value3:c',
tokens: { comparator, operator: '$in', suffix: '$not', value: 'value1:a,value2:b,value3:c' },
},
{ string: cSrt + 'value', tokens: { comparator, operator: '$eq', suffix: undefined, value: 'value' } },
{ string: cSrt + 'val:ue', tokens: { comparator, operator: '$eq', suffix: undefined, value: 'val:ue' } },
{ string: cSrt + '$not:value', tokens: { comparator, operator: '$eq', suffix: '$not', value: 'value' } },
{
string: cSrt + '$eq:$not:value',
tokens: { comparator, operator: '$eq', suffix: '$not', value: 'value' },
},
{
string: cSrt + '$eq:$null',
tokens: { comparator, operator: '$null', suffix: undefined, value: undefined },
},
{ string: cSrt + '$null', tokens: { comparator, operator: '$null', suffix: undefined, value: undefined } },
{ string: cSrt + '', tokens: { comparator, operator: '$eq', suffix: undefined, value: '' } },
{
string: cSrt + '$eq:$not:$in:value',
tokens: { comparator, operator: '$in', suffix: '$not', value: 'value' },
},
{
string: cSrt + '$eq:$not:value:$in',
tokens: { comparator, operator: '$eq', suffix: '$not', value: 'value:$in' },
},
{
string: cSrt + '$eq:$not:$null:value:$in',
tokens: { comparator, operator: '$null', suffix: '$not', value: undefined },
},
])('should get filter tokens for "$string"', ({ string, tokens }) => {
expect(getFilterTokens(string)).toStrictEqual(tokens)
})
}
it('should return result based on virtualcolumn filter', async () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
'home.countCat': [FilterOperator.GT],
},
relations: ['home'],
}
const query: PaginateQuery = {
path: '',
filter: {
'home.countCat': '$gt:0',
},
sortBy: [['id', 'ASC']],
}
const result = await paginate<CatEntity>(query, catRepo, config)
const expectedResult = [0, 1].map((i) => {
const ret = Object.assign(clone(cats[i]), { home: Object.assign(clone(catHomes[i])) })
delete ret.home.cat
return ret
})
expect(result.data).toStrictEqual(expectedResult)
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.home.countCat=$gt:0')
})
it('should return result sorted by a virtualcolumn', async () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['home.countCat'],
relations: ['home'],
}
const query: PaginateQuery = {
path: '',
sortBy: [['home.countCat', 'ASC']],
}
const result = await paginate<CatEntity>(query, catRepo, config)
const expectedResult = [2, 3, 4, 0, 1].map((i) => {
const ret = clone(cats[i])
if (i == 0 || i == 1) {
ret.home = clone(catHomes[i])
ret.home.countCat = cats.filter((cat) => cat.id === ret.home.cat.id).length
delete ret.home.cat
} else {
ret.home = null
}
return ret
})
expect(result.data).toStrictEqual(expectedResult)
expect(result.links.current).toBe('?page=1&limit=20&sortBy=home.countCat:ASC')
})
it('should return result based on or between range filter', async () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
age: [FilterOperator.BTW],
},
}
const query: PaginateQuery = {
path: '',
filter: {
age: ['$btw:4,5', '$or:$btw:5,6'],
},
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([cats[0], cats[1], cats[2]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$btw:4,5&filter.age=$or:$btw:5,6')
})
it('should return result based on or with all cats', async () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
age: [FilterOperator.BTW],
},
}
const query: PaginateQuery = {
path: '',
filter: {
age: ['$null', '$or:$not:$eq:$null'],
},
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([...cats])
expect(result.links.current).toBe(
'?page=1&limit=20&sortBy=id:ASC&filter.age=$null&filter.age=$or:$not:$eq:$null'
)
})
it('should return result sorted and filter by a virtualcolumn in main entity', async () => {
const config: PaginateConfig<CatHomeEntity> = {
sortableColumns: ['countCat'],
relations: ['cat'],
filterableColumns: {
countCat: [FilterOperator.GT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
countCat: '$gt:0',
},
sortBy: [['countCat', 'ASC']],
}
const result = await paginate<CatHomeEntity>(query, catHomeRepo, config)
expect(result.data).toStrictEqual([catHomes[0], catHomes[1]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=countCat:ASC&filter.countCat=$gt:0')
})
it('should return result based on virtualcolumn filter', async () => {
const config: PaginateConfig<CatEntity> = {

View File

@ -1,24 +1,7 @@
import {
Repository,
SelectQueryBuilder,
FindOperator,
Equal,
MoreThan,
MoreThanOrEqual,
In,
IsNull,
LessThan,
LessThanOrEqual,
Not,
ILike,
Brackets,
Between,
FindOptionsWhere,
ObjectLiteral,
} from 'typeorm'
import { Repository, SelectQueryBuilder, Brackets, FindOptionsWhere, ObjectLiteral } from 'typeorm'
import { PaginateQuery } from './decorator'
import { ServiceUnavailableException, Logger } from '@nestjs/common'
import { values, mapKeys } from 'lodash'
import { mapKeys } from 'lodash'
import { stringify } from 'querystring'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
import {
@ -32,7 +15,8 @@ import {
RelationColumn,
SortBy,
} from './helper'
import { addWhereCondition, Filter } from './filter'
import { FilterOperator, FilterSuffix } from './operator'
import { addFilter } from './filter'
const logger: Logger = new Logger('nestjs-paginate')
@ -67,126 +51,14 @@ export interface PaginateConfig<T> {
defaultSortBy?: SortBy<T>
defaultLimit?: number
where?: FindOptionsWhere<T> | FindOptionsWhere<T>[]
filterableColumns?: { [key in Column<T>]?: FilterOperator[] }
filterableColumns?: {
[key in Column<T>]?: (FilterOperator | FilterSuffix)[]
}
withDeleted?: boolean
relativePath?: boolean
origin?: string
}
export enum FilterOperator {
EQ = '$eq',
GT = '$gt',
GTE = '$gte',
IN = '$in',
NULL = '$null',
LT = '$lt',
LTE = '$lte',
BTW = '$btw',
NOT = '$not',
ILIKE = '$ilike',
SW = '$sw',
}
export function isOperator(value: unknown): value is FilterOperator {
return values(FilterOperator).includes(value as any)
}
export const OperatorSymbolToFunction = new Map<FilterOperator, (...args: any[]) => FindOperator<string>>([
[FilterOperator.EQ, Equal],
[FilterOperator.GT, MoreThan],
[FilterOperator.GTE, MoreThanOrEqual],
[FilterOperator.IN, In],
[FilterOperator.NULL, IsNull],
[FilterOperator.LT, LessThan],
[FilterOperator.LTE, LessThanOrEqual],
[FilterOperator.BTW, Between],
[FilterOperator.NOT, Not],
[FilterOperator.ILIKE, ILike],
[FilterOperator.SW, ILike],
])
export function getFilterTokens(raw: string): string[] {
const tokens = []
const matches = raw.match(/(\$\w+):/g)
if (matches) {
const value = raw.replace(matches.join(''), '')
tokens.push(...matches.map((token) => token.substring(0, token.length - 1)), value)
} else {
tokens.push(raw)
}
if (tokens.length === 0 || tokens.length > 3) {
return []
} else if (tokens.length === 2) {
if (tokens[1] !== FilterOperator.NULL) {
tokens.unshift(null)
}
} else if (tokens.length === 1) {
if (tokens[0] === FilterOperator.NULL) {
tokens.unshift(null)
} else {
tokens.unshift(null, FilterOperator.EQ)
}
}
return tokens
}
function parseFilter<T>(query: PaginateQuery, config: PaginateConfig<T>): Filter {
const filter: Filter = {}
let filterableColumns = config.filterableColumns
if (filterableColumns === undefined) {
logger.debug("No 'filterableColumns' given, ignoring filters.")
filterableColumns = {}
}
for (const column of Object.keys(query.filter)) {
if (!(column in filterableColumns)) {
continue
}
const allowedOperators = filterableColumns[column]
const input = query.filter[column]
const statements = !Array.isArray(input) ? [input] : input
for (const raw of statements) {
const tokens = getFilterTokens(raw)
if (tokens.length === 0) {
continue
}
const [op2, op1, value] = tokens
if (!isOperator(op1) || !allowedOperators.includes(op1)) {
continue
}
if (isOperator(op2) && !allowedOperators.includes(op2)) {
continue
}
if (isOperator(op1)) {
switch (op1) {
case FilterOperator.BTW:
filter[column] = OperatorSymbolToFunction.get(op1)(...value.split(','))
break
case FilterOperator.IN:
filter[column] = OperatorSymbolToFunction.get(op1)(value.split(','))
break
case FilterOperator.ILIKE:
filter[column] = OperatorSymbolToFunction.get(op1)(`%${value}%`)
break
case FilterOperator.SW:
filter[column] = OperatorSymbolToFunction.get(op1)(`${value}%`)
break
default:
filter[column] = OperatorSymbolToFunction.get(op1)(value)
break
}
}
if (isOperator(op2)) {
filter[column] = OperatorSymbolToFunction.get(op2)(filter[column])
}
}
}
return filter
}
export const DEFAULT_MAX_LIMIT = 100
export const DEFAULT_LIMIT = 20
export const NO_PAGINATION = 0
@ -342,14 +214,7 @@ export async function paginate<T extends ObjectLiteral>(
}
if (query.filter) {
const filter = parseFilter(query, config)
queryBuilder.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column in filter) {
addWhereCondition(qb, column, filter)
}
})
)
addFilter(queryBuilder, query, config.filterableColumns)
}
if (isPaginated) {