Skip to content

Commit

Permalink
feat: refactored filter builder to support nested object filters
Browse files Browse the repository at this point in the history
  • Loading branch information
Dylan Stanfield authored and doug-martin committed Aug 14, 2020
1 parent d78bdb8 commit 1ee8dbf
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 43 deletions.
137 changes: 129 additions & 8 deletions packages/core/__tests__/helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,14 +314,6 @@ describe('applyFilter', () => {
expect(applyFilter({ first: 'e', last: 'bar' }, filter)).toBe(true);
});

it('should throw an error for an unknown operator', () => {
const filter: Filter<TestDTO> = {
// @ts-ignore
first: { foo: 'bar' },
};
expect(() => applyFilter({ first: 'baz', last: 'kaz' }, filter)).toThrow('unknown operator "foo"');
});

it('should handle and grouping', () => {
const filter: Filter<TestDTO> = {
and: [{ first: { eq: 'foo' } }, { last: { like: '%bar' } }],
Expand All @@ -342,6 +334,135 @@ describe('applyFilter', () => {
expect(applyFilter({ first: 'foo', last: 'baz' }, filter)).toBe(true);
expect(applyFilter({ first: 'fo', last: 'ba' }, filter)).toBe(false);
});

it('should handle nested objects', () => {
type ParentDTO = TestDTO & { child: TestDTO };
const parentFilter: Filter<ParentDTO> = {
child: { or: [{ first: { eq: 'foo' } }, { last: { like: '%bar' } }] },
};
const withChild = (child: TestDTO): ParentDTO => ({
first: 'baz',
last: 'qux',
child,
});
expect(applyFilter(withChild({ first: 'foo', last: 'bar' }), parentFilter)).toBe(true);
expect(applyFilter(withChild({ first: 'foo', last: 'foobar' }), parentFilter)).toBe(true);
expect(applyFilter(withChild({ first: 'oo', last: 'foobar' }), parentFilter)).toBe(true);
expect(applyFilter(withChild({ first: 'foo', last: 'baz' }), parentFilter)).toBe(true);
expect(applyFilter(withChild({ first: 'oo', last: 'baz' }), parentFilter)).toBe(false);

type GrandParentDTO = TestDTO & { child: ParentDTO };
const grandParentFilter: Filter<GrandParentDTO> = {
child: { child: { or: [{ first: { eq: 'foo' } }, { last: { like: '%bar' } }] } },
};
const withGrandChild = (child: TestDTO): GrandParentDTO => ({
first: 'baz',
last: 'qux',
child: { first: 'baz', last: 'qux', child },
});
expect(applyFilter(withGrandChild({ first: 'foo', last: 'bar' }), grandParentFilter)).toBe(true);
expect(applyFilter(withGrandChild({ first: 'foo', last: 'foobar' }), grandParentFilter)).toBe(true);
expect(applyFilter(withGrandChild({ first: 'oo', last: 'foobar' }), grandParentFilter)).toBe(true);
expect(applyFilter(withGrandChild({ first: 'foo', last: 'baz' }), grandParentFilter)).toBe(true);
expect(applyFilter(withGrandChild({ first: 'oo', last: 'baz' }), grandParentFilter)).toBe(false);
});

describe('nested nulls', () => {
type ParentDTO = TestDTO & { child: TestDTO | null };
type GrandParentDTO = TestDTO & { child: ParentDTO | null };
const singleNestedNull = (): ParentDTO => ({ child: null });
const doubleNestedNull = (): GrandParentDTO => ({ child: null });

it('should handle like comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { like: '%foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { like: '%foo' } } } })).toBe(false);
});

it('should handle notLike comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { notLike: '%foo' } } })).toBe(true);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { notLike: '%foo' } } } })).toBe(true);
});

it('should handle iLike comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { iLike: '%foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { iLike: '%foo' } } } })).toBe(false);
});

it('should handle notILike comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { notILike: '%foo' } } })).toBe(true);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { notILike: '%foo' } } } })).toBe(true);
});

it('should handle in comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { in: ['foo'] } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { in: ['foo'] } } } })).toBe(false);
});

it('should handle notIn comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { notIn: ['foo'] } } })).toBe(true);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { notIn: ['foo'] } } } })).toBe(true);
});

it('should handle between comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { between: { lower: 'foo', upper: 'bar' } } } })).toBe(
false,
);
expect(
applyFilter(doubleNestedNull(), { child: { child: { first: { between: { lower: 'foo', upper: 'bar' } } } } }),
).toBe(false);
});

it('should handle notBetween comparisons', () => {
expect(
applyFilter(singleNestedNull(), { child: { first: { notBetween: { lower: 'foo', upper: 'bar' } } } }),
).toBe(true);
expect(
applyFilter(doubleNestedNull(), {
child: { child: { first: { notBetween: { lower: 'foo', upper: 'bar' } } } },
}),
).toBe(true);
});

it('should handle gt comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { gt: 'foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { gt: 'foo' } } } })).toBe(false);
});

it('should handle gte comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { gte: 'foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { gte: 'foo' } } } })).toBe(false);
});

it('should handle lt comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { lt: 'foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { lt: 'foo' } } } })).toBe(false);
});

it('should handle lte comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { lte: 'foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { lte: 'foo' } } } })).toBe(false);
});

it('should handle eq comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { eq: 'foo' } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { eq: 'foo' } } } })).toBe(false);
});

it('should handle neq comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { neq: 'foo' } } })).toBe(true);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { neq: 'foo' } } } })).toBe(true);
});

it('should handle is comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { is: null } } })).toBe(true);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { is: null } } } })).toBe(true);
});

it('should handle isNot comparisons', () => {
expect(applyFilter(singleNestedNull(), { child: { first: { isNot: null } } })).toBe(false);
expect(applyFilter(doubleNestedNull(), { child: { child: { first: { isNot: null } } } })).toBe(false);
});
});
});

describe('getFilterFields', () => {
Expand Down
53 changes: 34 additions & 19 deletions packages/core/src/helpers/comparison.builder.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CommonFieldComparisonBetweenType, FilterComparisonOperators } from '../interfaces';
import { CommonFieldComparisonBetweenType, FilterComparisonOperators, Filter } from '../interfaces';
import { ComparisonField, FilterFn } from './types';

type LikeComparisonOperators = 'like' | 'notLike' | 'iLike' | 'notILike';
Expand All @@ -7,6 +7,10 @@ type BetweenComparisonOperators = 'between' | 'notBetween';
type RangeComparisonOperators = 'gt' | 'gte' | 'lt' | 'lte';
type BooleanComparisonOperators = 'eq' | 'neq' | 'is' | 'isNot';

const compare = <DTO>(filter: (dto: DTO) => boolean, fallback: boolean): FilterFn<DTO> => {
return (dto?: DTO) => (dto ? filter(dto) : fallback);
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isLikeComparisonOperator = (op: any): op is LikeComparisonOperators => {
return op === 'like' || op === 'notLike' || op === 'iLike' || op === 'notILike';
Expand All @@ -32,6 +36,19 @@ const isBooleanComparisonOperators = (op: any): op is BooleanComparisonOperators
return op === 'eq' || op === 'neq' || op === 'is' || op === 'isNot';
};

export const isComparison = <DTO>(maybeComparison: Filter<DTO>[keyof DTO]): boolean => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Object.keys(maybeComparison as Record<string, any>).every((op) => {
return (
isLikeComparisonOperator(op) ||
isInComparisonOperators(op) ||
isBetweenComparisonOperators(op) ||
isRangeComparisonOperators(op) ||
isBooleanComparisonOperators(op)
);
});
};

export class ComparisonBuilder {
static build<DTO, F extends keyof DTO>(
field: F,
Expand Down Expand Up @@ -63,9 +80,9 @@ export class ComparisonBuilder {
val: DTO[F],
): FilterFn<DTO> {
if (cmp === 'neq' || cmp === 'isNot') {
return (dto: DTO): boolean => dto[field] !== val;
return (dto?: DTO): boolean => (dto ? dto[field] : null) !== val;
}
return (dto: DTO): boolean => dto[field] === val;
return (dto?: DTO): boolean => (dto ? dto[field] : null) === val;
}

private static rangeComparison<DTO, F extends keyof DTO>(
Expand All @@ -74,15 +91,15 @@ export class ComparisonBuilder {
val: DTO[F],
): FilterFn<DTO> {
if (cmp === 'gt') {
return (dto: DTO): boolean => dto[field] > val;
return compare((dto) => dto[field] > val, false);
}
if (cmp === 'gte') {
return (dto: DTO): boolean => dto[field] >= val;
return compare((dto) => dto[field] >= val, false);
}
if (cmp === 'lt') {
return (dto: DTO): boolean => dto[field] < val;
return compare((dto) => dto[field] < val, false);
}
return (dto: DTO): boolean => dto[field] <= val;
return compare((dto) => dto[field] <= val, false);
}

private static likeComparison<DTO, F extends keyof DTO>(
Expand All @@ -92,20 +109,18 @@ export class ComparisonBuilder {
): FilterFn<DTO> {
if (cmp === 'like') {
const likeRegexp = this.likeSearchToRegexp(val);
return (dto: DTO): boolean => {
return likeRegexp.test((dto[field] as unknown) as string);
};
return compare((dto) => likeRegexp.test((dto[field] as unknown) as string), false);
}
if (cmp === 'notLike') {
const likeRegexp = this.likeSearchToRegexp(val);
return (dto: DTO): boolean => !likeRegexp.test((dto[field] as unknown) as string);
return compare((dto) => !likeRegexp.test((dto[field] as unknown) as string), true);
}
if (cmp === 'iLike') {
const likeRegexp = this.likeSearchToRegexp(val, true);
return (dto: DTO): boolean => likeRegexp.test((dto[field] as unknown) as string);
return compare((dto) => likeRegexp.test((dto[field] as unknown) as string), false);
}
const likeRegexp = this.likeSearchToRegexp(val, true);
return (dto: DTO): boolean => !likeRegexp.test((dto[field] as unknown) as string);
return compare((dto) => !likeRegexp.test((dto[field] as unknown) as string), true);
}

private static inComparison<DTO, F extends keyof DTO>(
Expand All @@ -114,9 +129,9 @@ export class ComparisonBuilder {
val: DTO[F][],
): FilterFn<DTO> {
if (cmp === 'notIn') {
return (dto: DTO): boolean => !val.includes(dto[field]);
return compare((dto) => !val.includes(dto[field]), true);
}
return (dto: DTO): boolean => val.includes(dto[field]);
return compare((dto) => val.includes(dto[field]), false);
}

private static betweenComparison<DTO, F extends keyof DTO>(
Expand All @@ -126,15 +141,15 @@ export class ComparisonBuilder {
): FilterFn<DTO> {
const { lower, upper } = val;
if (cmp === 'notBetween') {
return (dto: DTO): boolean => {
return compare((dto) => {
const dtoVal = dto[field];
return dtoVal < lower || dtoVal > upper;
};
}, true);
}
return (dto: DTO): boolean => {
return compare((dto) => {
const dtoVal = dto[field];
return dtoVal >= lower && dtoVal <= upper;
};
}, false);
}

private static likeSearchToRegexp(likeStr: string, caseInsensitive = false): RegExp {
Expand Down
37 changes: 22 additions & 15 deletions packages/core/src/helpers/filter.builder.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,54 @@
import { Filter, FilterComparisons, FilterFieldComparison } from '../interfaces';
import { ComparisonBuilder } from './comparison.builder';
import { ComparisonBuilder, isComparison } from './comparison.builder';
import { ComparisonField, FilterFn } from './types';

export class FilterBuilder {
static build<DTO>(filter: Filter<DTO>): FilterFn<DTO> {
const { and, or } = filter;
const filters: FilterFn<DTO>[] = [];
if (and && and.length) {

if (and) {
filters.push(this.andFilterFn(...and.map((f) => this.build(f))));
}
if (or && or.length) {

if (or) {
filters.push(this.orFilterFn(...or.map((f) => this.build(f))));
}
filters.push(this.filterFields(filter));

filters.push(this.filterFieldsOrNested(filter));
return this.andFilterFn(...filters);
}

private static andFilterFn<DTO>(...filterFns: FilterFn<DTO>[]): FilterFn<DTO> {
return (dto) => filterFns.every((fn) => fn(dto));
return (dto) => filterFns.every((filter) => filter(dto));
}

private static orFilterFn<DTO>(...filterFns: FilterFn<DTO>[]): FilterFn<DTO> {
return (dto) => filterFns.some((fn) => fn(dto));
return (dto) => filterFns.some((filter) => filter(dto));
}

private static filterFields<DTO>(filter: Filter<DTO>): FilterFn<DTO> {
private static filterFieldsOrNested<DTO>(filter: Filter<DTO>): FilterFn<DTO> {
return this.andFilterFn(
...Object.keys(filter)
.filter((k) => k !== 'and' && k !== 'or')
.map((field) =>
this.withFilterComparison(
field as keyof DTO,
this.getField(filter as FilterComparisons<DTO>, field as keyof DTO),
),
),
.map((fieldOrNested) => {
const value = this.getField(filter as FilterComparisons<DTO>, fieldOrNested as keyof DTO);

if (isComparison(filter[fieldOrNested as keyof DTO])) {
return this.withFilterComparison(fieldOrNested as keyof DTO, value);
}

const nestedFilterFn = this.build(value);
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested as keyof DTO] : null);
}),
);
}

private static getField<DTO, K extends keyof FilterComparisons<DTO>>(
obj: FilterComparisons<DTO>,
field: K,
): FilterFieldComparison<DTO[K]> {
return obj[field] as FilterFieldComparison<DTO[K]>;
): FilterFieldComparison<DTO[K]> & Filter<DTO[K]> {
return obj[field] as FilterFieldComparison<DTO[K]> & Filter<DTO[K]>;
}

private static withFilterComparison<DTO, T extends keyof DTO>(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/helpers/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CommonFieldComparisonBetweenType } from '../interfaces';

export type FilterFn<DTO> = (dto: DTO) => boolean;
export type FilterFn<DTO> = (dto?: DTO) => boolean;

export type ComparisonField<DTO, F extends keyof DTO> =
| DTO[F]
Expand Down

0 comments on commit 1ee8dbf

Please sign in to comment.