From 0089fffc79d9c4cd96ed8833e5bef219362115b7 Mon Sep 17 00:00:00 2001 From: Alex Stansfield Date: Wed, 17 Nov 2021 00:49:13 +0700 Subject: [PATCH] Better splitting of the filter string to allow for filter values that contain a colon (#120) --- src/paginate.spec.ts | 54 ++++++++++++++- src/paginate.ts | 152 +++++++++++++++++++++++++------------------ 2 files changed, 142 insertions(+), 64 deletions(-) diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index 7f42878..3944875 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -1,5 +1,13 @@ import { createConnection, Repository, Column, In } from 'typeorm' -import { Paginated, paginate, PaginateConfig, FilterOperator } from './paginate' +import { + Paginated, + paginate, + PaginateConfig, + FilterOperator, + isOperator, + getOperatorFn, + getFilterTokens, +} from './paginate' import { PaginateQuery } from './decorator' import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm' import { HttpException } from '@nestjs/common' @@ -432,4 +440,48 @@ describe('paginate', () => { expect(err).toBeInstanceOf(HttpException) } }) + + it.each([ + { operator: '$eq', result: true }, + { operator: '$gte', result: true }, + { operator: '$gt', result: true }, + { operator: '$in', result: true }, + { operator: '$null', result: true }, + { operator: '$lt', result: true }, + { operator: '$lte', result: true }, + { operator: '$not', result: true }, + { operator: '$fake', result: false }, + ])('should check operator "$operator" valid is $result', ({ operator, result }) => { + expect(isOperator(operator)).toStrictEqual(result) + }) + + it.each([ + { operator: '$eq', name: 'Equal' }, + { operator: '$gt', name: 'MoreThan' }, + { operator: '$gte', name: 'MoreThanOrEqual' }, + { operator: '$in', name: 'In' }, + { operator: '$null', name: 'IsNull' }, + { operator: '$lt', name: 'LessThan' }, + { operator: '$lte', name: 'LessThanOrEqual' }, + { operator: '$not', name: 'Not' }, + ])('should get operator function $name for "$operator"', ({ operator, name }) => { + const func = getOperatorFn(operator as FilterOperator) + expect(func.name).toStrictEqual(name) + }) + + it.each([ + { 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: '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) + }) }) diff --git a/src/paginate.ts b/src/paginate.ts index a901a41..04081e4 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -66,6 +66,94 @@ export enum FilterOperator { NOT = '$not', } +export function isOperator(value: unknown): value is FilterOperator { + return values(FilterOperator).includes(value as any) +} + +export function getOperatorFn(op: FilterOperator): (...args: any[]) => FindOperator { + switch (op) { + case FilterOperator.EQ: + return Equal + case FilterOperator.GT: + return MoreThan + case FilterOperator.GTE: + return MoreThanOrEqual + case FilterOperator.IN: + return In + case FilterOperator.NULL: + return IsNull + case FilterOperator.LT: + return LessThan + case FilterOperator.LTE: + return LessThanOrEqual + case FilterOperator.NOT: + return Not + } +} + +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) { + const filter = {} + + for (const column of Object.keys(query.filter)) { + if (!(column in config.filterableColumns)) { + continue + } + const allowedOperators = config.filterableColumns[column as 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)) { + const args = op1 === FilterOperator.IN ? value.split(',') : value + filter[column] = getOperatorFn(op1)(args) + } + if (isOperator(op2)) { + filter[column] = getOperatorFn(op2)(filter[column]) + } + } + } + return filter +} + export async function paginate( query: PaginateQuery, repo: Repository | SelectQueryBuilder, @@ -143,69 +231,7 @@ export async function paginate( } if (query.filter) { - const filter = {} - function getOperatorFn(op: FilterOperator): (...args: any[]) => FindOperator { - switch (op) { - case FilterOperator.EQ: - return Equal - case FilterOperator.GT: - return MoreThan - case FilterOperator.GTE: - return MoreThanOrEqual - case FilterOperator.IN: - return In - case FilterOperator.NULL: - return IsNull - case FilterOperator.LT: - return LessThan - case FilterOperator.LTE: - return LessThanOrEqual - case FilterOperator.NOT: - return Not - } - } - function isOperator(value: any): value is FilterOperator { - return values(FilterOperator).includes(value) - } - for (const column of Object.keys(query.filter)) { - if (!(column in config.filterableColumns)) { - continue - } - const allowedOperators = config.filterableColumns[column as Column] - const input = query.filter[column] - const statements = !Array.isArray(input) ? [input] : input - for (const raw of statements) { - const tokens = raw.split(':') - if (tokens.length === 0 || tokens.length > 3) { - continue - } 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) - } - } - const [op2, op1, value] = tokens - - if (!isOperator(op1) || !allowedOperators.includes(op1)) { - continue - } - if (isOperator(op2) && !allowedOperators.includes(op2)) { - continue - } - if (isOperator(op1)) { - const args = op1 === FilterOperator.IN ? value.split(',') : value - filter[column] = getOperatorFn(op1)(args) - } - if (isOperator(op2)) { - filter[column] = getOperatorFn(op2)(filter[column]) - } - } - } + const filter = parseFilter(query, config) queryBuilder = queryBuilder.andWhere(filter) }