feat: add support for arrays in search filter

This commit is contained in:
Mohamed Marrouchi 2024-12-29 14:12:53 +01:00
parent 039ce0e1bc
commit 1261c804de
3 changed files with 211 additions and 28 deletions

View File

@ -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<string, unknown>),
Logger: {
warn: jest.fn(),
},
}));
describe('SearchFilterPipe', () => {
const allowedFields = [
'name',
'email',
'id',
'status',
'roles',
] as (keyof PipeTest)[];
const pipe = new SearchFilterPipe<PipeTest>({
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<PipeTest>;
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'] }],
});
});
});

View File

@ -55,71 +55,93 @@ export class SearchFilterPipe<T>
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<T>, _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<T>,
);
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<T>);
}
}

View File

@ -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 */