From 1261c804de0cd2dc8a1f6f43f8b862b519409d88 Mon Sep 17 00:00:00 2001 From: Mohamed Marrouchi Date: Sun, 29 Dec 2024 14:12:53 +0100 Subject: [PATCH] feat: add support for arrays in search filter --- .../utils/pipes/search-filter.pipe.spec.ts | 161 ++++++++++++++++++ api/src/utils/pipes/search-filter.pipe.ts | 72 +++++--- api/src/utils/types/filter.types.ts | 6 +- 3 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 api/src/utils/pipes/search-filter.pipe.spec.ts diff --git a/api/src/utils/pipes/search-filter.pipe.spec.ts b/api/src/utils/pipes/search-filter.pipe.spec.ts new file mode 100644 index 00000000..fe9b51a2 --- /dev/null +++ b/api/src/utils/pipes/search-filter.pipe.spec.ts @@ -0,0 +1,161 @@ +/* + * Copyright © 2024 Hexastack. All rights reserved. + * + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: + * 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. + * 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file). + */ + +import { Logger } from '@nestjs/common'; + +import { TSearchFilterValue } from '../types/filter.types'; + +import { SearchFilterPipe } from './search-filter.pipe'; + +type PipeTest = { + name: string; + email: string; + id: string; + roles: string[]; + status: string; +}; + +jest.mock('@nestjs/common', () => ({ + ...(jest.requireActual('@nestjs/common') as Record), + Logger: { + warn: jest.fn(), + }, +})); + +describe('SearchFilterPipe', () => { + const allowedFields = [ + 'name', + 'email', + 'id', + 'status', + 'roles', + ] as (keyof PipeTest)[]; + + const pipe = new SearchFilterPipe({ + allowedFields, + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should transform a simple "where" query correctly', async () => { + const input = { + where: { + name: 'John Doe', + email: { contains: 'example' }, + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $and: [{ name: 'John Doe' }, { email: /example/i }], + }); + }); + + it('should handle "or" queries correctly', async () => { + const input = { + where: { + or: [{ name: 'John Doe' }, { email: { contains: 'example' } }], + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $or: [{ name: 'John Doe' }, { email: /example/i }], + }); + }); + + it('should filter out disallowed fields', async () => { + const input = { + where: { + name: 'John Doe', + secret: 'top-secret', // Disallowed field + }, + } as TSearchFilterValue; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $and: [{ name: 'John Doe' }], + }); + + expect(Logger.warn).toHaveBeenCalledWith('Field secret is not allowed'); + }); + + it('should transform "id" field into ObjectId when valid', async () => { + const oid = '9'.repeat(24); + const input = { + where: { + id: oid, + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $and: [{ _id: oid }], + }); + }); + + it('should skip "id" field if it is not a valid ObjectId', async () => { + const input = { + where: { + id: 'invalid-id', + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({}); + }); + + it('should handle null values properly', async () => { + const input = { + where: { + name: 'null', + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $and: [{ name: undefined }], + }); + }); + + it('should handle "!=" operator correctly', async () => { + const input = { + where: { + status: { '!=': 'inactive' }, + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $nor: [{ status: 'inactive' }], + }); + }); + + it('should handle "in" operator correctly', async () => { + const input = { + where: { + roles: ['1', '2'], + }, + }; + + const result = await pipe.transform(input, {} as any); + + expect(result).toEqual({ + $and: [{ roles: ['1', '2'] }], + }); + }); +}); diff --git a/api/src/utils/pipes/search-filter.pipe.ts b/api/src/utils/pipes/search-filter.pipe.ts index 6d84f9c5..15d266f7 100644 --- a/api/src/utils/pipes/search-filter.pipe.ts +++ b/api/src/utils/pipes/search-filter.pipe.ts @@ -55,71 +55,93 @@ export class SearchFilterPipe private transformField(field: string, val?: unknown): TTransformFieldProps { if (['id'].includes(field)) { - if (Types.ObjectId.isValid(String(val[field]))) + if (Types.ObjectId.isValid(String(val))) { return { - operator: 'eq', - [field === 'id' ? '_id' : field]: this.getNullableValue(val[field]), + _operator: 'eq', + [field === 'id' ? '_id' : field]: this.getNullableValue(String(val)), }; + } return {}; } else if (val['contains'] || val[field]?.['contains']) { return { - operator: 'iLike', + _operator: 'iLike', [field]: this.getRegexValue( String(val['contains'] || val[field]['contains']), ), }; } else if (val['!=']) { return { - operator: 'neq', + _operator: 'neq', [field]: this.getNullableValue(val['!=']), }; } + return { - operator: 'eq', - [field]: this.getNullableValue(String(val)), + _operator: 'eq', + [field]: Array.isArray(val) + ? val.map((v) => this.getNullableValue(v)).filter((v) => v) + : this.getNullableValue(String(val)), }; } async transform(value: TSearchFilterValue, _metadata: ArgumentMetadata) { const whereParams = value['where'] ?? {}; const filters: TTransformFieldProps[] = []; - if (whereParams?.['or']) + + if (whereParams?.['or']) { Object.values(whereParams['or']) .filter((val) => this.isAllowedField(Object.keys(val)[0])) .map((val) => { const [field] = Object.keys(val); const filter = this.transformField(field, val[field]); - if (filter.operator) + if (filter._operator) filters.push({ ...filter, - context: 'or', + _context: 'or', }); }); + } delete whereParams['or']; - if (whereParams) + + if (whereParams) { Object.entries(whereParams) .filter(([field]) => this.isAllowedField(field)) - .map(([field, val]) => { + .forEach(([field, val]) => { const filter = this.transformField(field, val); - if (filter.operator) + if (filter._operator) { filters.push({ ...filter, - context: 'and', + _context: 'and', }); + } }); + } - return filters.reduce( - (acc, { context, operator, ...filter }) => ({ - ...acc, - ...(operator === 'neq' - ? { $nor: [...(acc?.$nor || []), filter] } - : context === 'or' - ? { $or: [...(acc?.$or || []), filter] } - : context === 'and' && { $and: [...(acc?.$and || []), filter] }), - }), - {} as TFilterQuery, - ); + return filters.reduce((acc, { _context, _operator, ...filter }) => { + switch (_operator) { + case 'neq': + return { + ...acc, + $nor: [...(acc?.$nor || []), filter], + }; + default: + switch (_context) { + case 'or': + return { + ...acc, + $or: [...(acc?.$or || []), filter], + }; + case 'and': + return { + ...acc, + $and: [...(acc?.$and || []), filter], + }; + default: + return acc; // Handle any other cases if necessary + } + } + }, {} as TFilterQuery); } } diff --git a/api/src/utils/types/filter.types.ts b/api/src/utils/types/filter.types.ts index 27003bab..ebeff1ff 100644 --- a/api/src/utils/types/filter.types.ts +++ b/api/src/utils/types/filter.types.ts @@ -105,10 +105,10 @@ type TOperator = 'eq' | 'iLike' | 'neq'; type TContext = 'and' | 'or'; export type TTransformFieldProps = { - [x: string]: string | RegExp; + [x: string]: string | RegExp | string[]; _id?: string; - context?: TContext; - operator?: TOperator; + _context?: TContext; + _operator?: TOperator; }; /* mongoose */