@hedhog/pagination 0.0.1
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/package.json +20 -0
- package/src/constants/pagination.constants.ts +2 -0
- package/src/decorator/pagination.decorator.ts +62 -0
- package/src/dto/pagination.dto.ts +31 -0
- package/src/enums/patination.enums.ts +12 -0
- package/src/index.ts +7 -0
- package/src/pagination.module.ts +9 -0
- package/src/pagination.service.spec.ts +18 -0
- package/src/pagination.service.ts +128 -0
- package/src/types/pagination.types.ts +43 -0
- package/tsconfig.lib.json +9 -0
- package/tsconfig.production.json +20 -0
package/package.json
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
{
|
2
|
+
"name": "@hedhog/pagination",
|
3
|
+
"version": "0.0.1",
|
4
|
+
"private": false,
|
5
|
+
"main": "dist/index.js",
|
6
|
+
"scripts": {
|
7
|
+
"build": "tsc && npm version patch",
|
8
|
+
"prod": "npm run build && npm publish --access public"
|
9
|
+
},
|
10
|
+
"keywords": [],
|
11
|
+
"author": "",
|
12
|
+
"license": "MIT",
|
13
|
+
"description": "",
|
14
|
+
"peerDependencies": {
|
15
|
+
"@prisma/client": "^5.17.0",
|
16
|
+
"@hedhog/prisma": "latest",
|
17
|
+
"class-transformer": "^0.5.1",
|
18
|
+
"class-validator": "^0.14.1"
|
19
|
+
}
|
20
|
+
}
|
@@ -0,0 +1,62 @@
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
2
|
+
import {
|
3
|
+
DEFAULT_PAGE,
|
4
|
+
DEFAULT_PAGE_SIZE,
|
5
|
+
} from '../constants/pagination.constants';
|
6
|
+
import { PageOrderDirection, PaginationField } from '../enums/patination.enums';
|
7
|
+
import { PaginationType } from '../types/pagination.types';
|
8
|
+
|
9
|
+
export const Pagination = createParamDecorator(
|
10
|
+
(data: PaginationField, ctx: ExecutionContext): PaginationType => {
|
11
|
+
const request = ctx.switchToHttp().getRequest();
|
12
|
+
|
13
|
+
const defaultOptions: PaginationType = {
|
14
|
+
page: DEFAULT_PAGE,
|
15
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
16
|
+
search: '',
|
17
|
+
sortField: 'id',
|
18
|
+
sortOrder: PageOrderDirection.Asc,
|
19
|
+
fields: '',
|
20
|
+
};
|
21
|
+
|
22
|
+
const requestData = {
|
23
|
+
...defaultOptions,
|
24
|
+
...(request.body || {}),
|
25
|
+
...(request.query || {}),
|
26
|
+
};
|
27
|
+
|
28
|
+
const {
|
29
|
+
page = defaultOptions.page,
|
30
|
+
pageSize = defaultOptions.pageSize,
|
31
|
+
search = defaultOptions.search,
|
32
|
+
sortField = defaultOptions.sortField,
|
33
|
+
sortOrder = defaultOptions.sortOrder,
|
34
|
+
fields = defaultOptions.fields,
|
35
|
+
} = requestData;
|
36
|
+
|
37
|
+
const validSortOrder = Object.values(PageOrderDirection).includes(sortOrder)
|
38
|
+
? sortOrder
|
39
|
+
: defaultOptions.sortOrder;
|
40
|
+
|
41
|
+
if (data) {
|
42
|
+
switch (data) {
|
43
|
+
case PaginationField.Page:
|
44
|
+
case PaginationField.PageSize:
|
45
|
+
return requestData[data] ? +requestData[data] : defaultOptions[data];
|
46
|
+
case PaginationField.OrderDirection:
|
47
|
+
return requestData[data] || defaultOptions[data];
|
48
|
+
default:
|
49
|
+
return requestData[data];
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
return {
|
54
|
+
page: +page,
|
55
|
+
pageSize: +pageSize,
|
56
|
+
search,
|
57
|
+
sortField,
|
58
|
+
sortOrder: validSortOrder,
|
59
|
+
fields,
|
60
|
+
};
|
61
|
+
},
|
62
|
+
);
|
@@ -0,0 +1,31 @@
|
|
1
|
+
import { Transform } from 'class-transformer';
|
2
|
+
import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator';
|
3
|
+
import { PageOrderDirection } from '../enums/patination.enums';
|
4
|
+
export class PaginationDTO {
|
5
|
+
@IsOptional()
|
6
|
+
@Transform((value) => Number(value))
|
7
|
+
@IsInt({ message: 'page must be an integer' })
|
8
|
+
page: number;
|
9
|
+
|
10
|
+
@IsOptional()
|
11
|
+
@Transform((value) => Number(value))
|
12
|
+
@IsInt({ message: 'pageSize must be an integer' })
|
13
|
+
pageSize: number;
|
14
|
+
|
15
|
+
@IsOptional()
|
16
|
+
@IsString({ message: 'search must be a string' })
|
17
|
+
search: string;
|
18
|
+
|
19
|
+
@IsOptional()
|
20
|
+
@IsString({ message: 'field must be a string' })
|
21
|
+
sortField: string;
|
22
|
+
|
23
|
+
@IsOptional()
|
24
|
+
@IsString({ message: 'sortOrder must be a string' })
|
25
|
+
@IsEnum(PageOrderDirection, { message: 'sortOrder is not valid' })
|
26
|
+
sortOrder: PageOrderDirection;
|
27
|
+
|
28
|
+
@IsOptional()
|
29
|
+
@IsString({ message: 'fields must be a string' })
|
30
|
+
fields: string;
|
31
|
+
}
|
package/src/index.ts
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
export * from './constants/pagination.constants';
|
2
|
+
export * from './decorator/pagination.decorator';
|
3
|
+
export * from './dto/pagination.dto';
|
4
|
+
export * from './enums/patination.enums';
|
5
|
+
export * from './pagination.module';
|
6
|
+
export * from './pagination.service';
|
7
|
+
export type * from './types/pagination.types';
|
@@ -0,0 +1,18 @@
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
2
|
+
import { PaginationService } from './pagination.service';
|
3
|
+
|
4
|
+
describe('PaginationService', () => {
|
5
|
+
let service: PaginationService;
|
6
|
+
|
7
|
+
beforeEach(async () => {
|
8
|
+
const module: TestingModule = await Test.createTestingModule({
|
9
|
+
providers: [PaginationService],
|
10
|
+
}).compile();
|
11
|
+
|
12
|
+
service = module.get<PaginationService>(PaginationService);
|
13
|
+
});
|
14
|
+
|
15
|
+
it('should be defined', () => {
|
16
|
+
expect(service).toBeDefined();
|
17
|
+
});
|
18
|
+
});
|
@@ -0,0 +1,128 @@
|
|
1
|
+
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
2
|
+
import {
|
3
|
+
DEFAULT_PAGE,
|
4
|
+
DEFAULT_PAGE_SIZE,
|
5
|
+
} from './constants/pagination.constants';
|
6
|
+
import { PageOrderDirection } from './enums/patination.enums';
|
7
|
+
import type {
|
8
|
+
BaseModel,
|
9
|
+
FindManyArgs,
|
10
|
+
PaginatedResult,
|
11
|
+
PaginationParams,
|
12
|
+
} from './types/pagination.types';
|
13
|
+
|
14
|
+
@Injectable()
|
15
|
+
export class PaginationService {
|
16
|
+
private readonly logger = new Logger(PaginationService.name);
|
17
|
+
|
18
|
+
async paginate<T, M extends BaseModel>(
|
19
|
+
model: M,
|
20
|
+
paginationParams: PaginationParams,
|
21
|
+
customQuery?: FindManyArgs<M>,
|
22
|
+
): Promise<PaginatedResult<T>> {
|
23
|
+
try {
|
24
|
+
const page = Number(paginationParams.page || DEFAULT_PAGE);
|
25
|
+
const pageSize = Number(paginationParams.pageSize || DEFAULT_PAGE_SIZE);
|
26
|
+
const search = paginationParams.search || null;
|
27
|
+
const sortField = paginationParams.sortField || null;
|
28
|
+
const sortOrder = paginationParams.sortOrder || PageOrderDirection.Asc;
|
29
|
+
const fields = paginationParams.fields
|
30
|
+
? paginationParams.fields.split(',')
|
31
|
+
: null;
|
32
|
+
|
33
|
+
if (page < 1 || pageSize < 1) {
|
34
|
+
throw new BadRequestException(
|
35
|
+
'Page and pageSize must be greater than 0',
|
36
|
+
);
|
37
|
+
}
|
38
|
+
|
39
|
+
let selectCondition = undefined;
|
40
|
+
let sortOrderCondition: any = {
|
41
|
+
id: paginationParams.sortOrder || PageOrderDirection.Asc,
|
42
|
+
};
|
43
|
+
|
44
|
+
if (search && sortField) {
|
45
|
+
const fieldNames = this.extractFieldNames(model);
|
46
|
+
|
47
|
+
if (!fieldNames.includes(sortField)) {
|
48
|
+
throw new BadRequestException(
|
49
|
+
`Invalid field: ${sortField}. Valid columns are: ${fieldNames.join(', ')}`,
|
50
|
+
);
|
51
|
+
}
|
52
|
+
|
53
|
+
if (typeof sortField !== 'string') {
|
54
|
+
throw new BadRequestException('Field must be a string');
|
55
|
+
}
|
56
|
+
}
|
57
|
+
|
58
|
+
if (sortField) {
|
59
|
+
sortOrderCondition = { [sortField]: sortOrder };
|
60
|
+
}
|
61
|
+
|
62
|
+
if (fields) {
|
63
|
+
const fieldNames = this.extractFieldNames(model);
|
64
|
+
const invalidFields = fields.filter(
|
65
|
+
(field) => !fieldNames.includes(field),
|
66
|
+
);
|
67
|
+
if (invalidFields.length > 0) {
|
68
|
+
throw new BadRequestException(
|
69
|
+
`Invalid fields: ${invalidFields.join(', ')}. Valid columns are: ${fieldNames.join(', ')}`,
|
70
|
+
);
|
71
|
+
}
|
72
|
+
selectCondition = fields.reduce((acc, field) => {
|
73
|
+
acc[field] = true;
|
74
|
+
return acc;
|
75
|
+
}, {});
|
76
|
+
}
|
77
|
+
|
78
|
+
const skip = page > 0 ? pageSize * (page - 1) : 0;
|
79
|
+
|
80
|
+
const query = {
|
81
|
+
select: selectCondition,
|
82
|
+
where: customQuery?.where || {},
|
83
|
+
orderBy: sortOrderCondition,
|
84
|
+
take: pageSize,
|
85
|
+
skip,
|
86
|
+
};
|
87
|
+
|
88
|
+
const [total, data] = await Promise.all([
|
89
|
+
model.count({ where: customQuery?.where || {} }),
|
90
|
+
model.findMany(query),
|
91
|
+
]);
|
92
|
+
|
93
|
+
const lastPage = Math.ceil(total / pageSize);
|
94
|
+
|
95
|
+
return {
|
96
|
+
total,
|
97
|
+
lastPage,
|
98
|
+
currentPage: page,
|
99
|
+
pageSize,
|
100
|
+
prev: page > 1 ? page - 1 : null,
|
101
|
+
next: page < lastPage ? page + 1 : null,
|
102
|
+
data,
|
103
|
+
};
|
104
|
+
} catch (error) {
|
105
|
+
this.logger.error('Pagination Error:', error);
|
106
|
+
|
107
|
+
if (error instanceof BadRequestException) {
|
108
|
+
throw error;
|
109
|
+
}
|
110
|
+
|
111
|
+
throw new BadRequestException(`Failed to paginate: ${error.message}`);
|
112
|
+
}
|
113
|
+
}
|
114
|
+
|
115
|
+
extractFieldNames(model: Record<string, any>): string[] {
|
116
|
+
const fieldNames: string[] = [];
|
117
|
+
|
118
|
+
const fields = model.fields;
|
119
|
+
|
120
|
+
for (const key in fields) {
|
121
|
+
if (fields.hasOwnProperty(key)) {
|
122
|
+
fieldNames.push(key);
|
123
|
+
}
|
124
|
+
}
|
125
|
+
|
126
|
+
return fieldNames;
|
127
|
+
}
|
128
|
+
}
|
@@ -0,0 +1,43 @@
|
|
1
|
+
import { PageOrderDirection } from '../enums/patination.enums';
|
2
|
+
|
3
|
+
export type PaginatedResult<T> = {
|
4
|
+
total: number;
|
5
|
+
lastPage: number;
|
6
|
+
currentPage: number;
|
7
|
+
pageSize: number;
|
8
|
+
prev: number | null;
|
9
|
+
next: number | null;
|
10
|
+
data: T[];
|
11
|
+
};
|
12
|
+
|
13
|
+
export type PaginationType = string | number | PaginationParams;
|
14
|
+
|
15
|
+
export type PaginateFunction = <K, T>(
|
16
|
+
model: any,
|
17
|
+
args?: K,
|
18
|
+
options?: PaginateOptions,
|
19
|
+
) => Promise<PaginatedResult<T>>;
|
20
|
+
|
21
|
+
export type PaginationParams = {
|
22
|
+
page?: number;
|
23
|
+
pageSize?: number;
|
24
|
+
search?: string;
|
25
|
+
sortField?: string;
|
26
|
+
sortOrder?: PageOrderDirection;
|
27
|
+
fields: string;
|
28
|
+
};
|
29
|
+
|
30
|
+
export type PaginateOptions = {
|
31
|
+
page?: number | string;
|
32
|
+
pageSize?: number | string;
|
33
|
+
};
|
34
|
+
|
35
|
+
export type BaseModel = {
|
36
|
+
findMany: (args: any) => Promise<any[]>;
|
37
|
+
count: (args: any) => Promise<number>;
|
38
|
+
fields?: Record<string, any>;
|
39
|
+
};
|
40
|
+
|
41
|
+
export type FindManyArgs<M> = M extends { findMany: (args: infer A) => any }
|
42
|
+
? A
|
43
|
+
: never;
|
@@ -0,0 +1,20 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
"experimentalDecorators": true,
|
4
|
+
"target": "es2017",
|
5
|
+
"module": "commonjs",
|
6
|
+
"lib": ["es2017", "es7", "es6"],
|
7
|
+
"declaration": true,
|
8
|
+
"declarationMap": true,
|
9
|
+
"sourceMap": true,
|
10
|
+
"outDir": "./dist",
|
11
|
+
"rootDir": "./src",
|
12
|
+
"strict": true,
|
13
|
+
"noImplicitAny": false,
|
14
|
+
"strictNullChecks": false,
|
15
|
+
"allowSyntheticDefaultImports": true,
|
16
|
+
"esModuleInterop": true,
|
17
|
+
"emitDecoratorMetadata": true
|
18
|
+
},
|
19
|
+
"exclude": ["node_modules", "dist"]
|
20
|
+
}
|