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 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,7 @@
1
+ export * from './interfaces';
2
+ export * from './utils';
3
+ export * from './parsers';
4
+ export * from './interceptors';
5
+ export * from './decorators';
6
+ export * from './builders';
7
+ export * from './module';
@@ -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,3 @@
1
+ export * from './smart-query-config.interface';
2
+ export * from './smart-query-context.interface';
3
+ export * from './pagination-options.interface';
@@ -0,0 +1,7 @@
1
+ export interface PaginationOptions {
2
+ page: number;
3
+ limit: number;
4
+ skip: number;
5
+ sortBy: string;
6
+ sortOrder: 'asc' | 'desc';
7
+ }
@@ -0,0 +1,9 @@
1
+ export interface SmartQueryConfig {
2
+ searchableFields: string[];
3
+ filterableFields: string[];
4
+ numberFields?: string[];
5
+ booleanFields?: string[];
6
+ dateFields?: string[];
7
+ defaultLimit?: number;
8
+ maxLimit?: number;
9
+ }
@@ -0,0 +1,6 @@
1
+ import { PaginationOptions } from './pagination-options.interface';
2
+
3
+ export interface SmartQueryContext {
4
+ where: Record<string, unknown>;
5
+ pagination: PaginationOptions;
6
+ }
@@ -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,3 @@
1
+ export * from './search.parser';
2
+ export * from './filter.parser';
3
+ export * from './pagination.parser';
@@ -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,2 @@
1
+ export * from './query-parser.util';
2
+ export * from './pick.util';
@@ -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
+ });