From 1be66ab3a7b98665d5cacfd4eaf9547390d92367 Mon Sep 17 00:00:00 2001 From: Pawel Pograniczny Date: Thu, 28 Mar 2024 13:23:45 +0100 Subject: [PATCH] feat: add query filters to query response meta data --- functions/example-lambda/event.schema.ts | 1 + functions/example-lambda/handler.ts | 30 +---- shared/middleware/query-parser.ts | 4 +- .../pagination-utils/pagination-utils.spec.ts | 47 +++++--- shared/pagination-utils/pagination-utils.ts | 114 +++++++++++++++--- 5 files changed, 138 insertions(+), 58 deletions(-) diff --git a/functions/example-lambda/event.schema.ts b/functions/example-lambda/event.schema.ts index aaf638c..57e3eff 100644 --- a/functions/example-lambda/event.schema.ts +++ b/functions/example-lambda/event.schema.ts @@ -11,6 +11,7 @@ export const exampleLambdaSchema = z.object({ limit: z.string().optional(), sort: z.record(z.string()).optional(), filter: z.record(z.string()).optional(), + search: z.string().optional(), }), }); diff --git a/functions/example-lambda/handler.ts b/functions/example-lambda/handler.ts index dd47b7a..3423c29 100644 --- a/functions/example-lambda/handler.ts +++ b/functions/example-lambda/handler.ts @@ -14,11 +14,7 @@ import { queryParser } from "../../shared/middleware/query-parser"; import { zodValidator } from "../../shared/middleware/zod-validator"; import { httpCorsConfigured } from "../../shared/middleware/http-cors-configured"; import { httpErrorHandlerConfigured } from "../../shared/middleware/http-error-handler-configured"; -import { - calculateSkipFindOption, - isFilterAvailable, - makePaginationResult, -} from "../../shared/pagination-utils/pagination-utils"; +import { createFindManyOptions, makePaginationResult } from "../../shared/pagination-utils/pagination-utils"; const connectToDb = dataSource.initialize(); const config = createConfig(process.env); @@ -29,27 +25,13 @@ const lambdaHandler = async (event: ExampleLambdaPayload) => { await connectToDb; - const { page: pageString, limit: limitString, sort, filter } = event.queryStringParameters; - const page = Number(pageString); - const limit = Number(limitString); - const findOptions = {} as any; - - if (limit && page) { - findOptions.take = limit; - findOptions.skip = calculateSkipFindOption(page, limit); - } - - if (sort && isFilterAvailable(sort, userRepository)) { - findOptions.order = sort; - } - - if (filter && isFilterAvailable(filter, userRepository)) { - findOptions.where = filter; - } - + const findOptions = createFindManyOptions(userRepository, event.queryStringParameters); const [data, total] = await userRepository.findAndCount(findOptions); - return awsLambdaResponse(StatusCodes.OK, makePaginationResult(data, total, limit, page)); + return awsLambdaResponse( + StatusCodes.OK, + makePaginationResult(data, total, findOptions, event.queryStringParameters.search), + ); }; export const handle = middy() diff --git a/shared/middleware/query-parser.ts b/shared/middleware/query-parser.ts index a9e25c5..3f07cfc 100644 --- a/shared/middleware/query-parser.ts +++ b/shared/middleware/query-parser.ts @@ -37,7 +37,9 @@ export const queryParser = (): middy.MiddlewareObj { }, ]; + const queryFilters = { order: { firstName: "ASC" }, where: { lastName: "Doe" } }; + it("returns valid pagination", () => { - const result = makePaginationResult(data, 10, 4, 2); + const result = makePaginationResult(data, 10, { take: 4, skip: 2, ...queryFilters }, "john"); assert.deepEqual(result, { meta: { - page: 2, - limit: 4, - total: 10, - totalPages: 3, + pagination: { + page: 2, + limit: 4, + total: 10, + totalPages: 3, + }, + filter: queryFilters.where, + sort: queryFilters.order, + search: "john", }, data, }); }); it("returns valid pagination if limit is 0", () => { - const result = makePaginationResult(data, 10, 0, 2); + const result = makePaginationResult(data, 10, { take: 0, skip: 2, ...queryFilters }, "john"); assert.deepEqual(result, { meta: { - page: 2, - limit: 0, - total: 10, - totalPages: null, + pagination: { + page: 2, + limit: 0, + total: 10, + totalPages: null, + }, + filter: queryFilters.where, + sort: queryFilters.order, + search: "john", }, data, }); }); it("returns first page if passed 0 page", () => { - const result = makePaginationResult(data, 10, 5, 2); + const result = makePaginationResult(data, 10, { take: 5, skip: 2, ...queryFilters }, "john"); assert.deepEqual(result, { meta: { - page: 2, - limit: 5, - total: 10, - totalPages: 2, + pagination: { + page: 2, + limit: 5, + total: 10, + totalPages: 2, + }, + filter: queryFilters.where, + sort: queryFilters.order, + search: "john", }, data, }); diff --git a/shared/pagination-utils/pagination-utils.ts b/shared/pagination-utils/pagination-utils.ts index c8bd2aa..6177d52 100644 --- a/shared/pagination-utils/pagination-utils.ts +++ b/shared/pagination-utils/pagination-utils.ts @@ -1,36 +1,114 @@ -import { Repository } from "typeorm"; -import { AppError } from "../errors/app.error"; +import { + FindManyOptions, + FindOperator, + FindOptionsOrder, + FindOptionsWhere, + Like, + ObjectLiteral, + Repository, +} from "typeorm"; -export interface PaginationResult { +export interface PaginationResult { meta: { - page?: number; - limit?: number; - total: number; - totalPages: number | null; + pagination: { + page?: number; + limit?: number; + total: number; + totalPages: number | null; + }; + filter?: FindOptionsWhere | FindOptionsWhere[]; + sort?: FindOptionsOrder | FindOptionsOrder[]; + search?: string; }; - data: any; + data: T[]; } -export function calculateSkipFindOption(page: number, limit: number) { +export interface PaginationParamsDto { + page?: string; + limit?: string; + sort?: { [key: string]: string }; + filter?: { [key: string]: string | string[] }; + search?: string; +} + +type QueryFilters = { [key: string]: "ASC" | "DESC" } | { [key: string]: string | string[] }; + +export function calculateSkipFindOption(page: number, limit: number): number { return (page - 1) * limit; } -export function isFilterAvailable(filter: any, repository: Repository): boolean { +export function getAvailableFilters( + filter: QueryFilters, + repository: Repository, +): QueryFilters { const availableFilters = repository.metadata.columns.map((column) => column.propertyName); + const currentFilters = filter; + + Object.keys(currentFilters).forEach((key) => { + if (!availableFilters.includes(key)) { + delete currentFilters[key]; + } + }); + + return currentFilters; +} + +export function createFindManyOptions( + repository: Repository, + queryParams: PaginationParamsDto, +): FindManyOptions { + const { page: pageString, limit: limitString, sort, filter, search } = queryParams; + const page = Number(pageString); + const limit = Number(limitString); + const findOptions: FindManyOptions = {}; + + if (limit && page) { + findOptions.take = limit; + findOptions.skip = calculateSkipFindOption(page, limit); + } - if (Object.keys(filter).some((key) => availableFilters.includes(key))) { - return true; + if (sort) { + findOptions.order = getAvailableFilters(sort, repository); } - throw new AppError("Invalid query string"); + + if (filter) { + findOptions.where = getAvailableFilters(filter, repository); + } + + if (search) { + findOptions.where = { ...findOptions.where, email: Like(`%${search}%`) }; + } + + return findOptions; } -export function makePaginationResult(data: any, total: number, limit?: number, page?: number): PaginationResult { +export function makePaginationResult( + data: T[], + total: number, + findOptions: FindManyOptions, + search?: string, +): PaginationResult { + const { skip: page, take: limit, order: sort, where: filter } = findOptions; + + if (search && filter && !Array.isArray(filter)) { + Object.keys(filter).forEach((key) => { + if (filter[key] instanceof FindOperator) { + delete filter[key]; + } + }); + } + return { meta: { - page: page || 1, - limit, - total, - totalPages: limit ? Math.ceil(total / Math.max(limit, 1)) : null, + pagination: { + page: page || 1, + limit, + total, + totalPages: limit ? Math.ceil(total / Math.max(limit, 1)) : null, + }, + filter, + sort, + search, }, data, };