Enhance pagination and ordering capabilities in API responses

- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results.
- Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns.
- Enhanced `PaginationQueryDto` to support ordering fields in API requests.
- Updated various repository and service classes to implement ordering in database queries.
- Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior.
- Refactored related DTOs to include user and code relations for better data representation in responses.
This commit is contained in:
shancheas
2026-08-27 13:09:41 +07:00
parent 790725e227
commit 4c45a4371e
85 changed files with 1824 additions and 365 deletions
+16 -1
View File
@@ -23,12 +23,27 @@ export {
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
export {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS,
fallbackUserRelation,
pickCodeRelation,
pickDefaultRelation,
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
type CodeRelation,
type DefaultRelation,
type UserRelation,
} from './relation-fields';
export { DefaultRelationDto, UserRelationDto } from './relation.dto';
export {
CodeRelationDto,
DefaultRelationDto,
UserRelationDto,
} from './relation.dto';
export {
ORDER_TYPES,
toOrderClauses,
type ListOrderQuery,
type OrderDefault,
type OrderType,
} from './order-clause';
@@ -0,0 +1,71 @@
import { BadRequestException } from '@nestjs/common';
import { asc, desc } from 'drizzle-orm';
import { integer, pgTable } from 'drizzle-orm/pg-core';
import { toOrderClauses } from './order-clause';
const sample = pgTable('sample', {
code: integer('code'),
name: integer('name'),
createdAt: integer('created_at'),
});
const columns = {
code: sample.code,
name: sample.name,
createdAt: sample.createdAt,
};
describe('toOrderClauses', () => {
it('uses default columns when orderBy is omitted', () => {
expect(
toOrderClauses(columns, {}, [{ column: 'code', type: 'ASC' }]),
).toEqual([asc(sample.code)]);
});
it('applies multiple defaults when orderBy is omitted', () => {
expect(
toOrderClauses(columns, {}, [
{ column: 'createdAt', type: 'ASC' },
{ column: 'code', type: 'ASC' },
]),
).toEqual([asc(sample.createdAt), asc(sample.code)]);
});
it('uses a single client column and DESC', () => {
expect(
toOrderClauses(columns, { orderBy: 'name', orderType: 'DESC' }, [
{ column: 'code', type: 'ASC' },
]),
).toEqual([desc(sample.name)]);
});
it('applies orderType to defaults when orderBy is omitted', () => {
expect(
toOrderClauses(columns, { orderType: 'DESC' }, [
{ column: 'code', type: 'ASC' },
]),
).toEqual([desc(sample.code)]);
});
it('rejects unknown orderBy without echoing the raw value as SQL', () => {
expect(() =>
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]),
).toThrow(BadRequestException);
try {
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]);
} catch (error) {
expect((error as BadRequestException).message).toContain(
'code, name, createdAt',
);
expect((error as BadRequestException).message).not.toContain(
'drop table',
);
}
});
it('rejects invalid orderType', () => {
expect(() =>
toOrderClauses(columns, { orderType: 'SIDEWAYS' }, [{ column: 'code' }]),
).toThrow(BadRequestException);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { BadRequestException } from '@nestjs/common';
import { asc, desc, type SQL } from 'drizzle-orm';
export const ORDER_TYPES = ['ASC', 'DESC'] as const;
export type OrderType = (typeof ORDER_TYPES)[number];
export type ListOrderQuery = {
readonly orderBy?: string;
readonly orderType?: string;
};
export type OrderDefault = {
readonly column?: string;
readonly type?: OrderType;
};
export function toOrderClauses(
columns: Record<string, Parameters<typeof asc>[0]>,
query: ListOrderQuery,
defaults: readonly OrderDefault[],
): SQL[] {
if (query.orderBy) {
return [toSql(columns, query.orderBy, normalizeOrderType(query.orderType))];
}
const typeOverride =
query.orderType != null && query.orderType !== ''
? normalizeOrderType(query.orderType)
: undefined;
return defaults.map((entry) =>
toSql(columns, entry.column ?? '', typeOverride ?? entry.type ?? 'ASC'),
);
}
function toSql(
columns: Record<string, Parameters<typeof asc>[0]>,
column: string,
type: OrderType,
): SQL {
const selected = columns[column];
if (selected == null) {
throw new BadRequestException(
`Invalid orderBy. Allowed: ${Object.keys(columns).join(', ')}`,
);
}
return type === 'DESC' ? desc(selected) : asc(selected);
}
function normalizeOrderType(raw?: string): OrderType {
if (raw == null || raw === '') {
return 'ASC';
}
const normalized = raw.toUpperCase();
if (normalized === 'ASC' || normalized === 'DESC') {
return normalized;
}
throw new BadRequestException('Invalid orderType. Allowed: ASC, DESC');
}
@@ -1,5 +1,7 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ORDER_TYPES } from './order-clause';
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
export class PaginationQueryDto {
@@ -21,4 +23,19 @@ export class PaginationQueryDto {
@IsInt()
@Min(0)
offset?: number;
@ApiPropertyOptional({
description: 'Column to order by (resource response field name)',
})
@IsOptional()
@IsString()
orderBy?: string;
@ApiPropertyOptional({ enum: ORDER_TYPES, default: 'ASC' })
@IsOptional()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.toUpperCase() : value,
)
@IsIn([...ORDER_TYPES])
orderType?: (typeof ORDER_TYPES)[number];
}
@@ -1,5 +1,7 @@
import {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS,
pickCodeRelation,
pickDefaultRelation,
pickRelation,
pickUserRelation,
@@ -60,6 +62,19 @@ describe('pickRelation', () => {
});
});
describe('pickCodeRelation', () => {
it('picks id and code only', () => {
expect(
pickCodeRelation({
id: 'so-1',
code: 'SO-001',
}),
).toEqual({ id: 'so-1', code: 'SO-001' });
expect(CODE_RELATION_FIELDS).toEqual(['id', 'code']);
expect(pickCodeRelation(null)).toBeNull();
});
});
describe('pickDefaultRelation / pickUserRelation', () => {
it('maps catalog and user sources without leaking extra fields', () => {
expect(
@@ -1,5 +1,6 @@
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
export const CODE_RELATION_FIELDS = ['id', 'code'] as const;
export type DefaultRelation = {
readonly id: string;
@@ -12,6 +13,11 @@ export type UserRelation = {
readonly username: string;
};
export type CodeRelation = {
readonly id: string;
readonly code: string;
};
export function pickRelation<T, K extends keyof NonNullable<T>>(
source: T | null | undefined,
fields: readonly K[],
@@ -40,3 +46,16 @@ export function pickUserRelation(source: UserRelation): UserRelation {
}
);
}
export function pickCodeRelation(
source: CodeRelation | null | undefined,
): CodeRelation | null {
return pickRelation(source, CODE_RELATION_FIELDS);
}
export function fallbackUserRelation(
source: UserRelation | null | undefined,
fallbackId: string,
): UserRelation {
return pickUserRelation(source ?? { id: fallbackId, username: '' });
}
+8
View File
@@ -18,3 +18,11 @@ export class UserRelationDto {
@ApiProperty()
username!: string;
}
export class CodeRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
}