Skip to content

Commit 45aff05

Browse files
committed
Force UTC timezone to make some tests (e.g. typeorm-query-service.spec) deterministic
Implement type-based custom filters + field specific custom filters Filter registration through decorators performed on module initialization Tests
1 parent 098f83a commit 45aff05

36 files changed

+1161
-160
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@
1414
"lint": "eslint --cache --ext=.ts .",
1515
"lint:no-cache": "eslint --ext=.ts .",
1616
"lint:fix": "eslint --fix --ext=.ts .",
17-
"jest": "jest --runInBand --coverage",
18-
"jest:e2e": "jest --runInBand --config=./jest.e2e.config.js",
19-
"jest:unit": "jest --coverage --config=./jest.unit.config.js",
17+
"jest": "TZ=UTC jest --runInBand --coverage",
18+
"jest:e2e": "TZ=UTC jest --runInBand --config=./jest.e2e.config.js",
19+
"jest:unit": "TZ=UTC jest --coverage --config=./jest.unit.config.js",
2020
"coverage": "cat ./coverage/lcov.info | coveralls",
2121
"prepare": "husky install"
2222
},

packages/core/src/helpers/filter.builder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ export class FilterBuilder {
5858
throw new Error(`unknown comparison ${JSON.stringify(fieldOrNested)}`);
5959
}
6060
const nestedFilterFn = this.build(value);
61-
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : null);
61+
return (dto?: DTO) => nestedFilterFn(dto ? dto[fieldOrNested] : undefined);
6262
}
6363
}

packages/core/src/interfaces/filter.interface.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,5 +111,6 @@ type FilterGrouping<T> = {
111111
* ```
112112
*
113113
* @typeparam T - the type of object to filter on.
114+
* @typeparam C - custom filters defined on the object.
114115
*/
115-
export type Filter<T> = FilterGrouping<T> & FilterComparisons<T>;
116+
export type Filter<T, C = Record<string, any>> = FilterGrouping<T> & FilterComparisons<T> & { [K in keyof C]: C[K] };

packages/query-typeorm/__tests__/__fixtures__/connection.fixture.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,13 @@ export const CONNECTION_OPTIONS: ConnectionOptions = {
1616
logging: false,
1717
};
1818

19-
export function createTestConnection(): Promise<Connection> {
20-
return createConnection(CONNECTION_OPTIONS);
19+
// eslint-disable-next-line @typescript-eslint/ban-types
20+
export function createTestConnection(opts?: { extraEntities: (string | Function)[] }): Promise<Connection> {
21+
const connOpts: ConnectionOptions = {
22+
...CONNECTION_OPTIONS,
23+
entities: [...(CONNECTION_OPTIONS.entities || []), ...(opts?.extraEntities ?? [])],
24+
};
25+
return createConnection(connOpts);
2126
}
2227

2328
export function closeTestConnection(): Promise<void> {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { ColumnType } from 'typeorm';
2+
import { randomString } from '../../src/common';
3+
import { CustomFilter, CustomFilterResult } from '../../src/query/custom-filter.registry';
4+
import { TypeOrmQueryFilter } from '../../src/decorators/typeorm-query-filter.decorator';
5+
6+
type IsMultipleOfOpType = 'isMultipleOf';
7+
8+
@TypeOrmQueryFilter()
9+
export class IsMultipleOfCustomFilter implements CustomFilter<IsMultipleOfOpType> {
10+
readonly operations: IsMultipleOfOpType[] = ['isMultipleOf'];
11+
12+
readonly types: ColumnType[] = [Number, 'integer'];
13+
14+
apply(field: string, cmp: IsMultipleOfOpType, val: unknown, alias?: string): CustomFilterResult {
15+
alias = alias ? alias : '';
16+
const pname = `param${randomString()}`;
17+
return {
18+
sql: `(${alias}.${field} % :${pname}) == 0`,
19+
params: { [pname]: val },
20+
};
21+
}
22+
}
23+
24+
@TypeOrmQueryFilter()
25+
export class IsMultipleOfDateCustomFilter implements CustomFilter<IsMultipleOfOpType> {
26+
readonly operations: IsMultipleOfOpType[] = ['isMultipleOf'];
27+
28+
readonly types: ColumnType[] = [Date, 'date', 'datetime'];
29+
30+
apply(field: string, cmp: IsMultipleOfOpType, val: unknown, alias?: string): CustomFilterResult {
31+
alias = alias ? alias : '';
32+
const pname = `param${randomString()}`;
33+
return {
34+
sql: `(EXTRACT(EPOCH FROM ${alias}.${field}) / 3600 / 24) % :${pname}) == 0`,
35+
params: { [pname]: val },
36+
};
37+
}
38+
}
39+
40+
type RadiusCustomFilterOp = 'distanceFrom';
41+
42+
@TypeOrmQueryFilter({
43+
autoRegister: false,
44+
})
45+
export class RadiusCustomFilter implements CustomFilter<RadiusCustomFilterOp> {
46+
readonly operations: RadiusCustomFilterOp[] = ['distanceFrom'];
47+
48+
apply(
49+
field: string,
50+
cmp: RadiusCustomFilterOp,
51+
val: { point: { lat: number; lng: number }; radius: number },
52+
alias?: string,
53+
): CustomFilterResult {
54+
alias = alias ? alias : '';
55+
const plat = `param${randomString()}`;
56+
const plng = `param${randomString()}`;
57+
const prad = `param${randomString()}`;
58+
return {
59+
sql: `ST_Distance(${alias}.${field}, ST_MakePoint(:${plat},:${plng})) <= :${prad}`,
60+
params: { [plat]: val.point.lat, [plng]: val.point.lng, [prad]: val.radius },
61+
};
62+
}
63+
}

packages/query-typeorm/__tests__/__fixtures__/seeds.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,33 @@ export const TEST_SOFT_DELETE_ENTITIES: TestSoftDeleteEntity[] = [1, 2, 3, 4, 5,
2323
};
2424
});
2525

26-
export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce(
27-
(relations, te) => [
26+
// Generate different numberTypes so we can use them for filters later on
27+
export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce((relations, te) => {
28+
return [
2829
...relations,
2930
{
3031
testRelationPk: `test-relations-${te.testEntityPk}-1`,
3132
relationName: `${te.stringType}-test-relation-one`,
3233
testEntityId: te.testEntityPk,
3334
uniDirectionalTestEntityId: te.testEntityPk,
35+
numberType: te.numberType * 10 + 1,
3436
},
3537
{
3638
testRelationPk: `test-relations-${te.testEntityPk}-2`,
3739
relationName: `${te.stringType}-test-relation-two`,
3840
testEntityId: te.testEntityPk,
3941
uniDirectionalTestEntityId: te.testEntityPk,
42+
numberType: te.numberType * 10 + 2,
4043
},
4144
{
4245
testRelationPk: `test-relations-${te.testEntityPk}-3`,
4346
relationName: `${te.stringType}-test-relation-three`,
4447
testEntityId: te.testEntityPk,
4548
uniDirectionalTestEntityId: te.testEntityPk,
49+
numberType: te.numberType * 10 + 3,
4650
},
47-
],
48-
[] as TestRelation[],
49-
);
51+
];
52+
}, [] as TestRelation[]);
5053

5154
export const TEST_RELATIONS_OF_RELATION = TEST_RELATIONS.map<Partial<RelationOfTestRelationEntity>>((testRelation) => ({
5255
relationName: `test-relation-of-${testRelation.relationName}`,

packages/query-typeorm/__tests__/__fixtures__/test-relation.entity.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ManyToOne, Column, Entity, JoinColumn, ManyToMany, OneToOne, OneToMany, PrimaryColumn } from 'typeorm';
1+
import { Column, Entity, JoinColumn, ManyToMany, ManyToOne, OneToMany, OneToOne, PrimaryColumn } from 'typeorm';
22
import { TestEntityRelationEntity } from './test-entity-relation.entity';
33
import { TestEntity } from './test.entity';
44
import { RelationOfTestRelationEntity } from './relation-of-test-relation.entity';
@@ -17,6 +17,9 @@ export class TestRelation {
1717
@Column({ name: 'uni_directional_test_entity_id', nullable: true })
1818
uniDirectionalTestEntityId?: string;
1919

20+
@Column({ name: 'number_type' })
21+
numberType?: number;
22+
2023
@ManyToOne(() => TestEntity, (te) => te.testRelations, { onDelete: 'CASCADE' })
2124
@JoinColumn({ name: 'test_entity_id' })
2225
testEntity?: TestEntity;

packages/query-typeorm/__tests__/__fixtures__/test.entity.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1-
import { Column, Entity, OneToMany, ManyToMany, JoinTable, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
1+
import { Column, Entity, JoinColumn, JoinTable, ManyToMany, OneToMany, OneToOne, PrimaryColumn } from 'typeorm';
2+
import { WithTypeormQueryFilter } from '../../src/decorators/with-typeorm-entity-query-filter.decorator';
3+
import { RadiusCustomFilter } from './custom-filters.services';
24
import { TestEntityRelationEntity } from './test-entity-relation.entity';
35
import { TestRelation } from './test-relation.entity';
46

57
@Entity()
8+
@WithTypeormQueryFilter<TestEntity>({
9+
filter: RadiusCustomFilter,
10+
fields: ['fakePointType'],
11+
})
612
export class TestEntity {
713
@PrimaryColumn({ name: 'test_entity_pk' })
814
testEntityPk!: string;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Column, Entity, PrimaryColumn } from 'typeorm';
2+
import { getQueryTypeormMetadata, QueryTypeormEntityMetadata } from '../../src/common';
3+
import { closeTestConnection, createTestConnection, getTestConnection } from '../__fixtures__/connection.fixture';
4+
import { TestEntityRelationEntity } from '../__fixtures__/test-entity-relation.entity';
5+
import { TestRelation } from '../__fixtures__/test-relation.entity';
6+
import { TestEntity } from '../__fixtures__/test.entity';
7+
8+
describe('TypeormMetadata', (): void => {
9+
class TestEmbedded {
10+
@Column({ type: 'text' })
11+
stringType!: string;
12+
13+
@Column({ type: 'boolean' })
14+
boolType!: boolean;
15+
}
16+
17+
@Entity()
18+
class TestMetadataEntity {
19+
@PrimaryColumn({ type: 'text' })
20+
pk!: string;
21+
22+
@Column({ type: 'text' })
23+
stringType!: string;
24+
25+
@Column({ type: 'boolean' })
26+
boolType!: boolean;
27+
28+
@Column({ type: 'integer' })
29+
numberType!: number;
30+
31+
@Column({ type: 'date' })
32+
dateType!: Date;
33+
34+
@Column({ type: 'datetime' })
35+
datetimeType!: Date;
36+
37+
@Column({ type: 'simple-json' })
38+
jsonType!: any;
39+
40+
@Column(() => TestEmbedded)
41+
embeddedType!: TestEmbedded;
42+
}
43+
44+
beforeEach(() => createTestConnection({ extraEntities: [TestMetadataEntity] }));
45+
afterEach(() => closeTestConnection());
46+
47+
it('Test metadata', (): void => {
48+
const meta = getQueryTypeormMetadata(getTestConnection());
49+
console.log(meta);
50+
// Implicit column types
51+
expect(meta.get(TestEntity)).toMatchObject({
52+
testEntityPk: { metaType: 'property', type: String },
53+
stringType: { metaType: 'property', type: String },
54+
dateType: { metaType: 'property', type: Date },
55+
boolType: { metaType: 'property', type: Boolean },
56+
oneTestRelation: { metaType: 'relation', type: TestRelation },
57+
testRelations: { metaType: 'relation', type: TestRelation },
58+
manyTestRelations: { metaType: 'relation', type: TestRelation },
59+
manyToManyUniDirectional: { metaType: 'relation', type: TestRelation },
60+
testEntityRelation: { metaType: 'relation', type: TestEntityRelationEntity },
61+
} as QueryTypeormEntityMetadata<TestEntity>);
62+
// Explicit column types
63+
expect(meta.get(TestMetadataEntity)).toMatchObject({
64+
pk: { metaType: 'property', type: 'text' },
65+
stringType: { metaType: 'property', type: 'text' },
66+
boolType: { metaType: 'property', type: 'boolean' },
67+
numberType: { metaType: 'property', type: 'integer' },
68+
dateType: { metaType: 'property', type: 'date' },
69+
datetimeType: { metaType: 'property', type: 'datetime' },
70+
jsonType: { metaType: 'property', type: 'simple-json' },
71+
} as QueryTypeormEntityMetadata<TestMetadataEntity>);
72+
});
73+
});

packages/query-typeorm/__tests__/module.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { NestjsQueryTypeOrmModule } from '../src';
33
describe('NestjsQueryTypeOrmModule', () => {
44
it('should create a module', () => {
55
class TestEntity {}
6+
67
const typeOrmModule = NestjsQueryTypeOrmModule.forFeature([TestEntity]);
78
expect(typeOrmModule.imports).toHaveLength(1);
89
expect(typeOrmModule.module).toBe(NestjsQueryTypeOrmModule);
9-
expect(typeOrmModule.providers).toHaveLength(1);
10+
expect(typeOrmModule.providers).toHaveLength(3);
1011
expect(typeOrmModule.exports).toHaveLength(2);
1112
});
1213
});
Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
import { getQueryServiceToken } from '@nestjs-query/core';
22
import { getRepositoryToken } from '@nestjs/typeorm';
3-
import { Repository } from 'typeorm';
4-
import { mock, instance } from 'ts-mockito';
3+
import { createConnection, Repository } from 'typeorm';
4+
import { instance, mock } from 'ts-mockito';
55
import { createTypeOrmQueryServiceProviders } from '../src/providers';
66
import { TypeOrmQueryService } from '../src/services';
7+
import { CustomFilterRegistry } from '../src/query';
78

89
describe('createTypeOrmQueryServiceProviders', () => {
9-
it('should create a provider for the entity', () => {
10+
it('should create a provider for the entity', async () => {
1011
class TestEntity {}
12+
13+
// We need a connection in order to extract entity metadata
14+
const conn = await createConnection({
15+
type: 'sqlite',
16+
database: ':memory:',
17+
dropSchema: true,
18+
entities: [TestEntity],
19+
synchronize: true,
20+
logging: false,
21+
});
1122
const mockRepo = mock<Repository<TestEntity>>(Repository);
12-
const providers = createTypeOrmQueryServiceProviders([TestEntity]);
23+
const providers = createTypeOrmQueryServiceProviders([TestEntity], conn);
1324
expect(providers).toHaveLength(1);
1425
expect(providers[0].provide).toBe(getQueryServiceToken(TestEntity));
15-
expect(providers[0].inject).toEqual([getRepositoryToken(TestEntity)]);
26+
expect(providers[0].inject).toEqual([getRepositoryToken(TestEntity), CustomFilterRegistry]);
1627
expect(providers[0].useFactory(instance(mockRepo))).toBeInstanceOf(TypeOrmQueryService);
28+
29+
await conn.close();
1730
});
1831
});

packages/query-typeorm/__tests__/query/__snapshots__/filter-query.builder.spec.ts.snap

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,34 @@ exports[`FilterQueryBuilder #delete with sorting should ignore sorting 1`] = `DE
1212

1313
exports[`FilterQueryBuilder #delete with sorting should ignore sorting 2`] = `Array []`;
1414

15+
exports[`FilterQueryBuilder #select with custom filter should add custom 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"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`;
16+
17+
exports[`FilterQueryBuilder #select with custom filter should add custom filters 2`] = `
18+
Array [
19+
1,
20+
10,
21+
5,
22+
3,
23+
45.3,
24+
9.5,
25+
50000,
26+
]
27+
`;
28+
29+
exports[`FilterQueryBuilder #select with custom filter should add custom filters with aggregate 1`] = `SELECT MAX("TestEntity"."number_type") AS "MAX_numberType" FROM "test_entity" "TestEntity" WHERE ("TestEntity"."number_type" >= ? OR "TestEntity"."number_type" <= ? OR ("TestEntity"."number_type" % ?) == 0) AND ((EXTRACT(EPOCH FROM "TestEntity"."date_type") / 3600 / 24) % ?) == 0) AND (ST_Distance(TestEntity.fakePointType, ST_MakePoint(?,?)) <= ?)`;
30+
31+
exports[`FilterQueryBuilder #select with custom filter should add custom filters with aggregate 2`] = `
32+
Array [
33+
1,
34+
10,
35+
5,
36+
3,
37+
45.3,
38+
9.5,
39+
50000,
40+
]
41+
`;
42+
1543
exports[`FilterQueryBuilder #select with filter should call whereBuilder#build if there is a 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" WHERE "TestEntity"."string_type" = 'foo'`;
1644

1745
exports[`FilterQueryBuilder #select with filter should call whereBuilder#build if there is a filter 2`] = `Array []`;

0 commit comments

Comments
 (0)