mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
Merge pull request #487 from Hexastack/fix/search-filter-array-support
feat: add support for arrays in search filter
This commit is contained in:
commit
54bbe94164
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 {
|
private transformField(field: string, val?: unknown): TTransformFieldProps {
|
||||||
if (['id'].includes(field)) {
|
if (['id'].includes(field)) {
|
||||||
if (Types.ObjectId.isValid(String(val[field])))
|
if (Types.ObjectId.isValid(String(val))) {
|
||||||
return {
|
return {
|
||||||
operator: 'eq',
|
_operator: 'eq',
|
||||||
[field === 'id' ? '_id' : field]: this.getNullableValue(val[field]),
|
[field === 'id' ? '_id' : field]: this.getNullableValue(String(val)),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
return {};
|
return {};
|
||||||
} else if (val['contains'] || val[field]?.['contains']) {
|
} else if (val['contains'] || val[field]?.['contains']) {
|
||||||
return {
|
return {
|
||||||
operator: 'iLike',
|
_operator: 'iLike',
|
||||||
[field]: this.getRegexValue(
|
[field]: this.getRegexValue(
|
||||||
String(val['contains'] || val[field]['contains']),
|
String(val['contains'] || val[field]['contains']),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
} else if (val['!=']) {
|
} else if (val['!=']) {
|
||||||
return {
|
return {
|
||||||
operator: 'neq',
|
_operator: 'neq',
|
||||||
[field]: this.getNullableValue(val['!=']),
|
[field]: this.getNullableValue(val['!=']),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
operator: 'eq',
|
_operator: 'eq',
|
||||||
[field]: this.getNullableValue(String(val)),
|
[field]: Array.isArray(val)
|
||||||
|
? val.map((v) => this.getNullableValue(v)).filter((v) => v)
|
||||||
|
: this.getNullableValue(String(val)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async transform(value: TSearchFilterValue<T>, _metadata: ArgumentMetadata) {
|
async transform(value: TSearchFilterValue<T>, _metadata: ArgumentMetadata) {
|
||||||
const whereParams = value['where'] ?? {};
|
const whereParams = value['where'] ?? {};
|
||||||
const filters: TTransformFieldProps[] = [];
|
const filters: TTransformFieldProps[] = [];
|
||||||
if (whereParams?.['or'])
|
|
||||||
|
if (whereParams?.['or']) {
|
||||||
Object.values(whereParams['or'])
|
Object.values(whereParams['or'])
|
||||||
.filter((val) => this.isAllowedField(Object.keys(val)[0]))
|
.filter((val) => this.isAllowedField(Object.keys(val)[0]))
|
||||||
.map((val) => {
|
.map((val) => {
|
||||||
const [field] = Object.keys(val);
|
const [field] = Object.keys(val);
|
||||||
const filter = this.transformField(field, val[field]);
|
const filter = this.transformField(field, val[field]);
|
||||||
if (filter.operator)
|
if (filter._operator)
|
||||||
filters.push({
|
filters.push({
|
||||||
...filter,
|
...filter,
|
||||||
context: 'or',
|
_context: 'or',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
delete whereParams['or'];
|
delete whereParams['or'];
|
||||||
if (whereParams)
|
|
||||||
|
if (whereParams) {
|
||||||
Object.entries(whereParams)
|
Object.entries(whereParams)
|
||||||
.filter(([field]) => this.isAllowedField(field))
|
.filter(([field]) => this.isAllowedField(field))
|
||||||
.map(([field, val]) => {
|
.forEach(([field, val]) => {
|
||||||
const filter = this.transformField(field, val);
|
const filter = this.transformField(field, val);
|
||||||
|
|
||||||
if (filter.operator)
|
if (filter._operator) {
|
||||||
filters.push({
|
filters.push({
|
||||||
...filter,
|
...filter,
|
||||||
context: 'and',
|
_context: 'and',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return filters.reduce(
|
return filters.reduce((acc, { _context, _operator, ...filter }) => {
|
||||||
(acc, { context, operator, ...filter }) => ({
|
switch (_operator) {
|
||||||
...acc,
|
case 'neq':
|
||||||
...(operator === 'neq'
|
return {
|
||||||
? { $nor: [...(acc?.$nor || []), filter] }
|
...acc,
|
||||||
: context === 'or'
|
$nor: [...(acc?.$nor || []), filter],
|
||||||
? { $or: [...(acc?.$or || []), filter] }
|
};
|
||||||
: context === 'and' && { $and: [...(acc?.$and || []), filter] }),
|
default:
|
||||||
}),
|
switch (_context) {
|
||||||
{} as TFilterQuery<T>,
|
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';
|
type TContext = 'and' | 'or';
|
||||||
|
|
||||||
export type TTransformFieldProps = {
|
export type TTransformFieldProps = {
|
||||||
[x: string]: string | RegExp;
|
[x: string]: string | RegExp | string[];
|
||||||
_id?: string;
|
_id?: string;
|
||||||
context?: TContext;
|
_context?: TContext;
|
||||||
operator?: TOperator;
|
_operator?: TOperator;
|
||||||
};
|
};
|
||||||
|
|
||||||
/* mongoose */
|
/* mongoose */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user