mirror of
https://github.com/hexastack/hexabot
synced 2025-05-31 10:57:06 +00:00
feat: add support for arrays in search filter
This commit is contained in:
parent
039ce0e1bc
commit
1261c804de
161
api/src/utils/pipes/search-filter.pipe.spec.ts
Normal file
161
api/src/utils/pipes/search-filter.pipe.spec.ts
Normal 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'] }],
|
||||
});
|
||||
});
|
||||
});
|
@ -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>);
|
||||
}
|
||||
}
|
||||
|
@ -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 */
|
||||
|
Loading…
Reference in New Issue
Block a user