Skip to content

Commit ea21cc1

Browse files
committed
Add search filter options and schema decorators with tests for filtering functionality
1 parent 7e2a41c commit ea21cc1

File tree

5 files changed

+943
-0
lines changed

5 files changed

+943
-0
lines changed

src/_common/restools/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './search-filter-options.decorator'
2+
export * from './search-filter-schema.decorator'
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { DEFAULT_FILTER_OPTIONS, filterOptions } from "./search-filter-options.decorator"
2+
3+
describe('search-filter-options', () => {
4+
it('should work', () => {
5+
expect(true).toBe(true)
6+
})
7+
8+
it('sort with desc string', () => {
9+
expect(
10+
filterOptions({
11+
sort: { 'metadata.lastUpdatedAt': 'desc' },
12+
}),
13+
).toStrictEqual({
14+
...DEFAULT_FILTER_OPTIONS,
15+
sort: { 'metadata.lastUpdatedAt': -1 },
16+
})
17+
})
18+
19+
it('sort with desc number', () => {
20+
expect(
21+
filterOptions({
22+
sort: { 'metadata.lastUpdatedAt': '-1' },
23+
}),
24+
).toStrictEqual({
25+
...DEFAULT_FILTER_OPTIONS,
26+
sort: { 'metadata.lastUpdatedAt': -1 },
27+
})
28+
})
29+
30+
it('sort with asc string', () => {
31+
expect(
32+
filterOptions({
33+
sort: { 'metadata.lastUpdatedAt': 'asc' },
34+
}),
35+
).toStrictEqual({
36+
...DEFAULT_FILTER_OPTIONS,
37+
sort: { 'metadata.lastUpdatedAt': 1 },
38+
})
39+
})
40+
41+
it('sort with asc number', () => {
42+
expect(
43+
filterOptions({
44+
sort: { 'metadata.lastUpdatedAt': '1' },
45+
}),
46+
).toStrictEqual({
47+
...DEFAULT_FILTER_OPTIONS,
48+
sort: { 'metadata.lastUpdatedAt': 1 },
49+
})
50+
})
51+
52+
it('limit 69', () => {
53+
expect(
54+
filterOptions({
55+
limit: '69',
56+
}),
57+
).toStrictEqual({
58+
...DEFAULT_FILTER_OPTIONS,
59+
limit: 69,
60+
})
61+
})
62+
63+
it('skip 71', () => {
64+
expect(
65+
filterOptions({
66+
skip: '71',
67+
}),
68+
).toStrictEqual({
69+
...DEFAULT_FILTER_OPTIONS,
70+
skip: 71,
71+
})
72+
})
73+
74+
it('skip with other key', () => {
75+
expect(
76+
filterOptions({
77+
jump: '71',
78+
}, { skipKey: 'jump' }),
79+
).toStrictEqual({
80+
...DEFAULT_FILTER_OPTIONS,
81+
skip: 71,
82+
})
83+
})
84+
85+
it('limit with other key', () => {
86+
expect(
87+
filterOptions({
88+
quota: '71',
89+
}, { limitKey: 'quota' }),
90+
).toStrictEqual({
91+
...DEFAULT_FILTER_OPTIONS,
92+
limit: 71,
93+
})
94+
})
95+
96+
it('use pagination number', () => {
97+
expect(
98+
filterOptions({
99+
page: '42',
100+
}),
101+
).toStrictEqual({
102+
...DEFAULT_FILTER_OPTIONS,
103+
skip: DEFAULT_FILTER_OPTIONS.limit * 41,
104+
})
105+
})
106+
107+
it('use pagination number with other key', () => {
108+
expect(
109+
filterOptions({
110+
increment: '11',
111+
}, { pageKey: 'increment' }),
112+
).toStrictEqual({
113+
...DEFAULT_FILTER_OPTIONS,
114+
skip: DEFAULT_FILTER_OPTIONS.limit * 10,
115+
})
116+
})
117+
118+
it('use pagination number and conflict with skip', () => {
119+
expect(
120+
filterOptions({
121+
page: '5',
122+
skip: '50',
123+
}),
124+
).toStrictEqual({
125+
...DEFAULT_FILTER_OPTIONS,
126+
skip: DEFAULT_FILTER_OPTIONS.limit * 4,
127+
})
128+
})
129+
})
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { BadRequestException, ExecutionContext, Logger, createParamDecorator } from '@nestjs/common'
2+
import { Request } from 'express'
3+
import { ParsedQs } from 'qs'
4+
5+
export const DEFAULT_SEARCH_OPTIONS = {
6+
loggerType: 'FilterOptionsControl',
7+
defaultLimit: 10,
8+
limitKey: 'limit',
9+
skipKey: 'skip',
10+
pageKey: 'page',
11+
sortKey: 'sort',
12+
allowUnlimited: false,
13+
}
14+
15+
export interface FilterSearchOptions {
16+
loggerType?: string
17+
defaultLimit?: number
18+
limitKey?: string
19+
skipKey?: string
20+
pageKey?: string
21+
sortKey?: string
22+
allowUnlimited?: boolean,
23+
}
24+
25+
export interface SortOptions {
26+
[key: string]: 'asc' | 'desc' | 1 | -1
27+
}
28+
29+
export const DEFAULT_FILTER_OPTIONS = {
30+
limit: DEFAULT_SEARCH_OPTIONS.defaultLimit,
31+
skip: 0,
32+
sort: {},
33+
}
34+
35+
export interface FilterOptions {
36+
limit: number
37+
skip: number
38+
sort: SortOptions
39+
}
40+
41+
/* istanbul ignore next */
42+
export const SearchFilterOptions = createParamDecorator((options: FilterSearchOptions, ctx: ExecutionContext): FilterOptions => {
43+
options = { ...DEFAULT_SEARCH_OPTIONS, ...options }
44+
const req = ctx.switchToHttp().getRequest<Request>()
45+
46+
try {
47+
return filterOptions(req.query, options)
48+
} catch (error) {
49+
throw new BadRequestException(error.message)
50+
}
51+
})
52+
53+
export function filterOptions(
54+
queries: string | string[] | ParsedQs | ParsedQs[],
55+
options?: FilterSearchOptions,
56+
): FilterOptions {
57+
options = { ...DEFAULT_SEARCH_OPTIONS, ...options }
58+
let limit = parseInt(`${queries[options.limitKey]}`) || options.defaultLimit
59+
if (limit === -1 && options.allowUnlimited) limit = undefined
60+
let skip = parseInt(`${queries[options.skipKey]}`) || 0
61+
62+
if (queries[options.pageKey]) {
63+
if (skip > 0) Logger.debug(`Both ${options.skipKey} and ${options.pageKey} are set. ${options.skipKey} will be ignored`, options.loggerType)
64+
skip = (parseInt(`${queries[options.pageKey]}`) - 1) * limit
65+
}
66+
67+
const sort = {}
68+
for (const key in <string[] | ParsedQs[]>queries[options.sortKey]) {
69+
switch (`${queries[options.sortKey][key]}`.toLowerCase()) {
70+
case '1':
71+
case 'asc':
72+
sort[key] = 1
73+
break
74+
75+
case '-1':
76+
case 'desc':
77+
sort[key] = -1
78+
break
79+
}
80+
}
81+
82+
return {
83+
limit,
84+
skip,
85+
sort,
86+
}
87+
}

0 commit comments

Comments
 (0)