fix: date column filter with iso dates

This commit is contained in:
ppetzold 2023-03-21 12:11:07 +01:00
parent ad488e31d7
commit 098216b3cc
5 changed files with 232 additions and 10 deletions

View File

@ -335,7 +335,7 @@ const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id', 'name', 'home.pillows.color'], sortableColumns: ['id', 'name', 'home.pillows.color'],
searchableColumns: ['name', 'home.pillows.color'], searchableColumns: ['name', 'home.pillows.color'],
filterableColumns: { filterableColumns: {
'home.pillows.color': [FilterOperator.EQ] 'home.pillows.color': [FilterOperator.EQ],
}, },
} }
@ -410,6 +410,8 @@ const config: PaginateConfig<CatEntity> = {
`?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`
`?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` 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` `?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 ### 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` `?filter.id=$contains:moderator&filter.id=$or:$contains:admin` where column `roles` is an array and contains `moderator` **or** `admin`

View File

@ -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. @Column({ type: 'text' }) // We don't use enum type as it makes it easier when testing across different db drivers.
cutenessLevel: CutenessLevel cutenessLevel: CutenessLevel
@Column({ nullable: true })
lastVetVisit: Date | null
@Column(() => SizeEmbed) @Column(() => SizeEmbed)
size: SizeEmbed size: SizeEmbed

View File

@ -23,6 +23,7 @@ import {
extractVirtualProperty, extractVirtualProperty,
fixColumnAlias, fixColumnAlias,
getPropertiesByColumnName, getPropertiesByColumnName,
isISODate,
} from './helper' } from './helper'
export enum FilterOperator { export enum FilterOperator {
@ -182,7 +183,7 @@ export function addWhereCondition<T>(qb: SelectQueryBuilder<T>, column: string,
}) })
} }
export function getFilterTokens(raw?: string): FilterToken | null { export function parseFilterToken(raw?: string): FilterToken | null {
if (raw === undefined || raw === null) { if (raw === undefined || raw === null) {
return null return null
} }
@ -241,7 +242,7 @@ export function parseFilter(
const input = query.filter[column] const input = query.filter[column]
const statements = !Array.isArray(input) ? [input] : input const statements = !Array.isArray(input) ? [input] : input
for (const raw of statements) { for (const raw of statements) {
const token = getFilterTokens(raw) const token = parseFilterToken(raw)
if (!token) { if (!token) {
continue continue
} }
@ -270,12 +271,16 @@ export function parseFilter(
findOperator: undefined, findOperator: undefined,
} }
const fixValue = (value: string) => (isISODate(value) ? new Date(value) : value)
switch (token.operator) { switch (token.operator) {
case FilterOperator.BTW: case FilterOperator.BTW:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(...token.value.split(',')) params.findOperator = OperatorSymbolToFunction.get(token.operator)(
...token.value.split(',').map(fixValue)
)
break break
case FilterOperator.IN: case FilterOperator.IN:
case FilterOperator.CONTAINS: // <- IN and CONTAINS are identically handled. case FilterOperator.CONTAINS:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value.split(',')) params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value.split(','))
break break
case FilterOperator.ILIKE: case FilterOperator.ILIKE:
@ -285,7 +290,7 @@ export function parseFilter(
params.findOperator = OperatorSymbolToFunction.get(token.operator)(`${token.value}%`) params.findOperator = OperatorSymbolToFunction.get(token.operator)(`${token.value}%`)
break break
default: default:
params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value) params.findOperator = OperatorSymbolToFunction.get(token.operator)(fixValue(token.value))
} }
filter[column] = [...(filter[column] || []), params] filter[column] = [...(filter[column] || []), params]

View File

@ -155,3 +155,11 @@ export function getQueryUrlComponents(path: string): { queryOrigin: string; quer
} }
return { queryOrigin, queryPath } 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)
}

View File

@ -8,7 +8,7 @@ import { CatHomeEntity } from './__tests__/cat-home.entity'
import { CatHomePillowEntity } from './__tests__/cat-home-pillow.entity' import { CatHomePillowEntity } from './__tests__/cat-home-pillow.entity'
import { clone } from 'lodash' import { clone } from 'lodash'
import { import {
getFilterTokens, parseFilterToken,
FilterComparator, FilterComparator,
FilterOperator, FilterOperator,
FilterSuffix, FilterSuffix,
@ -17,6 +17,8 @@ import {
OperatorSymbolToFunction, OperatorSymbolToFunction,
} from './filter' } from './filter'
const isoStringToDate = (isoString) => new Date(isoString)
describe('paginate', () => { describe('paginate', () => {
let dataSource: DataSource let dataSource: DataSource
let catRepo: Repository<CatEntity> let catRepo: Repository<CatEntity>
@ -44,7 +46,7 @@ describe('paginate', () => {
database: ':memory:', database: ':memory:',
}), }),
synchronize: true, synchronize: true,
logging: true, logging: false,
entities: [CatEntity, CatToyEntity, CatHomeEntity, CatHomePillowEntity], entities: [CatEntity, CatToyEntity, CatHomeEntity, CatHomePillowEntity],
}) })
await dataSource.initialize() await dataSource.initialize()
@ -59,6 +61,7 @@ describe('paginate', () => {
color: 'brown', color: 'brown',
age: 6, age: 6,
cutenessLevel: CutenessLevel.HIGH, cutenessLevel: CutenessLevel.HIGH,
lastVetVisit: isoStringToDate('2022-12-19T10:00:00.000Z'),
size: { height: 25, width: 10, length: 40 }, size: { height: 25, width: 10, length: 40 },
}), }),
catRepo.create({ catRepo.create({
@ -66,6 +69,7 @@ describe('paginate', () => {
color: 'ginger', color: 'ginger',
age: 5, age: 5,
cutenessLevel: CutenessLevel.MEDIUM, cutenessLevel: CutenessLevel.MEDIUM,
lastVetVisit: isoStringToDate('2022-12-20T10:00:00.000Z'),
size: { height: 30, width: 15, length: 45 }, size: { height: 30, width: 15, length: 45 },
}), }),
catRepo.create({ catRepo.create({
@ -73,6 +77,7 @@ describe('paginate', () => {
color: 'black', color: 'black',
age: 4, age: 4,
cutenessLevel: CutenessLevel.HIGH, cutenessLevel: CutenessLevel.HIGH,
lastVetVisit: isoStringToDate('2022-12-21T10:00:00.000Z'),
size: { height: 25, width: 10, length: 50 }, size: { height: 25, width: 10, length: 50 },
}), }),
catRepo.create({ catRepo.create({
@ -80,6 +85,7 @@ describe('paginate', () => {
color: 'white', color: 'white',
age: 3, age: 3,
cutenessLevel: CutenessLevel.LOW, cutenessLevel: CutenessLevel.LOW,
lastVetVisit: null,
size: { height: 35, width: 12, length: 40 }, size: { height: 35, width: 12, length: 40 },
}), }),
catRepo.create({ catRepo.create({
@ -87,6 +93,7 @@ describe('paginate', () => {
color: 'white', color: 'white',
age: null, age: null,
cutenessLevel: CutenessLevel.HIGH, cutenessLevel: CutenessLevel.HIGH,
lastVetVisit: null,
size: { height: 10, width: 5, length: 15 }, size: { height: 10, width: 5, length: 15 },
}), }),
]) ])
@ -1753,7 +1760,7 @@ describe('paginate', () => {
tokens: { comparator, operator: '$null', suffix: '$not', value: undefined }, 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(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.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') 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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterSuffix.NOT, FilterOperator.NULL],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$not:$null',
},
}
const result = await paginate<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterOperator.LT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$lt:2022-12-20T10:00:00.000Z',
},
}
const result = await paginate<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterOperator.LTE],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$lte:2022-12-20T10:00:00.000Z',
},
}
const result = await paginate<CatEntity>(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<CatEntity> = {
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<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterOperator.GTE],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$gte:2022-12-20T10:00:00.000Z',
},
}
const result = await paginate<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterOperator.GT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$gt:2022-12-20T10:00:00.000Z',
},
}
const result = await paginate<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterOperator.LT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$lt:2022-12-20',
},
}
const result = await paginate<CatEntity>(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<CatEntity> = {
sortableColumns: ['id'],
filterableColumns: {
lastVetVisit: [FilterOperator.LT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
lastVetVisit: '$lt:2022-12-21',
},
}
const result = await paginate<CatEntity>(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')
}
})
})
}) })