smart-query-nestjs 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +22 -0
- package/README.md +236 -0
- package/package.json +54 -0
- package/src/builders/index.ts +1 -0
- package/src/builders/smart-query.builder.ts +45 -0
- package/src/decorators/index.ts +1 -0
- package/src/decorators/smart-query.decorator.ts +9 -0
- package/src/index.ts +7 -0
- package/src/interceptors/index.ts +1 -0
- package/src/interceptors/smart-query.interceptor.ts +83 -0
- package/src/interfaces/index.ts +3 -0
- package/src/interfaces/pagination-options.interface.ts +7 -0
- package/src/interfaces/smart-query-config.interface.ts +9 -0
- package/src/interfaces/smart-query-context.interface.ts +6 -0
- package/src/module/index.ts +1 -0
- package/src/module/smart-query.module.ts +20 -0
- package/src/parsers/filter.parser.ts +177 -0
- package/src/parsers/index.ts +3 -0
- package/src/parsers/pagination.parser.ts +71 -0
- package/src/parsers/search.parser.ts +31 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/pick.util.ts +12 -0
- package/src/utils/query-parser.util.ts +12 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +12 -0
package/.eslintrc.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"root": true,
|
|
3
|
+
"parser": "@typescript-eslint/parser",
|
|
4
|
+
"plugins": ["@typescript-eslint"],
|
|
5
|
+
"extends": [
|
|
6
|
+
"eslint:recommended",
|
|
7
|
+
"plugin:@typescript-eslint/recommended"
|
|
8
|
+
],
|
|
9
|
+
"env": {
|
|
10
|
+
"node": true,
|
|
11
|
+
"es6": true
|
|
12
|
+
},
|
|
13
|
+
"parserOptions": {
|
|
14
|
+
"ecmaVersion": 2020,
|
|
15
|
+
"sourceType": "module"
|
|
16
|
+
},
|
|
17
|
+
"rules": {
|
|
18
|
+
"@typescript-eslint/explicit-function-return-type": "off",
|
|
19
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
20
|
+
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
|
|
21
|
+
}
|
|
22
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# nestjs-smart-query
|
|
2
|
+
|
|
3
|
+
A high-performance, ORM-agnostic NestJS library for search, filtering, pagination, and sorting in REST APIs.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Global Search** - Search across multiple fields with a single query parameter
|
|
8
|
+
- **Field Filtering** - Filter by exact match, contains, startsWith, endsWith
|
|
9
|
+
- **Range Filtering** - Greater than, less than, greater or equal, less or equal
|
|
10
|
+
- **Array Filtering** - IN queries for multiple values
|
|
11
|
+
- **Nested Relation Filtering** - Filter by related entity fields
|
|
12
|
+
- **Pagination** - Page-based pagination with configurable limits
|
|
13
|
+
- **Sorting** - Sort by any field in ascending or descending order
|
|
14
|
+
- **ORM Agnostic** - Generates query objects compatible with any database layer (Prisma, TypeORM, etc.)
|
|
15
|
+
- **High Performance** - Optimized parsing with single query parse and O(1) field lookups
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install nestjs-smart-query
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### 1. Configure the Module
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Module } from '@nestjs/common';
|
|
29
|
+
import { SmartQueryModule } from 'nestjs-smart-query';
|
|
30
|
+
|
|
31
|
+
@Module({
|
|
32
|
+
imports: [
|
|
33
|
+
SmartQueryModule.forRoot({
|
|
34
|
+
searchableFields: ['full_name', 'email'],
|
|
35
|
+
filterableFields: ['full_name', 'email', 'is_active', 'status', 'shop_id'],
|
|
36
|
+
numberFields: ['age', 'price'],
|
|
37
|
+
booleanFields: ['is_active', 'is_verified'],
|
|
38
|
+
dateFields: ['created_at', 'updated_at'],
|
|
39
|
+
defaultLimit: 10,
|
|
40
|
+
maxLimit: 100,
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
export class AppModule {}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Use in Controller
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
|
51
|
+
import { SmartQueryInterceptor, SmartQuery, buildSmartQuery } from 'nestjs-smart-query';
|
|
52
|
+
|
|
53
|
+
const customerQueryConfig = {
|
|
54
|
+
searchableFields: ['full_name', 'email'],
|
|
55
|
+
filterableFields: ['full_name', 'email', 'is_active', 'status', 'shop_id', 'age'],
|
|
56
|
+
numberFields: ['age'],
|
|
57
|
+
booleanFields: ['is_active'],
|
|
58
|
+
dateFields: ['created_at'],
|
|
59
|
+
defaultLimit: 10,
|
|
60
|
+
maxLimit: 100,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
@Controller('customers')
|
|
64
|
+
export class CustomerController {
|
|
65
|
+
@Get()
|
|
66
|
+
@UseInterceptors(new SmartQueryInterceptor(customerQueryConfig))
|
|
67
|
+
async findAll(@SmartQuery() query) {
|
|
68
|
+
const dbQuery = buildSmartQuery(query, {
|
|
69
|
+
shop_id: user.tenant_id,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const [data, total] = await Promise.all([
|
|
73
|
+
this.prisma.customer.findMany(dbQuery),
|
|
74
|
+
this.prisma.customer.count({ where: dbQuery.where }),
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
return { data, total };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Supported Query Formats
|
|
83
|
+
|
|
84
|
+
### Global Search
|
|
85
|
+
|
|
86
|
+
Search across all searchable fields:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
GET /customers?searchTerm=john
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Field Filtering
|
|
93
|
+
|
|
94
|
+
Exact match filtering:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
GET /customers?full_name=John
|
|
98
|
+
GET /customers?is_active=true
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Range Filtering
|
|
102
|
+
|
|
103
|
+
Filter by numeric or date ranges:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
GET /customers?price[gte]=10&price[lte]=100
|
|
107
|
+
GET /customers?created_at[gte]=2024-01-01
|
|
108
|
+
GET /customers?age[gt]=18
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Operators: `gte`, `gt`, `lte`, `lt`
|
|
112
|
+
|
|
113
|
+
### Array Filtering (IN Query)
|
|
114
|
+
|
|
115
|
+
Filter by multiple values:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
GET /customers?status[]=pending&status[]=approved
|
|
119
|
+
GET /customers?status=pending,approved
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Nested Relation Filtering
|
|
123
|
+
|
|
124
|
+
Filter by related entity fields:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
GET /customers?shop.id=10
|
|
128
|
+
GET /customers?shop.name=MyShop
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Pagination
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
GET /customers?page=2&limit=20
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `page`: Page number (default: 1)
|
|
138
|
+
- `limit`: Items per page (default: 10, max: 100)
|
|
139
|
+
|
|
140
|
+
### Sorting
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
GET /customers?sortBy=created_at&sortOrder=desc
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- `sortBy`: Field to sort by
|
|
147
|
+
- `sortOrder`: `asc` or `desc` (default: asc)
|
|
148
|
+
|
|
149
|
+
## Combined Example
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
GET /customers?searchTerm=john&status[]=active&age[gte]=18&page=1&limit=20&sortBy=created_at&sortOrder=desc
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
This query will:
|
|
156
|
+
- Search for "john" in all searchable fields
|
|
157
|
+
- Filter by status "active"
|
|
158
|
+
- Filter by age >= 18
|
|
159
|
+
- Return page 1 with 20 items per page
|
|
160
|
+
- Sort by created_at in descending order
|
|
161
|
+
|
|
162
|
+
## API Reference
|
|
163
|
+
|
|
164
|
+
### Interfaces
|
|
165
|
+
|
|
166
|
+
#### SmartQueryConfig
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
interface SmartQueryConfig {
|
|
170
|
+
searchableFields: string[];
|
|
171
|
+
filterableFields: string[];
|
|
172
|
+
numberFields?: string[];
|
|
173
|
+
booleanFields?: string[];
|
|
174
|
+
dateFields?: string[];
|
|
175
|
+
defaultLimit?: number;
|
|
176
|
+
maxLimit?: number;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
#### SmartQueryContext
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
interface SmartQueryContext {
|
|
184
|
+
where: Record<string, unknown>;
|
|
185
|
+
pagination: PaginationOptions;
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### PaginationOptions
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
interface PaginationOptions {
|
|
193
|
+
page: number;
|
|
194
|
+
limit: number;
|
|
195
|
+
skip: number;
|
|
196
|
+
sortBy: string;
|
|
197
|
+
sortOrder: 'asc' | 'desc';
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Functions
|
|
202
|
+
|
|
203
|
+
#### buildSmartQuery(context, ...extraConditions)
|
|
204
|
+
|
|
205
|
+
Merges the smart query context with additional conditions and generates a database query object.
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const result = buildSmartQuery(query, { shop_id: 1 });
|
|
209
|
+
// Returns: { where, orderBy, skip, take, page }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Decorators
|
|
213
|
+
|
|
214
|
+
#### @SmartQuery()
|
|
215
|
+
|
|
216
|
+
Extracts the SmartQueryContext from the request.
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
@Get()
|
|
220
|
+
async findAll(@SmartQuery() query: SmartQueryContext) {
|
|
221
|
+
// ...
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## Performance Optimizations
|
|
226
|
+
|
|
227
|
+
The library includes several performance optimizations:
|
|
228
|
+
|
|
229
|
+
1. **Single Query Parse** - Uses `qs.parse` with `allowDots: true` to parse the query string only once
|
|
230
|
+
2. **O(1) Field Lookups** - Uses `Set` for field lookups instead of array includes
|
|
231
|
+
3. **Modular Architecture** - Separates concerns into dedicated parsers
|
|
232
|
+
4. **No Unnecessary Cloning** - Avoids deep object cloning where possible
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smart-query-nestjs",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "High-performance search, filtering, pagination, and sorting for NestJS REST APIs",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"build:watch": "tsup --watch",
|
|
18
|
+
"lint": "eslint src --ext .ts",
|
|
19
|
+
"lint:fix": "eslint src --ext .ts --fix",
|
|
20
|
+
"test": "jest",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"nestjs",
|
|
25
|
+
"query",
|
|
26
|
+
"filter",
|
|
27
|
+
"search",
|
|
28
|
+
"pagination",
|
|
29
|
+
"sort",
|
|
30
|
+
"rest-api",
|
|
31
|
+
"orm-agnostic"
|
|
32
|
+
],
|
|
33
|
+
"author": "",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"qs": "^6.12.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@nestjs/common": "^10.3.0",
|
|
40
|
+
"@nestjs/core": "^10.3.0",
|
|
41
|
+
"@nestjs/platform-express": "^10.3.0",
|
|
42
|
+
"@types/qs": "^6.9.14",
|
|
43
|
+
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
|
44
|
+
"@typescript-eslint/parser": "^6.21.0",
|
|
45
|
+
"eslint": "^8.56.0",
|
|
46
|
+
"tsup": "^8.0.2",
|
|
47
|
+
"typescript": "^5.3.3"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@nestjs/common": "^10.0.0 || ^9.0.0 || ^8.0.0",
|
|
51
|
+
"@nestjs/core": "^10.0.0 || ^9.0.0 || ^8.0.0",
|
|
52
|
+
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './smart-query.builder';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { SmartQueryContext } from '../interfaces';
|
|
2
|
+
|
|
3
|
+
export interface BuildSmartQueryOptions {
|
|
4
|
+
where?: Record<string, unknown>;
|
|
5
|
+
orderBy?: Record<string, 'asc' | 'desc'>;
|
|
6
|
+
skip?: number;
|
|
7
|
+
take?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SmartQueryResult {
|
|
11
|
+
where: Record<string, unknown>;
|
|
12
|
+
orderBy: Record<string, 'asc' | 'desc'>;
|
|
13
|
+
skip: number;
|
|
14
|
+
take: number;
|
|
15
|
+
page: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildSmartQuery(
|
|
19
|
+
context: SmartQueryContext,
|
|
20
|
+
...extraConditions: Record<string, unknown>[]
|
|
21
|
+
): SmartQueryResult {
|
|
22
|
+
const { where, pagination } = context;
|
|
23
|
+
|
|
24
|
+
let finalWhere: Record<string, unknown>;
|
|
25
|
+
|
|
26
|
+
if (extraConditions.length === 0) {
|
|
27
|
+
finalWhere = where;
|
|
28
|
+
} else if (extraConditions.length === 1) {
|
|
29
|
+
finalWhere = { AND: [where, extraConditions[0]] };
|
|
30
|
+
} else {
|
|
31
|
+
finalWhere = { AND: [where, ...extraConditions] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const orderBy: Record<string, 'asc' | 'desc'> = {
|
|
35
|
+
[pagination.sortBy]: pagination.sortOrder,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
where: finalWhere,
|
|
40
|
+
orderBy,
|
|
41
|
+
skip: pagination.skip,
|
|
42
|
+
take: pagination.limit,
|
|
43
|
+
page: pagination.page,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './smart-query.decorator';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { SmartQueryContext } from '../interfaces';
|
|
3
|
+
|
|
4
|
+
export const SmartQuery = createParamDecorator(
|
|
5
|
+
(_data: unknown, ctx: ExecutionContext): SmartQueryContext => {
|
|
6
|
+
const request = ctx.switchToHttp().getRequest();
|
|
7
|
+
return request.smartQuery as SmartQueryContext;
|
|
8
|
+
},
|
|
9
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './smart-query.interceptor';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
NestInterceptor,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
CallHandler,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import type { SmartQueryConfig, SmartQueryContext } from '../interfaces';
|
|
8
|
+
import { Observable } from 'rxjs';
|
|
9
|
+
import { map } from 'rxjs/operators';
|
|
10
|
+
import { parseQueryString } from '../utils';
|
|
11
|
+
import { buildSearchConditions } from '../parsers/search.parser';
|
|
12
|
+
import { parseFilters } from '../parsers/filter.parser';
|
|
13
|
+
import { parsePagination } from '../parsers/pagination.parser';
|
|
14
|
+
|
|
15
|
+
export const SMART_QUERY_CONFIG = 'SMART_QUERY_CONFIG';
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class SmartQueryInterceptor implements NestInterceptor {
|
|
19
|
+
private readonly config: SmartQueryConfig;
|
|
20
|
+
|
|
21
|
+
constructor(config: SmartQueryConfig) {
|
|
22
|
+
const defaults: SmartQueryConfig = {
|
|
23
|
+
searchableFields: [],
|
|
24
|
+
filterableFields: [],
|
|
25
|
+
numberFields: [],
|
|
26
|
+
booleanFields: [],
|
|
27
|
+
dateFields: [],
|
|
28
|
+
defaultLimit: 10,
|
|
29
|
+
maxLimit: 100,
|
|
30
|
+
};
|
|
31
|
+
this.config = { ...defaults, ...config };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
35
|
+
const request = context.switchToHttp().getRequest();
|
|
36
|
+
const queryString = request.url.split('?')[1] || '';
|
|
37
|
+
|
|
38
|
+
const parsedQuery = parseQueryString(queryString);
|
|
39
|
+
|
|
40
|
+
const searchTerm = parsedQuery.searchTerm as string | undefined;
|
|
41
|
+
const filters = parseFilters(parsedQuery, this.config);
|
|
42
|
+
const searchConditions = buildSearchConditions(searchTerm, this.config);
|
|
43
|
+
const pagination = parsePagination(parsedQuery, this.config);
|
|
44
|
+
|
|
45
|
+
const where: Record<string, unknown> = {};
|
|
46
|
+
|
|
47
|
+
if (Object.keys(filters).length > 0) {
|
|
48
|
+
Object.assign(where, filters);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (searchConditions) {
|
|
52
|
+
Object.assign(where, searchConditions);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const smartQueryContext: SmartQueryContext = {
|
|
56
|
+
where,
|
|
57
|
+
pagination,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
request.smartQuery = smartQueryContext;
|
|
61
|
+
|
|
62
|
+
return next.handle().pipe(
|
|
63
|
+
map((data) => {
|
|
64
|
+
if (data && typeof data === 'object' && 'data' in data && 'total' in data) {
|
|
65
|
+
return {
|
|
66
|
+
...data,
|
|
67
|
+
pagination: {
|
|
68
|
+
page: pagination.page,
|
|
69
|
+
limit: pagination.limit,
|
|
70
|
+
total: data.total,
|
|
71
|
+
totalPages: Math.ceil(data.total / pagination.limit),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return data;
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createSmartQueryInterceptor(config: SmartQueryConfig): SmartQueryInterceptor {
|
|
82
|
+
return new SmartQueryInterceptor(config);
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './smart-query.module';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Module, Global, DynamicModule, Provider } from '@nestjs/common';
|
|
2
|
+
import { SmartQueryConfig } from '../interfaces';
|
|
3
|
+
import { SMART_QUERY_CONFIG } from '../interceptors/smart-query.interceptor';
|
|
4
|
+
|
|
5
|
+
@Global()
|
|
6
|
+
@Module({})
|
|
7
|
+
export class SmartQueryModule {
|
|
8
|
+
static forRoot(config: SmartQueryConfig): DynamicModule {
|
|
9
|
+
const configProvider: Provider = {
|
|
10
|
+
provide: SMART_QUERY_CONFIG,
|
|
11
|
+
useValue: config,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
module: SmartQueryModule,
|
|
16
|
+
providers: [configProvider],
|
|
17
|
+
exports: [configProvider],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { SmartQueryConfig } from '../interfaces';
|
|
2
|
+
|
|
3
|
+
type FilterOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'contains' | 'startsWith' | 'endsWith';
|
|
4
|
+
|
|
5
|
+
interface ParsedFilter {
|
|
6
|
+
field: string;
|
|
7
|
+
operator: FilterOperator;
|
|
8
|
+
value: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseFilters(
|
|
12
|
+
query: Record<string, unknown>,
|
|
13
|
+
config: SmartQueryConfig,
|
|
14
|
+
): Record<string, unknown> {
|
|
15
|
+
const result: Record<string, unknown> = {};
|
|
16
|
+
const filterableFieldsSet = new Set(config.filterableFields);
|
|
17
|
+
const numberFieldsSet = new Set(config.numberFields ?? []);
|
|
18
|
+
const booleanFieldsSet = new Set(config.booleanFields ?? []);
|
|
19
|
+
const dateFieldsSet = new Set(config.dateFields ?? []);
|
|
20
|
+
|
|
21
|
+
const paginationKeys = new Set(['page', 'limit', 'sortBy', 'sortOrder', 'searchTerm']);
|
|
22
|
+
|
|
23
|
+
for (const [key, value] of Object.entries(query)) {
|
|
24
|
+
if (paginationKeys.has(key) || key.startsWith('searchTerm')) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!filterableFieldsSet.has(key)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (value === undefined || value === null || value === '') {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parsedFilter = parseFieldFilter(key, value);
|
|
37
|
+
if (!parsedFilter) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { field, operator, value: filterValue } = parsedFilter;
|
|
42
|
+
|
|
43
|
+
if (numberFieldsSet.has(field)) {
|
|
44
|
+
result[field] = buildNumericFilter(operator, filterValue);
|
|
45
|
+
} else if (booleanFieldsSet.has(field)) {
|
|
46
|
+
result[field] = parseBooleanFilter(filterValue);
|
|
47
|
+
} else if (dateFieldsSet.has(field)) {
|
|
48
|
+
result[field] = buildDateFilter(operator, filterValue);
|
|
49
|
+
} else {
|
|
50
|
+
result[field] = buildStringFilter(operator, filterValue);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseFieldFilter(key: string, value: unknown): ParsedFilter | null {
|
|
58
|
+
const rangeMatch = key.match(/^(.+)\[(gte|gt|lte|lt)\]$/);
|
|
59
|
+
if (rangeMatch) {
|
|
60
|
+
return {
|
|
61
|
+
field: rangeMatch[1],
|
|
62
|
+
operator: rangeMatch[2] as FilterOperator,
|
|
63
|
+
value: value,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const arrayMatch = key.match(/^(.+)\[\]$/);
|
|
68
|
+
if (arrayMatch) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
return {
|
|
71
|
+
field: arrayMatch[1],
|
|
72
|
+
operator: 'in',
|
|
73
|
+
value: value,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === 'string') {
|
|
77
|
+
return {
|
|
78
|
+
field: arrayMatch[1],
|
|
79
|
+
operator: 'in',
|
|
80
|
+
value: value.split(',').map((v) => v.trim()),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
field: key,
|
|
87
|
+
operator: 'eq',
|
|
88
|
+
value: value,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildNumericFilter(operator: FilterOperator, value: unknown): Record<string, unknown> {
|
|
93
|
+
const numValue = typeof value === 'number' ? value : Number(value);
|
|
94
|
+
if (Number.isNaN(numValue)) {
|
|
95
|
+
return { eq: value };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
switch (operator) {
|
|
99
|
+
case 'eq':
|
|
100
|
+
return { equals: numValue };
|
|
101
|
+
case 'ne':
|
|
102
|
+
return { not: { equals: numValue } };
|
|
103
|
+
case 'gt':
|
|
104
|
+
return { gt: numValue };
|
|
105
|
+
case 'gte':
|
|
106
|
+
return { gte: numValue };
|
|
107
|
+
case 'lt':
|
|
108
|
+
return { lt: numValue };
|
|
109
|
+
case 'lte':
|
|
110
|
+
return { lte: numValue };
|
|
111
|
+
case 'in':
|
|
112
|
+
return { in: Array.isArray(value) ? value : [value] };
|
|
113
|
+
default:
|
|
114
|
+
return { equals: numValue };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseBooleanFilter(value: unknown): Record<string, unknown> {
|
|
119
|
+
if (typeof value === 'boolean') {
|
|
120
|
+
return { equals: value };
|
|
121
|
+
}
|
|
122
|
+
if (typeof value === 'string') {
|
|
123
|
+
const lower = value.toLowerCase();
|
|
124
|
+
if (lower === 'true' || lower === '1' || lower === 'yes') {
|
|
125
|
+
return { equals: true };
|
|
126
|
+
}
|
|
127
|
+
if (lower === 'false' || lower === '0' || lower === 'no') {
|
|
128
|
+
return { equals: false };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { equals: value };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildDateFilter(operator: FilterOperator, value: unknown): Record<string, unknown> {
|
|
135
|
+
const dateValue = typeof value === 'string' ? new Date(value) : value;
|
|
136
|
+
if (!(dateValue instanceof Date) || Number.isNaN(dateValue.getTime())) {
|
|
137
|
+
return { equals: value };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (operator) {
|
|
141
|
+
case 'eq':
|
|
142
|
+
return { equals: dateValue };
|
|
143
|
+
case 'ne':
|
|
144
|
+
return { not: { equals: dateValue } };
|
|
145
|
+
case 'gt':
|
|
146
|
+
return { gt: dateValue };
|
|
147
|
+
case 'gte':
|
|
148
|
+
return { gte: dateValue };
|
|
149
|
+
case 'lt':
|
|
150
|
+
return { lt: dateValue };
|
|
151
|
+
case 'lte':
|
|
152
|
+
return { lte: dateValue };
|
|
153
|
+
case 'in':
|
|
154
|
+
return { in: Array.isArray(value) ? value : [value] };
|
|
155
|
+
default:
|
|
156
|
+
return { equals: dateValue };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildStringFilter(operator: FilterOperator, value: unknown): Record<string, unknown> {
|
|
161
|
+
switch (operator) {
|
|
162
|
+
case 'eq':
|
|
163
|
+
return { equals: String(value) };
|
|
164
|
+
case 'ne':
|
|
165
|
+
return { not: { equals: String(value) } };
|
|
166
|
+
case 'contains':
|
|
167
|
+
return { contains: String(value), mode: 'insensitive' };
|
|
168
|
+
case 'startsWith':
|
|
169
|
+
return { startsWith: String(value) };
|
|
170
|
+
case 'endsWith':
|
|
171
|
+
return { endsWith: String(value) };
|
|
172
|
+
case 'in':
|
|
173
|
+
return { in: Array.isArray(value) ? value : [value] };
|
|
174
|
+
default:
|
|
175
|
+
return { equals: String(value) };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { PaginationOptions } from '../interfaces';
|
|
2
|
+
import { SmartQueryConfig } from '../interfaces/smart-query-config.interface';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PAGE = 1;
|
|
5
|
+
const DEFAULT_LIMIT = 10;
|
|
6
|
+
|
|
7
|
+
export function parsePagination(
|
|
8
|
+
query: Record<string, unknown>,
|
|
9
|
+
config: SmartQueryConfig,
|
|
10
|
+
): PaginationOptions {
|
|
11
|
+
const pageParam = query.page;
|
|
12
|
+
const limitParam = query.limit;
|
|
13
|
+
const sortByParam = query.sortBy;
|
|
14
|
+
const sortOrderParam = query.sortOrder;
|
|
15
|
+
|
|
16
|
+
const maxLimit = config.maxLimit ?? 100;
|
|
17
|
+
const defaultLimit = config.defaultLimit ?? DEFAULT_LIMIT;
|
|
18
|
+
|
|
19
|
+
const page = parsePositiveInteger(pageParam) ?? DEFAULT_PAGE;
|
|
20
|
+
const limit = clampLimit(
|
|
21
|
+
parsePositiveInteger(limitParam) ?? defaultLimit,
|
|
22
|
+
defaultLimit,
|
|
23
|
+
maxLimit,
|
|
24
|
+
);
|
|
25
|
+
const skip = (page - 1) * limit;
|
|
26
|
+
|
|
27
|
+
let sortBy = 'id';
|
|
28
|
+
if (typeof sortByParam === 'string' && sortByParam.trim()) {
|
|
29
|
+
sortBy = sortByParam.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let sortOrder: 'asc' | 'desc' = 'asc';
|
|
33
|
+
if (typeof sortOrderParam === 'string') {
|
|
34
|
+
const normalized = sortOrderParam.toLowerCase();
|
|
35
|
+
if (normalized === 'desc' || normalized === 'asc') {
|
|
36
|
+
sortOrder = normalized;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
page,
|
|
42
|
+
limit,
|
|
43
|
+
skip,
|
|
44
|
+
sortBy,
|
|
45
|
+
sortOrder,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parsePositiveInteger(value: unknown): number | null {
|
|
50
|
+
if (value === undefined || value === null || value === '') {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
55
|
+
|
|
56
|
+
if (!Number.isInteger(num) || num < 1) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return num;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clampLimit(value: number, defaultLimit: number, maxLimit: number): number {
|
|
64
|
+
if (value < 1) {
|
|
65
|
+
return defaultLimit;
|
|
66
|
+
}
|
|
67
|
+
if (value > maxLimit) {
|
|
68
|
+
return maxLimit;
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SmartQueryConfig } from '../interfaces';
|
|
2
|
+
|
|
3
|
+
interface SearchCondition {
|
|
4
|
+
OR: Record<string, { contains: string; mode: string }>[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildSearchConditions(
|
|
8
|
+
searchTerm: string | undefined,
|
|
9
|
+
config: SmartQueryConfig,
|
|
10
|
+
): SearchCondition | null {
|
|
11
|
+
if (!searchTerm || typeof searchTerm !== 'string' || !searchTerm.trim()) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const trimmedTerm = searchTerm.trim();
|
|
16
|
+
const searchableFieldsSet = new Set(config.searchableFields);
|
|
17
|
+
|
|
18
|
+
const conditions: Record<string, { contains: string; mode: string }>[] = [];
|
|
19
|
+
|
|
20
|
+
for (const field of config.searchableFields) {
|
|
21
|
+
if (searchableFieldsSet.has(field)) {
|
|
22
|
+
conditions.push({ [field]: { contains: trimmedTerm, mode: 'insensitive' } });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (conditions.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { OR: conditions };
|
|
31
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function pick<T extends Record<string, unknown>, K extends keyof T>(
|
|
2
|
+
obj: T,
|
|
3
|
+
keys: K[],
|
|
4
|
+
): Pick<T, K> {
|
|
5
|
+
const result = {} as Pick<T, K>;
|
|
6
|
+
for (const key of keys) {
|
|
7
|
+
if (key in obj) {
|
|
8
|
+
result[key] = obj[key];
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import qs from 'qs';
|
|
2
|
+
|
|
3
|
+
export function parseQueryString(queryString: string): Record<string, unknown> {
|
|
4
|
+
if (!queryString || typeof queryString !== 'string') {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
return qs.parse(queryString, {
|
|
8
|
+
allowDots: true,
|
|
9
|
+
arrayLimit: 0,
|
|
10
|
+
comma: true,
|
|
11
|
+
}) as Record<string, unknown>;
|
|
12
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"moduleResolution": "node",
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"noUnusedLocals": true,
|
|
19
|
+
"noUnusedParameters": true,
|
|
20
|
+
"noImplicitReturns": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"emitDecoratorMetadata": true,
|
|
23
|
+
"experimentalDecorators": true,
|
|
24
|
+
"allowSyntheticDefaultImports": true
|
|
25
|
+
},
|
|
26
|
+
"include": ["src/**/*"],
|
|
27
|
+
"exclude": ["node_modules", "dist"]
|
|
28
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.ts'],
|
|
5
|
+
format: ['cjs', 'esm'],
|
|
6
|
+
dts: true,
|
|
7
|
+
sourcemap: true,
|
|
8
|
+
splitting: false,
|
|
9
|
+
clean: true,
|
|
10
|
+
external: ['@nestjs/common', '@nestjs/core', 'reflect-metadata'],
|
|
11
|
+
treeshake: true,
|
|
12
|
+
});
|