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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: '' });
|
||||
}
|
||||
|
||||
@@ -18,3 +18,11 @@ export class UserRelationDto {
|
||||
@ApiProperty()
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class CodeRelationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user