From 098216b3ccd62208cefc95031fbdfe51e6046973 Mon Sep 17 00:00:00 2001 From: ppetzold Date: Tue, 21 Mar 2023 12:11:07 +0100 Subject: [PATCH] fix: date column filter with iso dates --- README.md | 6 +- src/__tests__/cat.entity.ts | 3 + src/filter.ts | 15 ++- src/helper.ts | 8 ++ src/paginate.spec.ts | 210 +++++++++++++++++++++++++++++++++++- 5 files changed, 232 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e64febb..20333d2 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ const config: PaginateConfig = { sortableColumns: ['id', 'name', 'home.pillows.color'], searchableColumns: ['name', 'home.pillows.color'], filterableColumns: { - 'home.pillows.color': [FilterOperator.EQ] + 'home.pillows.color': [FilterOperator.EQ], }, } @@ -410,6 +410,8 @@ const config: 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=$lt:2022-12-20T10:00:00.000Z` where column `createdAt` is before iso date `2022-12-20T10:00:00.000Z` + `?filter.roles=$contains:moderator` where column `roles` is an array and contains the value `moderator` `?filter.roles=$contains:moderator,admin` where column `roles` is an array and contains the values `moderator` and `admin` @@ -420,7 +422,7 @@ Multi filters are filters that can be applied to a single column with a comparat ### Examples -`?filter.id=$gt:3&filter.id=$lt:5` where column `id` is greater than `3` **and** less than `5` +`?filter.createdAt=$gt:2022-02-02&filter.createdAt=$lt:2022-02-10` where column `createdAt` is after `2022-02-02` **and** is before `2022-02-10` `?filter.id=$contains:moderator&filter.id=$or:$contains:admin` where column `roles` is an array and contains `moderator` **or** `admin` diff --git a/src/__tests__/cat.entity.ts b/src/__tests__/cat.entity.ts index b5ba5bd..2d6b889 100644 --- a/src/__tests__/cat.entity.ts +++ b/src/__tests__/cat.entity.ts @@ -38,6 +38,9 @@ export class CatEntity { @Column({ type: 'text' }) // We don't use enum type as it makes it easier when testing across different db drivers. cutenessLevel: CutenessLevel + @Column({ nullable: true }) + lastVetVisit: Date | null + @Column(() => SizeEmbed) size: SizeEmbed diff --git a/src/filter.ts b/src/filter.ts index 57978aa..16c341b 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -23,6 +23,7 @@ import { extractVirtualProperty, fixColumnAlias, getPropertiesByColumnName, + isISODate, } from './helper' export enum FilterOperator { @@ -182,7 +183,7 @@ export function addWhereCondition(qb: SelectQueryBuilder, column: string, }) } -export function getFilterTokens(raw?: string): FilterToken | null { +export function parseFilterToken(raw?: string): FilterToken | null { if (raw === undefined || raw === null) { return null } @@ -241,7 +242,7 @@ export function parseFilter( const input = query.filter[column] const statements = !Array.isArray(input) ? [input] : input for (const raw of statements) { - const token = getFilterTokens(raw) + const token = parseFilterToken(raw) if (!token) { continue } @@ -270,12 +271,16 @@ export function parseFilter( findOperator: undefined, } + const fixValue = (value: string) => (isISODate(value) ? new Date(value) : value) + switch (token.operator) { case FilterOperator.BTW: - params.findOperator = OperatorSymbolToFunction.get(token.operator)(...token.value.split(',')) + params.findOperator = OperatorSymbolToFunction.get(token.operator)( + ...token.value.split(',').map(fixValue) + ) break case FilterOperator.IN: - case FilterOperator.CONTAINS: // <- IN and CONTAINS are identically handled. + case FilterOperator.CONTAINS: params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value.split(',')) break case FilterOperator.ILIKE: @@ -285,7 +290,7 @@ export function parseFilter( params.findOperator = OperatorSymbolToFunction.get(token.operator)(`${token.value}%`) break default: - params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value) + params.findOperator = OperatorSymbolToFunction.get(token.operator)(fixValue(token.value)) } filter[column] = [...(filter[column] || []), params] diff --git a/src/helper.ts b/src/helper.ts index 8b2069c..a066f86 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -155,3 +155,11 @@ export function getQueryUrlComponents(path: string): { queryOrigin: string; quer } return { queryOrigin, queryPath } } + +const isoDateRegExp = new RegExp( + /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/ +) + +export function isISODate(str: string): boolean { + return isoDateRegExp.test(str) +} diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index f4c8bec..76bc532 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -8,7 +8,7 @@ import { CatHomeEntity } from './__tests__/cat-home.entity' import { CatHomePillowEntity } from './__tests__/cat-home-pillow.entity' import { clone } from 'lodash' import { - getFilterTokens, + parseFilterToken, FilterComparator, FilterOperator, FilterSuffix, @@ -17,6 +17,8 @@ import { OperatorSymbolToFunction, } from './filter' +const isoStringToDate = (isoString) => new Date(isoString) + describe('paginate', () => { let dataSource: DataSource let catRepo: Repository @@ -44,7 +46,7 @@ describe('paginate', () => { database: ':memory:', }), synchronize: true, - logging: true, + logging: false, entities: [CatEntity, CatToyEntity, CatHomeEntity, CatHomePillowEntity], }) await dataSource.initialize() @@ -59,6 +61,7 @@ describe('paginate', () => { color: 'brown', age: 6, cutenessLevel: CutenessLevel.HIGH, + lastVetVisit: isoStringToDate('2022-12-19T10:00:00.000Z'), size: { height: 25, width: 10, length: 40 }, }), catRepo.create({ @@ -66,6 +69,7 @@ describe('paginate', () => { color: 'ginger', age: 5, cutenessLevel: CutenessLevel.MEDIUM, + lastVetVisit: isoStringToDate('2022-12-20T10:00:00.000Z'), size: { height: 30, width: 15, length: 45 }, }), catRepo.create({ @@ -73,6 +77,7 @@ describe('paginate', () => { color: 'black', age: 4, cutenessLevel: CutenessLevel.HIGH, + lastVetVisit: isoStringToDate('2022-12-21T10:00:00.000Z'), size: { height: 25, width: 10, length: 50 }, }), catRepo.create({ @@ -80,6 +85,7 @@ describe('paginate', () => { color: 'white', age: 3, cutenessLevel: CutenessLevel.LOW, + lastVetVisit: null, size: { height: 35, width: 12, length: 40 }, }), catRepo.create({ @@ -87,6 +93,7 @@ describe('paginate', () => { color: 'white', age: null, cutenessLevel: CutenessLevel.HIGH, + lastVetVisit: null, size: { height: 10, width: 5, length: 15 }, }), ]) @@ -1753,7 +1760,7 @@ describe('paginate', () => { tokens: { comparator, operator: '$null', suffix: '$not', value: undefined }, }, ])('should get filter tokens for "$string"', ({ string, tokens }) => { - expect(getFilterTokens(string)).toStrictEqual(tokens) + expect(parseFilterToken(string)).toStrictEqual(tokens) }) } @@ -2248,4 +2255,201 @@ describe('paginate', () => { expect(result.data).toStrictEqual([cats[0], cats[1], cats[2], cats[3], cats[4]]) expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.id=$not:$in:1,2,5') }) + + describe('should return result based on date column filter', () => { + it('with $not and $null operators', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterSuffix.NOT, FilterOperator.NULL], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$not:$null', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$not:$null', + }) + expect(result.data).toStrictEqual([cats[0], cats[1], cats[2]]) + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$not:$null') + }) + + it('with $lt operator', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.LT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$lt:2022-12-20T10:00:00.000Z', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$lt:2022-12-20T10:00:00.000Z', + }) + expect(result.data).toStrictEqual([cats[0]]) + expect(result.links.current).toBe( + '?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$lt:2022-12-20T10:00:00.000Z' + ) + }) + + it('with $lte operator', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.LTE], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$lte:2022-12-20T10:00:00.000Z', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$lte:2022-12-20T10:00:00.000Z', + }) + expect(result.data).toStrictEqual([cats[0], cats[1]]) + expect(result.links.current).toBe( + '?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$lte:2022-12-20T10:00:00.000Z' + ) + }) + + it('with $btw operator', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.BTW], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$btw:2022-12-20T08:00:00.000Z,2022-12-20T12:00:00.000Z', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$btw:2022-12-20T08:00:00.000Z,2022-12-20T12:00:00.000Z', + }) + expect(result.data).toStrictEqual([cats[1]]) + expect(result.links.current).toBe( + '?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$btw:2022-12-20T08:00:00.000Z,2022-12-20T12:00:00.000Z' + ) + }) + + it('with $gte operator', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.GTE], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$gte:2022-12-20T10:00:00.000Z', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$gte:2022-12-20T10:00:00.000Z', + }) + expect(result.data).toStrictEqual([cats[1], cats[2]]) + expect(result.links.current).toBe( + '?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$gte:2022-12-20T10:00:00.000Z' + ) + }) + + it('with $gt operator', async () => { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.GT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$gt:2022-12-20T10:00:00.000Z', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$gt:2022-12-20T10:00:00.000Z', + }) + expect(result.data).toStrictEqual([cats[2]]) + expect(result.links.current).toBe( + '?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$gt:2022-12-20T10:00:00.000Z' + ) + }) + + it('with $lt operator and date only', async () => { + { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.LT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$lt:2022-12-20', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$lt:2022-12-20', + }) + expect(result.data).toStrictEqual([cats[0]]) + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$lt:2022-12-20') + } + { + const config: PaginateConfig = { + sortableColumns: ['id'], + filterableColumns: { + lastVetVisit: [FilterOperator.LT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + lastVetVisit: '$lt:2022-12-21', + }, + } + + const result = await paginate(query, catRepo, config) + + expect(result.meta.filter).toStrictEqual({ + lastVetVisit: '$lt:2022-12-21', + }) + expect(result.data).toStrictEqual([cats[0], cats[1]]) + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.lastVetVisit=$lt:2022-12-21') + } + }) + }) })