Skip to content

Commit 0bd6b76

Browse files
psteinroedoug-martin
authored andcommitted
feat(query-typeorm): allow deeply nested filters
1 parent 738e9da commit 0bd6b76

File tree

12 files changed

+307
-47
lines changed

12 files changed

+307
-47
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { TestRelation } from './test-relation.entity';
55
import { TestSoftDeleteEntity } from './test-soft-delete.entity';
66
import { TestEntity } from './test.entity';
77
import { seed } from './seeds';
8+
import { RelationOfTestRelationEntity } from './relation-of-test-relation.entity';
89

910
export const CONNECTION_OPTIONS: ConnectionOptions = {
1011
type: 'sqlite',
1112
database: ':memory:',
1213
dropSchema: true,
13-
entities: [TestEntity, TestSoftDeleteEntity, TestRelation, TestEntityRelationEntity],
14+
entities: [TestEntity, TestSoftDeleteEntity, TestRelation, TestEntityRelationEntity, RelationOfTestRelationEntity],
1415
synchronize: true,
1516
logging: false,
1617
};
@@ -29,6 +30,7 @@ export function getTestConnection(): Connection {
2930

3031
const tables = [
3132
'test_entity',
33+
'relation_of_test_relation_entity',
3234
'test_relation',
3335
'test_entity_relation_entity',
3436
'test_soft_delete_entity',
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ManyToOne, Column, Entity, JoinColumn, PrimaryColumn } from 'typeorm';
2+
import { TestRelation } from './test-relation.entity';
3+
4+
@Entity()
5+
export class RelationOfTestRelationEntity {
6+
@PrimaryColumn({ name: 'test_relation_pk' })
7+
id!: string;
8+
9+
@Column({ name: 'relation_name' })
10+
relationName!: string;
11+
12+
@Column({ name: 'test_relation_id' })
13+
testRelationId!: string;
14+
15+
@ManyToOne(() => TestRelation, (tr) => tr.testEntityRelation, { onDelete: 'CASCADE' })
16+
@JoinColumn({ name: 'test_relation_id' })
17+
testRelation!: TestRelation;
18+
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Connection, getConnection } from 'typeorm';
22
import { TestRelation } from './test-relation.entity';
33
import { TestSoftDeleteEntity } from './test-soft-delete.entity';
44
import { TestEntity } from './test.entity';
5+
import { RelationOfTestRelationEntity } from './relation-of-test-relation.entity';
56

67
export const TEST_ENTITIES: TestEntity[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => {
78
const testEntityPk = `test-entity-${i}`;
@@ -47,15 +48,26 @@ export const TEST_RELATIONS: TestRelation[] = TEST_ENTITIES.reduce(
4748
[] as TestRelation[],
4849
);
4950

51+
export const TEST_RELATIONS_OF_RELATION = TEST_RELATIONS.map<Partial<RelationOfTestRelationEntity>>((testRelation) => ({
52+
relationName: `test-relation-of-${testRelation.relationName}`,
53+
id: `relation-of-test-relation-${testRelation.relationName}`,
54+
testRelationId: testRelation.testRelationPk,
55+
})) as RelationOfTestRelationEntity[];
56+
5057
export const seed = async (connection: Connection = getConnection()): Promise<void> => {
5158
const testEntityRepo = connection.getRepository(TestEntity);
5259
const testRelationRepo = connection.getRepository(TestRelation);
60+
const relationOfTestRelationRepo = connection.getRepository(RelationOfTestRelationEntity);
5361
const testSoftDeleteRepo = connection.getRepository(TestSoftDeleteEntity);
5462

5563
const testEntities = await testEntityRepo.save(TEST_ENTITIES.map((e: TestEntity) => ({ ...e })));
5664

5765
const testRelations = await testRelationRepo.save(TEST_RELATIONS.map((r: TestRelation) => ({ ...r })));
5866

67+
await relationOfTestRelationRepo.save(
68+
TEST_RELATIONS_OF_RELATION.map((r: RelationOfTestRelationEntity) => ({ ...r })),
69+
);
70+
5971
await Promise.all(
6072
testEntities.map((te) => {
6173
// eslint-disable-next-line no-param-reassign
@@ -71,6 +83,15 @@ export const seed = async (connection: Connection = getConnection()): Promise<vo
7183
return testEntityRepo.save(te);
7284
}),
7385
);
86+
await Promise.all(
87+
testRelations.map(async (te) => {
88+
const relationOfTestRelationEntity = TEST_RELATIONS_OF_RELATION.find(
89+
(r) => r.testRelationId === te.testRelationPk,
90+
);
91+
te.relationOfTestRelationId = relationOfTestRelationEntity?.id;
92+
return testRelationRepo.save(te);
93+
}),
94+
);
7495

7596
await testSoftDeleteRepo.save(TEST_SOFT_DELETE_ENTITIES.map((e: TestSoftDeleteEntity) => ({ ...e })));
7697
};

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ManyToOne, Column, Entity, JoinColumn, ManyToMany, OneToOne, OneToMany, PrimaryColumn } from 'typeorm';
22
import { TestEntityRelationEntity } from './test-entity-relation.entity';
33
import { TestEntity } from './test.entity';
4+
import { RelationOfTestRelationEntity } from './relation-of-test-relation.entity';
45

56
@Entity()
67
export class TestRelation {
@@ -32,4 +33,14 @@ export class TestRelation {
3233

3334
@OneToMany(() => TestEntityRelationEntity, (ter) => ter.testRelation)
3435
testEntityRelation?: TestEntityRelationEntity;
36+
37+
@OneToMany(() => RelationOfTestRelationEntity, (ter) => ter.testRelation)
38+
relationsOfTestRelation?: RelationOfTestRelationEntity;
39+
40+
@Column({ name: 'uni_directional_relation_test_entity_id', nullable: true })
41+
relationOfTestRelationId?: string;
42+
43+
@ManyToOne(() => RelationOfTestRelationEntity, { onDelete: 'CASCADE' })
44+
@JoinColumn({ name: 'uni_directional_relation_test_entity_id' })
45+
relationOfTestRelation?: RelationOfTestRelationEntity;
3546
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class TestEntity {
3030
@JoinTable()
3131
manyToManyUniDirectional?: TestRelation[];
3232

33-
@OneToOne(() => TestRelation, (relation) => relation.oneTestEntity)
33+
@OneToOne(() => TestRelation, (relation) => relation.oneTestEntity, { nullable: true })
3434
@JoinColumn()
3535
oneTestRelation?: TestRelation;
3636

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

Lines changed: 81 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,73 @@ describe('FilterQueryBuilder', (): void => {
2121
expect(params).toMatchSnapshot();
2222
};
2323

24+
describe('#getReferencedRelationsRecursive', () => {
25+
it('with deeply nested and / or', () => {
26+
const complexQuery: Filter<TestEntity> = {
27+
and: [
28+
{
29+
or: [
30+
{ and: [{ stringType: { eq: '123' } }] },
31+
{
32+
and: [{ stringType: { eq: '123' } }, { id: { gt: '123' } }],
33+
},
34+
],
35+
},
36+
{
37+
stringType: { eq: '345' },
38+
or: [
39+
{ oneTestRelation: { relationName: { eq: '123' } } },
40+
{ oneTestRelation: { relationOfTestRelation: { testRelationId: { eq: 'e1' } } } },
41+
],
42+
},
43+
],
44+
};
45+
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
46+
const qb = getEntityQueryBuilder(TestEntity, instance(mockWhereBuilder));
47+
expect(qb.getReferencedRelationsRecursive(qb.repo.metadata, complexQuery)).toEqual({
48+
oneTestRelation: { relationOfTestRelation: {} },
49+
});
50+
});
51+
it('with nested and / or', () => {
52+
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
53+
const qb = getEntityQueryBuilder(TestEntity, instance(mockWhereBuilder));
54+
expect(
55+
qb.getReferencedRelationsRecursive(qb.repo.metadata, {
56+
test: '123',
57+
and: [
58+
{
59+
boolType: { is: true },
60+
},
61+
{
62+
testRelations: {
63+
relationName: { eq: '123' },
64+
},
65+
},
66+
],
67+
or: [
68+
{
69+
boolType: { is: true },
70+
},
71+
{
72+
oneTestRelation: {
73+
testRelationPk: { eq: '123' },
74+
},
75+
},
76+
{
77+
oneTestRelation: {
78+
relationsOfTestRelation: {
79+
testRelationId: {
80+
eq: '123',
81+
},
82+
},
83+
},
84+
},
85+
],
86+
} as Filter<TestEntity>),
87+
).toEqual({ testRelations: {}, oneTestRelation: { relationsOfTestRelation: {} } });
88+
});
89+
});
90+
2491
describe('#select', () => {
2592
const expectSelectSQLSnapshot = (query: Query<TestEntity>, whereBuilder: WhereBuilder<TestEntity>): void => {
2693
const selectQueryBuilder = getEntityQueryBuilder(TestEntity, whereBuilder).select(query);
@@ -31,13 +98,13 @@ describe('FilterQueryBuilder', (): void => {
3198
it('should not call whereBuilder#build', () => {
3299
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
33100
expectSelectSQLSnapshot({}, instance(mockWhereBuilder));
34-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
101+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
35102
});
36103

37104
it('should call whereBuilder#build if there is a filter', () => {
38105
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
39106
const query = { filter: { stringType: { eq: 'foo' } } };
40-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), 'TestEntity')).thenCall(
107+
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), 'TestEntity')).thenCall(
41108
(where: WhereExpression, field: Filter<TestEntity>, relationNames: string[], alias: string) =>
42109
where.andWhere(`${alias}.stringType = 'foo'`),
43110
);
@@ -49,19 +116,19 @@ describe('FilterQueryBuilder', (): void => {
49116
it('should apply empty paging args', () => {
50117
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
51118
expectSelectSQLSnapshot({}, instance(mockWhereBuilder));
52-
verify(mockWhereBuilder.build(anything(), anything(), deepEqual([]), 'TestEntity')).never();
119+
verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never();
53120
});
54121

55122
it('should apply paging args going forward', () => {
56123
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
57124
expectSelectSQLSnapshot({ paging: { limit: 10, offset: 11 } }, instance(mockWhereBuilder));
58-
verify(mockWhereBuilder.build(anything(), anything(), deepEqual([]), 'TestEntity')).never();
125+
verify(mockWhereBuilder.build(anything(), anything(), deepEqual({}), 'TestEntity')).never();
59126
});
60127

61128
it('should apply paging args going backward', () => {
62129
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
63130
expectSelectSQLSnapshot({ paging: { limit: 10, offset: 10 } }, instance(mockWhereBuilder));
64-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
131+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
65132
});
66133
});
67134

@@ -72,7 +139,7 @@ describe('FilterQueryBuilder', (): void => {
72139
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC }] },
73140
instance(mockWhereBuilder),
74141
);
75-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
142+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
76143
});
77144

78145
it('should apply ASC NULLS_FIRST sorting', () => {
@@ -81,7 +148,7 @@ describe('FilterQueryBuilder', (): void => {
81148
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_FIRST }] },
82149
instance(mockWhereBuilder),
83150
);
84-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
151+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
85152
});
86153

87154
it('should apply ASC NULLS_LAST sorting', () => {
@@ -90,7 +157,7 @@ describe('FilterQueryBuilder', (): void => {
90157
{ sorting: [{ field: 'numberType', direction: SortDirection.ASC, nulls: SortNulls.NULLS_LAST }] },
91158
instance(mockWhereBuilder),
92159
);
93-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
160+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
94161
});
95162

96163
it('should apply DESC sorting', () => {
@@ -99,7 +166,7 @@ describe('FilterQueryBuilder', (): void => {
99166
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC }] },
100167
instance(mockWhereBuilder),
101168
);
102-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
169+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
103170
});
104171

105172
it('should apply DESC NULLS_FIRST sorting', () => {
@@ -116,7 +183,7 @@ describe('FilterQueryBuilder', (): void => {
116183
{ sorting: [{ field: 'numberType', direction: SortDirection.DESC, nulls: SortNulls.NULLS_LAST }] },
117184
instance(mockWhereBuilder),
118185
);
119-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
186+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
120187
});
121188

122189
it('should apply multiple sorts', () => {
@@ -132,7 +199,7 @@ describe('FilterQueryBuilder', (): void => {
132199
},
133200
instance(mockWhereBuilder),
134201
);
135-
verify(mockWhereBuilder.build(anything(), anything(), [], 'TestEntity')).never();
202+
verify(mockWhereBuilder.build(anything(), anything(), {}, 'TestEntity')).never();
136203
});
137204
});
138205
});
@@ -147,7 +214,7 @@ describe('FilterQueryBuilder', (): void => {
147214
it('should call whereBuilder#build if there is a filter', () => {
148215
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
149216
const query = { filter: { stringType: { eq: 'foo' } } };
150-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), undefined)).thenCall(
217+
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
151218
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
152219
);
153220
expectUpdateSQLSnapshot(query, instance(mockWhereBuilder));
@@ -244,7 +311,7 @@ describe('FilterQueryBuilder', (): void => {
244311
it('should call whereBuilder#build if there is a filter', () => {
245312
const mockWhereBuilder = mock<WhereBuilder<TestEntity>>(WhereBuilder);
246313
const query = { filter: { stringType: { eq: 'foo' } } };
247-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), undefined)).thenCall(
314+
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
248315
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
249316
);
250317
expectDeleteSQLSnapshot(query, instance(mockWhereBuilder));
@@ -290,7 +357,7 @@ describe('FilterQueryBuilder', (): void => {
290357
it('should call whereBuilder#build if there is a filter', () => {
291358
const mockWhereBuilder = mock<WhereBuilder<TestSoftDeleteEntity>>(WhereBuilder);
292359
const query = { filter: { stringType: { eq: 'foo' } } };
293-
when(mockWhereBuilder.build(anything(), query.filter, deepEqual([]), undefined)).thenCall(
360+
when(mockWhereBuilder.build(anything(), query.filter, deepEqual({}), undefined)).thenCall(
294361
(where: WhereExpression) => where.andWhere(`stringType = 'foo'`),
295362
);
296363
expectSoftDeleteSQLSnapshot(query, instance(mockWhereBuilder));

packages/query-typeorm/__tests__/query/where.builder.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe('WhereBuilder', (): void => {
1212
const createWhereBuilder = () => new WhereBuilder<TestEntity>();
1313

1414
const expectSQLSnapshot = (filter: Filter<TestEntity>): void => {
15-
const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, [], 'TestEntity');
15+
const selectQueryBuilder = createWhereBuilder().build(getQueryBuilder(), filter, {}, 'TestEntity');
1616
const [sql, params] = selectQueryBuilder.getQueryAndParameters();
1717
expect(sql).toMatchSnapshot();
1818
expect(params).toMatchSnapshot();

0 commit comments

Comments
 (0)