Skip to content

Commit

Permalink
Custom filter support for typeorm
Browse files Browse the repository at this point in the history
  • Loading branch information
luca-nardelli committed Oct 10, 2021
1 parent 9af43aa commit 5929f56
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 104 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/helpers/filter.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ export class FilterBuilder {
throw new Error(`unknown comparison ${JSON.stringify(fieldOrNested)}`);
}
const nestedFilterFn = this.build(value);
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : null);
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : undefined);
}
}
3 changes: 2 additions & 1 deletion packages/core/src/interfaces/filter.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,5 +111,6 @@ type FilterGrouping<T> = {
* ```
*
* @typeparam T - the type of object to filter on.
* @typeparam C - custom filters defined on the object.
*/
export type Filter<T> = FilterGrouping<T> & FilterComparisons<T>;
export type Filter<T, C = Record<string, any>> = FilterGrouping<T> & FilterComparisons<T> & { [K in keyof C]: C[K] };
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,16 @@ Array [
exports[`WhereBuilder should accept a empty filter 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity"`;

exports[`WhereBuilder should accept a empty filter 2`] = `Array []`;

exports[`WhereBuilder should accept custom filters alongside regular filters 1`] = `SELECT "TestEntity"."test_entity_pk" AS "TestEntity_test_entity_pk", "TestEntity"."string_type" AS "TestEntity_string_type", "TestEntity"."bool_type" AS "TestEntity_bool_type", "TestEntity"."number_type" AS "TestEntity_number_type", "TestEntity"."date_type" AS "TestEntity_date_type", "TestEntity"."oneTestRelationTestRelationPk" AS "TestEntity_oneTestRelationTestRelationPk" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."numberType" % ?) == 0) AND (ST_Distance("TestEntity"."fakePointType", ST_MakePoint(?,?)) <= ?)`;

exports[`WhereBuilder should accept custom filters alongside regular filters 2`] = `
Array [
1,
10,
5,
45.3,
9.5,
50000,
]
`;
86 changes: 49 additions & 37 deletions packages/query-typeorm/__tests__/query/filter-query.builder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { anything, instance, mock, verify, when, deepEqual } from 'ts-mockito';
import { QueryBuilder, WhereExpression } from 'typeorm';
import { Class, Filter, Query, SortDirection, SortNulls } from '@nestjs-query/core';
import { anything, deepEqual, instance, mock, verify, when } from 'ts-mockito';
import { QueryBuilder, WhereExpression } from 'typeorm';
import { CustomFilterRegistry, FilterQueryBuilder, WhereBuilder } from '../../src/query';
import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture';
import { TestSoftDeleteEntity } from '../__fixtures__/test-soft-delete.entity';
import { TestEntity } from '../__fixtures__/test.entity';
import { FilterQueryBuilder, WhereBuilder } from '../../src/query';

describe('FilterQueryBuilder', (): void => {
beforeEach(createTestConnection);
Expand Down Expand Up @@ -98,15 +98,23 @@ describe('FilterQueryBuilder', (): void => {
it('should not call whereBuilder#build', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
expectSelectSQLSnapshot({}, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});

it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), 'TestEntity')).thenCall(
(where: WhereExpression, field: Filter<TestEntity>, relationNames: string[], alias: string) =>
where.andWhere(`${alias}.stringType = 'foo'`),
when(
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined, 'TestEntity'),
).thenCall(
(
where: WhereExpression,
field: Filter<TestEntity>,
relationNames: string[],
klass: Class<any>,
customFilters: CustomFilterRegistry,
alias: string,
) => where.andWhere(`${alias}.stringType = 'foo'`),
);
expectSelectSQLSnapshot(query, instance(mockWhereBuilder));
});
Expand All @@ -116,19 +124,23 @@ describe('FilterQueryBuilder', (): void => {
it('should apply empty paging args', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
expectSelectSQLSnapshot({}, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never();
verify(
mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, undefined, 'TestEntity'),
).never();
});

it('should apply paging args going forward', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
expectSelectSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never();
verify(
mockWhereBuilder.build(anything(), anything(), deepEqual({}), TestEntity, undefined, 'TestEntity'),
).never();
});

it('should apply paging args going backward', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
expectSelectSQLSnapshot({ paging: { limit: 10, offset: 10 } }, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});
});

Expand All @@ -139,7 +151,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});

it('should apply ASC NULLS_FIRST sorting', () => {
Expand All @@ -148,7 +160,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});

it('should apply ASC NULLS_LAST sorting', () => {
Expand All @@ -157,7 +169,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});

it('should apply DESC sorting', () => {
Expand All @@ -166,7 +178,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});

it('should apply DESC NULLS_FIRST sorting', () => {
Expand All @@ -183,7 +195,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});

it('should apply multiple sorts', () => {
Expand All @@ -199,7 +211,7 @@ describe('FilterQueryBuilder', (): void => {
},
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
verify(mockWhereBuilder.build(anything(), anything(), {}, TestEntity, undefined, 'TestEntity')).never();
});
});
});
Expand All @@ -214,17 +226,17 @@ describe('FilterQueryBuilder', (): void => {
it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
);
when(
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined, undefined),
).thenCall((where: WhereExpression) => where.andWhere(`stringType = 'foo'`));
expectUpdateSQLSnapshot(query, instance(mockWhereBuilder));
});
});
describe('with paging', () => {
it('should ignore paging args', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
expectUpdateSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});
});

Expand All @@ -235,7 +247,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});

it('should apply ASC NULLS_FIRST sorting', () => {
Expand All @@ -244,7 +256,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});

it('should apply ASC NULLS_LAST sorting', () => {
Expand All @@ -253,7 +265,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});

it('should apply DESC sorting', () => {
Expand All @@ -262,7 +274,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});

it('should apply DESC NULLS_FIRST sorting', () => {
Expand All @@ -271,7 +283,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_FIRST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});

it('should apply DESC NULLS_LAST sorting', () => {
Expand All @@ -280,7 +292,7 @@ describe('FilterQueryBuilder', (): void => {
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] },
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});

it('should apply multiple sorts', () => {
Expand All @@ -296,7 +308,7 @@ describe('FilterQueryBuilder', (): void => {
},
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});
});
});
Expand All @@ -311,17 +323,17 @@ describe('FilterQueryBuilder', (): void => {
it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
);
when(
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestEntity, undefined, undefined),
).thenCall((where: WhereExpression) => where.andWhere(`stringType = 'foo'`));
expectDeleteSQLSnapshot(query, instance(mockWhereBuilder));
});
});
describe('with paging', () => {
it('should ignore paging args', () => {
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
expectDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});
});

Expand All @@ -339,7 +351,7 @@ describe('FilterQueryBuilder', (): void => {
},
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});
});
});
Expand All @@ -357,17 +369,17 @@ describe('FilterQueryBuilder', (): void => {
it('should call whereBuilder#build if there is a filter', () => {
const mockWhereBuilder = mock<WhereBuilder<TestSoftDeleteEntity>>(WhereBuilder);
const query = { filter: { stringType: { eq: 'foo' } } };
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
);
when(
mockWhereBuilder.build(anything(), query.filter, deepEqual({}), TestSoftDeleteEntity, undefined, undefined),
).thenCall((where: WhereExpression) => where.andWhere(`stringType = 'foo'`));
expectSoftDeleteSQLSnapshot(query, instance(mockWhereBuilder));
});
});
describe('with paging', () => {
it('should ignore paging args', () => {
const mockWhereBuilder = mock<WhereBuilder<TestSoftDeleteEntity>>(WhereBuilder);
expectSoftDeleteSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});
});

Expand All @@ -383,7 +395,7 @@ describe('FilterQueryBuilder', (): void => {
},
instance(mockWhereBuilder),
);
verify(mockWhereBuilder.build(anything(), anything(), anything())).never();
verify(mockWhereBuilder.build(anything(), anything(), anything(), anything())).never();
});
});
});
Expand Down
45 changes: 43 additions & 2 deletions packages/query-typeorm/__tests__/query/where.builder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Filter } from '@nestjs-query/core';
import { randomString } from '../../src/common';
import { CustomFilterRegistry, CustomFilterResult, WhereBuilder } from '../../src/query';
import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture';
import { TestEntity } from '../__fixtures__/test.entity';
import { WhereBuilder } from '../../src/query';

describe('WhereBuilder', (): void => {
beforeEach(createTestConnection);
Expand All @@ -11,8 +12,40 @@ describe('WhereBuilder', (): void => {
const getQueryBuilder = () => getRepo().createQueryBuilder();
const createWhereBuilder = () => new WhereBuilder<TestEntity>();

const customFilterRegistry = new CustomFilterRegistry();
customFilterRegistry.setFilter<TestEntity>(TestEntity, 'numberType', 'isMultipleOf', {
apply(field, cmp, val: number, alias): CustomFilterResult {
alias = alias ? alias : '';
const pname = `param${randomString()}`;
return {
sql: `("${alias}"."${field}" % :${pname}) == 0`,
params: { [pname]: val },
};
},
});
// This property does not actually exist in the entity, but since we are testing only the generated SQL it's ok.
customFilterRegistry.setFilter<TestEntity>(TestEntity, 'fakePointType', 'distanceFrom', {
apply(field, cmp, val: { point: { lat: number; lng: number }; radius: number }, alias): CustomFilterResult {
alias = alias ? alias : '';
const plat = `param${randomString()}`;
const plng = `param${randomString()}`;
const prad = `param${randomString()}`;
return {
sql: `ST_Distance("${alias}"."${field}", ST_MakePoint(:${plat},:${plng})) <= :${prad}`,
params: { [plat]: val.point.lat, [plng]: val.point.lng, [prad]: val.radius },
};
},
});

const expectSQLSnapshot = (filter: Filter<TestEntity>): void => {
const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, 'TestEntity');
const selectQueryBuilder = createWhereBuilder().build(
getQueryBuilder(),
filter,
{},
TestEntity,
customFilterRegistry,
'TestEntity',
);
const [sql, params] = selectQueryBuilder.getQueryAndParameters();
expect(sql).toMatchSnapshot();
expect(params).toMatchSnapshot();
Expand All @@ -30,6 +63,14 @@ describe('WhereBuilder', (): void => {
expectSQLSnapshot({ numberType: { eq: 1 }, stringType: { like: 'foo%' }, boolType: { is: true } });
});

// TODO Fix typings to avoid usage of any
it('should accept custom filters alongside regular filters', (): void => {
expectSQLSnapshot({
numberType: { gte: 1, lte: 10, isMultipleOf: 5 },
fakePointType: { distanceFrom: { point: { lat: 45.3, lng: 9.5 }, radius: 50000 } },
} as any);
});

describe('and', (): void => {
it('and multiple expressions together', (): void => {
expectSQLSnapshot({
Expand Down
Loading

0 comments on commit 5929f56

Please sign in to comment.