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) const result = await paginate<CatEntity>(query, catRepo, config)
``` ```
## Filters ## Single Filters
Filter operators must be whitelisted per column in `PaginateConfig`. 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` `?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 ## Troubleshooting
The package does not report error reasons in the response bodies. They are instead 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 { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
import { PaginateQuery } from './decorator'
import { checkIsRelation, extractVirtualProperty, fixColumnAlias, getPropertiesByColumnName } from './helper' 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( export interface FilterToken {
qb: SelectQueryBuilder<unknown>, comparator: FilterComparator
column: string, suffix?: FilterSuffix
filter: Filter, operator: FilterOperator
alias: string, value: string
isVirtualProperty = false
): WherePredicateOperator {
return qb['getWherePredicateCondition'](
isVirtualProperty ? column : alias,
filter[column]
) as WherePredicateOperator
} }
// This function is used to fix the query parameters when using relation, embeded or virtual properties // 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 // It will replace the column name with the alias name and return the new parameters
function fixQueryParam( export function fixQueryParam(
alias: string, alias: string,
column: string, column: string,
filter: Filter, filter: Filter,
@ -41,8 +47,8 @@ function fixQueryParam(
case 'between': case 'between':
condition_params = [alias, `:${column}_from`, `:${column}_to`] condition_params = [alias, `:${column}_from`, `:${column}_to`]
params = { params = {
[column + '_from']: filter[column].value[0], [column + '_from']: filter.findOperator.value[0],
[column + '_to']: filter[column].value[1], [column + '_to']: filter.findOperator.value[1],
} }
break break
case 'in': case 'in':
@ -72,14 +78,160 @@ function fixQueryParam(
return params 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 columnProperties = getPropertiesByColumnName(column)
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)
filter[column].forEach((columnFilter: Filter, index: number) => {
const columnNamePerIteration = `${column}${index}`
const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, virtualQuery) const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, virtualQuery)
const condition = generatePredicateCondition(qb, column, filter, alias, isVirtualProperty) const condition = generatePredicateCondition(qb, column, columnFilter, alias, isVirtualProperty)
const parameters = fixQueryParam(alias, column, filter, condition, { const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, {
[column]: filter[column].value, [columnNamePerIteration]: columnFilter.findOperator.value,
}) })
if (columnFilter.comparator === FilterComparator.OR) {
qb.orWhere(qb['createWhereConditionExpression'](condition), parameters)
} else {
qb.andWhere(qb['createWhereConditionExpression'](condition), parameters) 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) => export const positiveNumberOrDefault = (value: number | undefined, defaultValue: number, minValue: 0 | 1 = 0) =>
value === undefined || value < minValue ? defaultValue : value 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 { export function getPropertiesByColumnName(column: string): ColumnProperties {
const propertyPath = column.split('.') 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 { Repository, In, DataSource } from 'typeorm'
import { import { Paginated, paginate, PaginateConfig, NO_PAGINATION } from './paginate'
Paginated,
paginate,
PaginateConfig,
FilterOperator,
isOperator,
getFilterTokens,
OperatorSymbolToFunction,
NO_PAGINATION,
} from './paginate'
import { PaginateQuery } from './decorator' import { PaginateQuery } from './decorator'
import { HttpException } from '@nestjs/common' import { HttpException } from '@nestjs/common'
import { CatEntity } from './__tests__/cat.entity' import { CatEntity } from './__tests__/cat.entity'
import { CatToyEntity } from './__tests__/cat-toy.entity' import { CatToyEntity } from './__tests__/cat-toy.entity'
import { CatHomeEntity } from './__tests__/cat-home.entity' import { CatHomeEntity } from './__tests__/cat-home.entity'
import { clone } from 'lodash' import { clone } from 'lodash'
import {
FilterComparator,
FilterOperator,
FilterSuffix,
isOperator,
isSuffix,
OperatorSymbolToFunction,
} from './operator'
import { getFilterTokens } from './filter'
describe('paginate', () => { describe('paginate', () => {
let dataSource: DataSource let dataSource: DataSource
@ -586,7 +586,7 @@ describe('paginate', () => {
color: 'white', color: 'white',
}, },
filterableColumns: { filterableColumns: {
name: [FilterOperator.NOT], name: [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -601,6 +601,7 @@ describe('paginate', () => {
expect(result.meta.filter).toStrictEqual({ expect(result.meta.filter).toStrictEqual({
name: '$not:Leche', name: '$not:Leche',
}) })
expect(result.data).toStrictEqual([cats[3]]) expect(result.data).toStrictEqual([cats[3]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.name=$not:Leche') expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.name=$not:Leche')
}) })
@ -610,7 +611,7 @@ describe('paginate', () => {
relations: ['cat'], relations: ['cat'],
sortableColumns: ['id', 'name'], sortableColumns: ['id', 'name'],
filterableColumns: { filterableColumns: {
'cat.name': [FilterOperator.NOT], 'cat.name': [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -634,7 +635,7 @@ describe('paginate', () => {
relations: ['toys'], relations: ['toys'],
sortableColumns: ['id', 'name'], sortableColumns: ['id', 'name'],
filterableColumns: { filterableColumns: {
'toys.name': [FilterOperator.NOT], 'toys.name': [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -669,7 +670,7 @@ describe('paginate', () => {
relations: ['cat'], relations: ['cat'],
sortableColumns: ['id', 'name'], sortableColumns: ['id', 'name'],
filterableColumns: { filterableColumns: {
'cat.name': [FilterOperator.NOT], 'cat.name': [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1025,7 +1026,7 @@ describe('paginate', () => {
sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width'], sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width'],
searchableColumns: ['size.height'], searchableColumns: ['size.height'],
filterableColumns: { filterableColumns: {
'size.height': [FilterOperator.NOT], 'size.height': [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1046,7 +1047,7 @@ describe('paginate', () => {
sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width'], sortableColumns: ['id', 'name', 'size.height', 'size.length', 'size.width'],
searchableColumns: ['size.height'], searchableColumns: ['size.height'],
filterableColumns: { filterableColumns: {
'size.height': [FilterOperator.NOT], 'size.height': [FilterSuffix.NOT],
}, },
relations: ['home'], relations: ['home'],
} }
@ -1078,7 +1079,7 @@ describe('paginate', () => {
relations: ['cat'], relations: ['cat'],
sortableColumns: ['id', 'name'], sortableColumns: ['id', 'name'],
filterableColumns: { filterableColumns: {
'cat.size.height': [FilterOperator.NOT], 'cat.size.height': [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1108,7 +1109,7 @@ describe('paginate', () => {
const query: PaginateQuery = { const query: PaginateQuery = {
path: '', path: '',
filter: { filter: {
'toys.size.height': '$eq:1', 'toys.size.height': '1',
}, },
} }
@ -1120,10 +1121,10 @@ describe('paginate', () => {
cat2.toys = [catToys3] cat2.toys = [catToys3]
expect(result.meta.filter).toStrictEqual({ expect(result.meta.filter).toStrictEqual({
'toys.size.height': '$eq:1', 'toys.size.height': '1',
}) })
expect(result.data).toStrictEqual([cat2]) 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 () => { it('should return result based on filter on embedded on one-to-one relation', async () => {
@ -1226,7 +1227,7 @@ describe('paginate', () => {
}, },
], ],
filterableColumns: { filterableColumns: {
name: [FilterOperator.NOT], name: [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1249,7 +1250,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
filterableColumns: { filterableColumns: {
name: [FilterOperator.NOT], name: [FilterSuffix.NOT],
color: [FilterOperator.EQ], color: [FilterOperator.EQ],
}, },
} }
@ -1322,7 +1323,7 @@ describe('paginate', () => {
sortableColumns: ['id'], sortableColumns: ['id'],
searchableColumns: ['name', 'color'], searchableColumns: ['name', 'color'],
filterableColumns: { filterableColumns: {
id: [FilterOperator.NOT, FilterOperator.IN], id: [FilterSuffix.NOT, FilterOperator.IN],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1348,7 +1349,7 @@ describe('paginate', () => {
color: In(['black', 'white']), color: In(['black', 'white']),
}, },
filterableColumns: { filterableColumns: {
id: [FilterOperator.NOT, FilterOperator.IN], id: [FilterSuffix.NOT, FilterOperator.IN],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1428,7 +1429,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
filterableColumns: { filterableColumns: {
age: [FilterOperator.NOT, FilterOperator.NULL], age: [FilterSuffix.NOT, FilterOperator.NULL],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1448,7 +1449,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
filterableColumns: { filterableColumns: {
'home.name': [FilterOperator.NOT, FilterOperator.NULL], 'home.name': [FilterSuffix.NOT, FilterOperator.NULL],
}, },
relations: ['home'], relations: ['home'],
} }
@ -1474,7 +1475,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
filterableColumns: { filterableColumns: {
name: [FilterOperator.NOT, FilterOperator.NULL], name: [FilterSuffix.NOT, FilterOperator.NULL],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1494,7 +1495,7 @@ describe('paginate', () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
filterableColumns: { filterableColumns: {
age: [FilterOperator.NOT], age: [FilterSuffix.NOT],
}, },
} }
const query: PaginateQuery = { const query: PaginateQuery = {
@ -1534,13 +1535,19 @@ describe('paginate', () => {
{ operator: '$lt', result: true }, { operator: '$lt', result: true },
{ operator: '$lte', result: true }, { operator: '$lte', result: true },
{ operator: '$btw', result: true }, { operator: '$btw', result: true },
{ operator: '$not', result: true },
{ operator: '$ilike', result: true }, { operator: '$ilike', result: true },
{ operator: '$fake', result: false }, { operator: '$fake', result: false },
])('should check operator "$operator" valid is $result', ({ operator, result }) => { ])('should check operator "$operator" valid is $result', ({ operator, result }) => {
expect(isOperator(operator)).toStrictEqual(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([ it.each([
{ operator: '$eq', name: 'Equal' }, { operator: '$eq', name: 'Equal' },
{ operator: '$gt', name: 'MoreThan' }, { operator: '$gt', name: 'MoreThan' },
@ -1557,23 +1564,172 @@ describe('paginate', () => {
expect(func.name).toStrictEqual(name) 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([ it.each([
{ string: '$ilike:value', tokens: [null, '$ilike', 'value'] }, {
{ string: '$eq:value', tokens: [null, '$eq', 'value'] }, string: cSrt + '$ilike:value',
{ string: '$eq:val:ue', tokens: [null, '$eq', 'val:ue'] }, tokens: { comparator, operator: '$ilike', suffix: undefined, value: 'value' },
{ 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: cSrt + '$eq:value', tokens: { comparator, operator: '$eq', suffix: undefined, value: 'value' } },
{ string: 'value', tokens: [null, '$eq', 'value'] }, {
{ string: 'val:ue', tokens: [null, '$eq', 'val:ue'] }, string: cSrt + '$eq:val:ue',
{ string: '$not:value', tokens: [null, '$not', 'value'] }, tokens: { comparator, operator: '$eq', suffix: undefined, value: 'val:ue' },
{ string: '$eq:$not:value', tokens: ['$eq', '$not', 'value'] }, },
{ string: '$eq:$null', tokens: ['$eq', '$null'] }, {
{ string: '$null', tokens: [null, '$null'] }, string: cSrt + '$in:value1,value2,value3',
{ string: '', tokens: [null, '$eq', ''] }, tokens: { comparator, operator: '$in', suffix: undefined, value: 'value1,value2,value3' },
{ string: '$eq:$not:$in:value', tokens: [] }, },
{
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 }) => { ])('should get filter tokens for "$string"', ({ string, tokens }) => {
expect(getFilterTokens(string)).toStrictEqual(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 () => { it('should return result based on virtualcolumn filter', async () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {

View File

@ -1,24 +1,7 @@
import { import { Repository, SelectQueryBuilder, Brackets, FindOptionsWhere, ObjectLiteral } from 'typeorm'
Repository,
SelectQueryBuilder,
FindOperator,
Equal,
MoreThan,
MoreThanOrEqual,
In,
IsNull,
LessThan,
LessThanOrEqual,
Not,
ILike,
Brackets,
Between,
FindOptionsWhere,
ObjectLiteral,
} from 'typeorm'
import { PaginateQuery } from './decorator' import { PaginateQuery } from './decorator'
import { ServiceUnavailableException, Logger } from '@nestjs/common' import { ServiceUnavailableException, Logger } from '@nestjs/common'
import { values, mapKeys } from 'lodash' import { mapKeys } from 'lodash'
import { stringify } from 'querystring' import { stringify } from 'querystring'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause' import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
import { import {
@ -32,7 +15,8 @@ import {
RelationColumn, RelationColumn,
SortBy, SortBy,
} from './helper' } from './helper'
import { addWhereCondition, Filter } from './filter' import { FilterOperator, FilterSuffix } from './operator'
import { addFilter } from './filter'
const logger: Logger = new Logger('nestjs-paginate') const logger: Logger = new Logger('nestjs-paginate')
@ -67,126 +51,14 @@ export interface PaginateConfig<T> {
defaultSortBy?: SortBy<T> defaultSortBy?: SortBy<T>
defaultLimit?: number defaultLimit?: number
where?: FindOptionsWhere<T> | FindOptionsWhere<T>[] where?: FindOptionsWhere<T> | FindOptionsWhere<T>[]
filterableColumns?: { [key in Column<T>]?: FilterOperator[] } filterableColumns?: {
[key in Column<T>]?: (FilterOperator | FilterSuffix)[]
}
withDeleted?: boolean withDeleted?: boolean
relativePath?: boolean relativePath?: boolean
origin?: string 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_MAX_LIMIT = 100
export const DEFAULT_LIMIT = 20 export const DEFAULT_LIMIT = 20
export const NO_PAGINATION = 0 export const NO_PAGINATION = 0
@ -342,14 +214,7 @@ export async function paginate<T extends ObjectLiteral>(
} }
if (query.filter) { if (query.filter) {
const filter = parseFilter(query, config) addFilter(queryBuilder, query, config.filterableColumns)
queryBuilder.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column in filter) {
addWhereCondition(qb, column, filter)
}
})
)
} }
if (isPaginated) { if (isPaginated) {