Skip to content

Commit fa1c028

Browse files
authored
feat(hydra-cli) relations filtering: single query design (Joystream#402)
* feat(hydra-cli): single-query implementation of relationship filtering affects: @dzlzv/hydra-cli * feat(hydra-cli): add support for a single-query relationship filters affects: @dzlzv/hydra-cli Builds a complex SQL subqueries using Warthog's query builders to filter out the relation field. To make things work, additional subclass WarthogBaseService is introduced, together with utils ISSUES CLOSED: Joystream#391 * test(hydra-e2e-tests): add e2e tests for relations filtering affects: hydra-e2e-tests * chore(sample): cleanup sample project affects: sample, sample-mappings * fix(hydra-cli): apply take-limit at the end of the relations filtering query affects: @dzlzv/hydra-cli
1 parent 05236c4 commit fa1c028

File tree

21 files changed

+629
-163
lines changed

21 files changed

+629
-163
lines changed

packages/hydra-cli/src/codegen/WarthogWrapper.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ export default class WarthogWrapper {
161161
'src/index.ts',
162162
'src/server.ts',
163163
'src/pubsub.ts',
164+
'src/utils.ts',
165+
'src/WarthogBaseService.ts',
164166
'src/processor.resolver.ts',
165167
'tsconfig.json',
166168
]

packages/hydra-cli/src/generate/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import _, { upperFirst, kebabCase, camelCase, snakeCase } from 'lodash'
1+
import _, { upperFirst, kebabCase, camelCase, snakeCase, toLower } from 'lodash'
22
import { GeneratorContext } from './SourcesGenerator'
33
import { ObjectType, Field } from '../model'
44
import pluralize from 'pluralize'
@@ -25,6 +25,7 @@ export function names(name: string): { [key: string]: string } {
2525
return {
2626
className: pascalCase(name),
2727
camelName: camelCase(name),
28+
typeormAliasName: toLower(name), // FIXME: do we have to support other namings?
2829
kebabName: kebabCase(name),
2930
relClassName: pascalCase(name),
3031
relCamelName: camelCase(name),

packages/hydra-cli/src/model/Relation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ interface RelationTypeGuard {
6969
isOTM: boolean
7070
isMTO: boolean
7171
isMTM: boolean
72-
isModifier: boolean
72+
isTM: boolean
73+
isTO: boolean
7374
}
7475

7576
export function getRelationType(r: Relation): RelationTypeGuard {
@@ -78,6 +79,7 @@ export function getRelationType(r: Relation): RelationTypeGuard {
7879
isOTM: r.type === RelationType.OTM,
7980
isMTO: r.type === RelationType.MTO,
8081
isMTM: r.type === RelationType.MTM,
81-
isModifier: r.type === RelationType.OTM || r.type === RelationType.MTM,
82+
isTM: r.type === RelationType.OTM || r.type === RelationType.MTM,
83+
isTO: r.type === RelationType.OTO || r.type === RelationType.MTO,
8284
}
8385
}

packages/hydra-cli/src/templates/entities/resolver.ts.mst

Lines changed: 140 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import {
1515
import graphqlFields from 'graphql-fields';
1616
import { Inject } from 'typedi';
1717
import { Min } from 'class-validator'
18-
import { Fields, StandardDeleteResponse, UserId, PageInfo, RawFields } from 'warthog';
18+
import { Fields, StandardDeleteResponse, UserId, PageInfo, RawFields, NestedFields } from 'warthog';
19+
20+
import { mergeParameterKeys } from '../../utils';
1921

2022
import {
2123
{{className}}CreateInput,
@@ -96,118 +98,159 @@ export class {{className}}Resolver {
9698

9799
@Query(() => [{{className}}])
98100
async {{camelNamePlural}}(
99-
@Args() { where, orderBy, limit, offset }: {{className}}WhereArgs,
101+
@Args() { where: _where, orderBy, limit, offset }: {{className}}WhereArgs,
100102
@Fields() fields: string[]
101103
): Promise<{{className}}[]> {
102-
{{#crossFilters}}
103-
if (where) {
104-
const id_in = where.id_in;
104+
const where = <{{className}}WhereInput>(_where || {})
105105

106-
{{#fieldResolvers}}
107-
{{#relationType.isOTO}}
106+
{{#fieldResolvers}}
107+
// remove relation filters to enable warthog query builders
108+
{{#relationType.isTO}}
108109
const { {{fieldName}} } = where
110+
delete where.{{fieldName}};
111+
{{/relationType.isTO}}
112+
113+
{{#relationType.isTM}}
114+
const { {{fieldName}}_some, {{fieldName}}_none, {{fieldName}}_every } = where
115+
116+
if ((+!!{{fieldName}}_some) + (+!!{{fieldName}}_none) + (+!!{{fieldName}}_every) > 1) {
117+
throw new Error(`A query can have at most one of none, some, every clauses on a relation field`)
118+
}
119+
120+
delete where.{{fieldName}}_some;
121+
delete where.{{fieldName}}_none;
122+
delete where.{{fieldName}}_every;
123+
{{/relationType.isTM}}
124+
{{/fieldResolvers}}
125+
126+
let mainQuery = this.service.buildFindQueryWithParams(
127+
<any>where,
128+
orderBy,
129+
undefined,
130+
fields,
131+
'main'
132+
).take(undefined); // remove LIMIT
133+
134+
let parameters = mergeParameterKeys({}, 'main', mainQuery.getQueryAndParameters()[1]);
135+
136+
{{#crossFilters}}
137+
138+
{{#fieldResolvers}}
139+
{{#relationType.isTO}}
140+
109141
if ({{fieldName}}) {
110-
where.id_in = (
111-
await this.{{fieldName}}Service
112-
.getQueryBuilder({{fieldName}})
113-
.leftJoinAndSelect('{{tableName}}.{{relatedTsProp}}', '{{relatedTsProp}}')
114-
.getMany()
115-
)
116-
.filter(f => f.{{relatedTsProp}})
117-
.map(f => f.{{relatedTsProp}}!.id);
118-
delete where.{{fieldName}};
142+
// OTO or MTO
143+
const {{fieldName}}Query = this.{{fieldName}}Service.buildFindQueryWithParams(
144+
<any>{{fieldName}},
145+
undefined,
146+
undefined,
147+
['id'],
148+
'{{fieldName}}'
149+
).take(undefined); // remove the default LIMIT
150+
151+
152+
mainQuery = mainQuery
153+
.leftJoin('{{typeormAliasName}}.{{fieldName}}', '{{fieldName}}')
154+
.andWhere(`{{fieldName}}.id IN (${ {{fieldName}}Query.getQuery() })`);
155+
156+
parameters = parameters = mergeParameterKeys(
157+
parameters,
158+
'{{fieldName}}',
159+
{{fieldName}}Query.getQueryAndParameters()[1]);;
160+
119161
}
120-
{{/relationType.isOTO}}
121-
122-
{{#relationType.isModifier}}
123-
const { {{fieldName}}_some, {{fieldName}}_none, {{fieldName}}_every } = where
124-
if ({{fieldName}}_some || {{fieldName}}_none || {{fieldName}}_every) {
125-
const result = await this.{{fieldName}}Service
126-
.getQueryBuilder({{fieldName}}_some || {{fieldName}}_none || {{fieldName}}_every)
127-
.leftJoinAndSelect('{{tableName}}.{{relatedTsProp}}', '{{relatedTsProp}}')
128-
.getMany();
129-
let {{relatedTsProp}}Ids: string[] = []
130-
{{#relationType.isOTM}}
131-
{{relatedTsProp}}Ids = Array.from(new Set(result.map(v => v!.{{relatedTsProp}}!.id)));
132-
{{/relationType.isOTM}}
133-
{{#relationType.isMTM}}
134-
Array.from(new Set(result.map(v => v!.{{relatedTsProp}}!.map(c => {{relatedTsProp}}Ids.push(c.id)))));
135-
{{/relationType.isMTM}}
136-
137-
delete where.{{fieldName}}_some
138-
delete where.{{fieldName}}_none
139-
delete where.{{fieldName}}_every
140-
141-
{{#relationType.isOTM}}
142-
if ({{fieldName}}_some) {
143-
where.id_in = {{relatedTsProp}}Ids;
144-
}
145-
{{/relationType.isOTM}}
162+
{{/relationType.isTO}}
163+
164+
{{#relationType.isTM}}
165+
166+
const {{fieldName}}Filter = {{fieldName}}_some || {{fieldName}}_none || {{fieldName}}_every
167+
168+
if ({{fieldName}}Filter) {
169+
170+
const {{fieldName}}Query = this.{{fieldName}}Service.buildFindQueryWithParams(<any>{{fieldName}}Filter,
171+
undefined,
172+
undefined,
173+
['id'],
174+
'{{fieldName}}'
175+
).take(undefined); //remove the default LIMIT
176+
177+
parameters = mergeParameterKeys(
178+
parameters,
179+
'{{fieldName}}',
180+
{{fieldName}}Query.getQueryAndParameters()[1]);
181+
182+
const subQueryFiltered = this.service
183+
.getQueryBuilder()
184+
.select([])
185+
.leftJoin(
186+
'{{typeormAliasName}}.{{fieldName}}',
187+
'{{fieldName}}_filtered',
188+
`{{fieldName}}_filtered.id IN (${ {{fieldName}}Query.getQuery() })`
189+
)
190+
.groupBy('{{typeormAliasName}}_id')
191+
.addSelect('count({{fieldName}}_filtered.id)', 'cnt_filtered')
192+
.addSelect('{{typeormAliasName}}.id', '{{typeormAliasName}}_id');
193+
194+
const subQueryTotal = this.service
195+
.getQueryBuilder()
196+
.select([])
197+
.leftJoin('{{typeormAliasName}}.{{fieldName}}', '{{fieldName}}_total')
198+
.groupBy('{{typeormAliasName}}_id')
199+
.addSelect('count({{fieldName}}_total.id)', 'cnt_total')
200+
.addSelect('{{typeormAliasName}}.id', '{{typeormAliasName}}_id');
201+
202+
const subQuery = `
203+
SELECT
204+
f.{{typeormAliasName}}_id {{typeormAliasName}}_id, f.cnt_filtered cnt_filtered, t.cnt_total cnt_total
205+
FROM
206+
(${subQueryTotal.getQuery()}) t, (${subQueryFiltered.getQuery()}) f
207+
WHERE
208+
t.{{typeormAliasName}}_id = f.{{typeormAliasName}}_id`;
146209

147-
{{#relationType.isMTM}}
148-
if ({{fieldName}}_some || {{fieldName}}_every) {
149-
where.id_in = {{relatedTsProp}}Ids;
150-
}
151-
{{/relationType.isMTM}}
152-
153210

154211
if ({{fieldName}}_none) {
155-
where.id_in = (
156-
await getRepository({{returnTypeFunc}}).find({
157-
select: ['id'],
158-
where: { id: Not(In({{relatedTsProp}}Ids)) }
159-
})
160-
).map(c => c.id);
212+
mainQuery = mainQuery.andWhere(`{{typeormAliasName}}.id IN
213+
(SELECT
214+
{{fieldName}}_subq.{{typeormAliasName}}_id
215+
FROM
216+
(${subQuery}) {{fieldName}}_subq
217+
WHERE
218+
{{fieldName}}_subq.cnt_filtered = 0
219+
)`)
220+
}
221+
222+
if ({{fieldName}}_some) {
223+
mainQuery = mainQuery.andWhere(`{{typeormAliasName}}.id IN
224+
(SELECT
225+
{{fieldName}}_subq.{{typeormAliasName}}_id
226+
FROM
227+
(${subQuery}) {{fieldName}}_subq
228+
WHERE
229+
{{fieldName}}_subq.cnt_filtered > 0
230+
)`)
161231
}
162232

163-
{{#relationType.isOTM}}
164233
if ({{fieldName}}_every) {
165-
getConnection().transaction(async em => {
166-
const finalIds = [];
167-
168-
// Group entity
169-
const g = _.chain(result)
170-
.groupBy(v => v!.{{relatedTsProp}}!.id)
171-
.value();
172-
173-
// Get all entities with their relations without filtering them
174-
for (const { id, {{fieldName}} } of await em.getRepository({{rootArgType}}).find({
175-
where: { id: In({{relatedTsProp}}Ids) },
176-
relations: ['{{fieldName}}']
177-
})) {
178-
if ({{fieldName}} && {{fieldName}}.length === g[id].length) {
179-
finalIds.push(id);
180-
}
181-
}
182-
where.id_in = finalIds;
183-
});
234+
mainQuery = mainQuery.andWhere(`{{typeormAliasName}}.id IN
235+
(SELECT
236+
{{fieldName}}_subq.{{typeormAliasName}}_id
237+
FROM
238+
(${subQuery}) {{fieldName}}_subq
239+
WHERE
240+
{{fieldName}}_subq.cnt_filtered > 0
241+
AND {{fieldName}}_subq.cnt_filtered = {{fieldName}}_subq.cnt_total
242+
)`)
184243
}
185-
{{/relationType.isOTM}}
186244
}
187-
{{/relationType.isModifier}}
245+
{{/relationType.isTM}}
188246

189-
{{#relationType.isMTO}}
190-
const { {{fieldName}} } = where
191-
if ({{fieldName}}) {
192-
const entityIds: string[] = [];
193-
(
194-
await this.{{fieldName}}Service
195-
.getQueryBuilder({{fieldName}})
196-
.leftJoinAndSelect('{{tableName}}.{{relatedTsProp}}', '{{relatedTsProp}}')
197-
.getMany()
198-
).map((c: any) => entityIds.push(...c.{{relatedTsProp}}!.map((v: any) => v.id)));
199-
where.id_in = entityIds;
200-
delete where.{{fieldName}};
201-
}
202-
{{/relationType.isMTO}}
203-
{{/fieldResolvers}}
204-
205-
if (id_in) {
206-
where.id_in = where.id_in ? [...id_in, ...where.id_in] : id_in;
207-
}
208-
}
247+
{{/fieldResolvers}}
248+
209249
{{/crossFilters}}
210-
return this.service.find<{{className}}WhereInput>(where, orderBy, limit, offset, fields);
250+
251+
mainQuery = mainQuery.setParameters(parameters);
252+
253+
return mainQuery.take(limit || 50).skip(offset || 0).getMany();
211254
}
212255

213256
@Query(() => {{className}}, { nullable: true })

packages/hydra-cli/src/templates/entities/service.ts.mst

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Service } from 'typedi';
22
import { Repository } from 'typeorm';
33
import { InjectRepository } from 'typeorm-typedi-extensions';
4-
import { BaseService, WhereInput } from 'warthog';
4+
import { WhereInput } from 'warthog';
5+
import { WarthogBaseService } from '../../WarthogBaseService';
56

67
import { {{className}} } from './{{kebabName}}.model';
78

89
import { {{#variantNames}} {{.}}, {{/variantNames}} } from '../variants/variants.model'
910

1011
@Service('{{className}}Service')
11-
export class {{className}}Service extends BaseService<{{className}}> {
12+
export class {{className}}Service extends WarthogBaseService<{{className}}> {
1213
constructor(
1314
@InjectRepository({{className}}) protected readonly repository: Repository<{{className}}>
1415
) {

0 commit comments

Comments
 (0)