From 70c24b00ee2f1fc50c8578479793c7c0e2d5ffcd Mon Sep 17 00:00:00 2001 From: xMase <11925311+xMase@users.noreply.github.com> Date: Thu, 9 Feb 2023 09:21:09 +0100 Subject: [PATCH] feat: multi filter with $and/$or comperator (#457) --- README.md | 26 ++++- src/filter.ts | 196 ++++++++++++++++++++++++++++++---- src/helper.ts | 2 +- src/operator.ts | 65 ++++++++++++ src/paginate.spec.ts | 246 +++++++++++++++++++++++++++++++++++-------- src/paginate.ts | 151 ++------------------------ 6 files changed, 474 insertions(+), 212 deletions(-) create mode 100644 src/operator.ts diff --git a/README.md b/README.md index 5c5bcae..23272e9 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ const config: PaginateConfig = { const result = await paginate(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 diff --git a/src/filter.ts b/src/filter.ts index 1bb1d0b..e7e2c8b 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -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 } +type Filter = { comparator: FilterComparator; findOperator: FindOperator } +type ColumnsFilters = { [columnName: string]: Filter[] } -export function generatePredicateCondition( - qb: SelectQueryBuilder, - 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, column: string, filter: Filter) { +export function generatePredicateCondition( + qb: SelectQueryBuilder, + column: string, + filter: Filter, + alias: string, + isVirtualProperty = false +): WherePredicateOperator { + return qb['getWherePredicateCondition']( + isVirtualProperty ? column : alias, + filter.findOperator + ) as WherePredicateOperator +} + +export function addWhereCondition(qb: SelectQueryBuilder, column: string, filter: ColumnsFilters) { const columnProperties = getPropertiesByColumnName(column) const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties) const isRelation = checkIsRelation(qb, columnProperties.propertyPath) - 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, + 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, 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) + } }) - 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( + query: PaginateQuery, + filterableColumns?: PaginateConfig['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( + qb: SelectQueryBuilder, + query: PaginateQuery, + filterableColumns?: PaginateConfig['filterableColumns'] +): SelectQueryBuilder { + const filter = parseFilter(query, filterableColumns) + return qb.andWhere( + new Brackets((qb: SelectQueryBuilder) => { + for (const column in filter) { + addWhereCondition(qb, column, filter) + } + }) + ) } diff --git a/src/helper.ts b/src/helper.ts index d10ed41..2a6c13e 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -33,7 +33,7 @@ export type SortBy = Order[] 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('.') diff --git a/src/operator.ts b/src/operator.ts new file mode 100644 index 0000000..fc827fd --- /dev/null +++ b/src/operator.ts @@ -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 +>([ + [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], +]) diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index de5a821..84f8a5b 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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,22 +1564,171 @@ describe('paginate', () => { expect(func.name).toStrictEqual(name) }) - 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: [] }, - ])('should get filter tokens for "$string"', ({ string, tokens }) => { - expect(getFilterTokens(string)).toStrictEqual(tokens) + for (const cc of [FilterComparator.AND, FilterComparator.OR, '']) { + const comparator = cc === '' ? FilterComparator.AND : cc + const cSrt = cc === '' ? cc : `${comparator}:` + it.each([ + { + 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 = { + 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(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 = { + sortableColumns: ['home.countCat'], + relations: ['home'], + } + const query: PaginateQuery = { + path: '', + sortBy: [['home.countCat', 'ASC']], + } + + const result = await paginate(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 = { + sortableColumns: ['id'], + filterableColumns: { + age: [FilterOperator.BTW], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + age: ['$btw:4,5', '$or:$btw:5,6'], + }, + } + const result = await paginate(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 = { + sortableColumns: ['id'], + filterableColumns: { + age: [FilterOperator.BTW], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + age: ['$null', '$or:$not:$eq:$null'], + }, + } + const result = await paginate(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 = { + sortableColumns: ['countCat'], + relations: ['cat'], + filterableColumns: { + countCat: [FilterOperator.GT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + countCat: '$gt:0', + }, + sortBy: [['countCat', 'ASC']], + } + + const result = await paginate(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 () => { diff --git a/src/paginate.ts b/src/paginate.ts index 398d197..d0928a5 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -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 { defaultSortBy?: SortBy defaultLimit?: number where?: FindOptionsWhere | FindOptionsWhere[] - filterableColumns?: { [key in Column]?: FilterOperator[] } + filterableColumns?: { + [key in Column]?: (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 FindOperator>([ - [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(query: PaginateQuery, config: PaginateConfig): 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( } if (query.filter) { - const filter = parseFilter(query, config) - queryBuilder.andWhere( - new Brackets((qb: SelectQueryBuilder) => { - for (const column in filter) { - addWhereCondition(qb, column, filter) - } - }) - ) + addFilter(queryBuilder, query, config.filterableColumns) } if (isPaginated) {