Skip to content

Commit b02e31c

Browse files
authored
refactor: optimize relation selections and avoid unnecessary nested queries (#213)
* refactor: optimize relation selections and avoid unnecessary nested queries * update
1 parent 5846a81 commit b02e31c

File tree

4 files changed

+238
-132
lines changed

4 files changed

+238
-132
lines changed

packages/runtime/src/client/crud/dialects/base.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { invariant, isPlainObject } from '@zenstackhq/common-helpers';
22
import type { Expression, ExpressionBuilder, ExpressionWrapper, SqlBool, ValueNode } from 'kysely';
33
import { expressionBuilder, sql, type SelectQueryBuilder } from 'kysely';
44
import { match, P } from 'ts-pattern';
5-
import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, SchemaDef } from '../../../schema';
5+
import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, ModelDef, SchemaDef } from '../../../schema';
66
import { enumerate } from '../../../utils/enumerate';
77
import type { OrArray } from '../../../utils/type-utils';
88
import { AGGREGATE_OPERATORS, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants';
@@ -963,6 +963,31 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
963963
return result;
964964
}
965965

966+
protected buildModelSelect(
967+
eb: ExpressionBuilder<any, any>,
968+
model: GetModels<Schema>,
969+
subQueryAlias: string,
970+
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
971+
selectAllFields: boolean,
972+
) {
973+
let subQuery = this.buildSelectModel(eb, model, subQueryAlias);
974+
975+
if (selectAllFields) {
976+
subQuery = this.buildSelectAllFields(
977+
model,
978+
subQuery,
979+
typeof payload === 'object' ? payload?.omit : undefined,
980+
subQueryAlias,
981+
);
982+
}
983+
984+
if (payload && typeof payload === 'object') {
985+
subQuery = this.buildFilterSortTake(model, payload, subQuery, subQueryAlias);
986+
}
987+
988+
return subQuery;
989+
}
990+
966991
buildSelectField(
967992
query: SelectQueryBuilder<any, any, any>,
968993
model: string,
@@ -1115,6 +1140,35 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
11151140
return buildFieldRef(this.schema, model, field, this.options, eb, modelAlias, inlineComputedField);
11161141
}
11171142

1143+
protected canJoinWithoutNestedSelect(
1144+
modelDef: ModelDef,
1145+
payload: boolean | FindArgs<Schema, GetModels<Schema>, true>,
1146+
) {
1147+
if (modelDef.computedFields) {
1148+
// computed fields requires explicit select
1149+
return false;
1150+
}
1151+
1152+
if (modelDef.baseModel || modelDef.isDelegate) {
1153+
// delegate models require upward/downward joins
1154+
return false;
1155+
}
1156+
1157+
if (
1158+
typeof payload === 'object' &&
1159+
(payload.orderBy ||
1160+
payload.skip !== undefined ||
1161+
payload.take !== undefined ||
1162+
payload.cursor ||
1163+
(payload as any).distinct)
1164+
) {
1165+
// ordering/pagination/distinct needs to be handled before joining
1166+
return false;
1167+
}
1168+
1169+
return true;
1170+
}
1171+
11181172
// #endregion
11191173

11201174
// #region abstract methods

packages/runtime/src/client/crud/dialects/postgresql.ts

Lines changed: 101 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -58,127 +58,151 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
5858
parentAlias: string,
5959
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
6060
): SelectQueryBuilder<any, any, any> {
61-
const joinedQuery = this.buildRelationJSON(model, query, relationField, parentAlias, payload);
62-
63-
return joinedQuery.select(`${parentAlias}$${relationField}.$t as ${relationField}`);
61+
const relationResultName = `${parentAlias}$${relationField}`;
62+
const joinedQuery = this.buildRelationJSON(
63+
model,
64+
query,
65+
relationField,
66+
parentAlias,
67+
payload,
68+
relationResultName,
69+
);
70+
return joinedQuery.select(`${relationResultName}.$data as ${relationField}`);
6471
}
6572

6673
private buildRelationJSON(
6774
model: string,
6875
qb: SelectQueryBuilder<any, any, any>,
6976
relationField: string,
70-
parentName: string,
77+
parentAlias: string,
7178
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
79+
resultName: string,
7280
) {
7381
const relationFieldDef = requireField(this.schema, model, relationField);
7482
const relationModel = relationFieldDef.type as GetModels<Schema>;
7583

7684
return qb.leftJoinLateral(
7785
(eb) => {
78-
const joinTableName = `${parentName}$${relationField}`;
79-
80-
// simple select by default
81-
let result = eb.selectFrom(`${relationModel} as ${joinTableName}`);
86+
const relationSelectName = `${resultName}$sub`;
87+
const relationModelDef = requireModel(this.schema, relationModel);
8288

83-
// however if there're filter/orderBy/take/skip,
84-
// we need to build a subquery to handle them before aggregation
89+
let tbl: SelectQueryBuilder<any, any, any>;
8590

86-
// give sub query an alias to avoid conflict with parent scope
87-
// (e.g., for cases like self-relation)
88-
const subQueryAlias = `${relationModel}$${relationField}$sub`;
91+
if (this.canJoinWithoutNestedSelect(relationModelDef, payload)) {
92+
// build join directly
93+
tbl = this.buildModelSelect(eb, relationModel, relationSelectName, payload, false);
8994

90-
result = eb.selectFrom(() => {
91-
let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias);
92-
subQuery = this.buildSelectAllFields(
95+
// parent join filter
96+
tbl = this.buildRelationJoinFilter(
97+
tbl,
98+
model,
99+
relationField,
93100
relationModel,
94-
subQuery,
95-
typeof payload === 'object' ? payload?.omit : undefined,
96-
subQueryAlias,
101+
relationSelectName,
102+
parentAlias,
97103
);
98-
99-
if (payload && typeof payload === 'object') {
100-
subQuery = this.buildFilterSortTake(relationModel, payload, subQuery, subQueryAlias);
101-
}
102-
103-
// add join conditions
104-
105-
const m2m = getManyToManyRelation(this.schema, model, relationField);
106-
107-
if (m2m) {
108-
// many-to-many relation
109-
const parentIds = getIdFields(this.schema, model);
110-
const relationIds = getIdFields(this.schema, relationModel);
111-
invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field');
112-
invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field');
113-
subQuery = subQuery.where(
114-
eb(
115-
eb.ref(`${subQueryAlias}.${relationIds[0]}`),
116-
'in',
117-
eb
118-
.selectFrom(m2m.joinTable)
119-
.select(`${m2m.joinTable}.${m2m.otherFkName}`)
120-
.whereRef(
121-
`${parentName}.${parentIds[0]}`,
122-
'=',
123-
`${m2m.joinTable}.${m2m.parentFkName}`,
124-
),
125-
),
104+
} else {
105+
// join with a nested query
106+
tbl = eb.selectFrom(() => {
107+
let subQuery = this.buildModelSelect(
108+
eb,
109+
relationModel,
110+
`${relationSelectName}$t`,
111+
payload,
112+
true,
126113
);
127-
} else {
128-
const joinPairs = buildJoinPairs(this.schema, model, parentName, relationField, subQueryAlias);
129-
subQuery = subQuery.where((eb) =>
130-
this.and(eb, ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))),
114+
115+
// parent join filter
116+
subQuery = this.buildRelationJoinFilter(
117+
subQuery,
118+
model,
119+
relationField,
120+
relationModel,
121+
`${relationSelectName}$t`,
122+
parentAlias,
131123
);
132-
}
133124

134-
return subQuery.as(joinTableName);
135-
});
125+
return subQuery.as(relationSelectName);
126+
});
127+
}
136128

137-
result = this.buildRelationObjectSelect(
129+
// select relation result
130+
tbl = this.buildRelationObjectSelect(
138131
relationModel,
139-
joinTableName,
140-
relationField,
132+
relationSelectName,
141133
relationFieldDef,
142-
result,
134+
tbl,
143135
payload,
144-
parentName,
136+
resultName,
145137
);
146138

147139
// add nested joins for each relation
148-
result = this.buildRelationJoins(relationModel, relationField, result, payload, parentName);
140+
tbl = this.buildRelationJoins(tbl, relationModel, relationSelectName, payload, resultName);
149141

150142
// alias the join table
151-
return result.as(joinTableName);
143+
return tbl.as(resultName);
152144
},
153145
(join) => join.onTrue(),
154146
);
155147
}
156148

149+
private buildRelationJoinFilter(
150+
query: SelectQueryBuilder<any, any, {}>,
151+
model: string,
152+
relationField: string,
153+
relationModel: GetModels<Schema>,
154+
relationModelAlias: string,
155+
parentAlias: string,
156+
) {
157+
const m2m = getManyToManyRelation(this.schema, model, relationField);
158+
if (m2m) {
159+
// many-to-many relation
160+
const parentIds = getIdFields(this.schema, model);
161+
const relationIds = getIdFields(this.schema, relationModel);
162+
invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field');
163+
invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field');
164+
query = query.where((eb) =>
165+
eb(
166+
eb.ref(`${relationModelAlias}.${relationIds[0]}`),
167+
'in',
168+
eb
169+
.selectFrom(m2m.joinTable)
170+
.select(`${m2m.joinTable}.${m2m.otherFkName}`)
171+
.whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`),
172+
),
173+
);
174+
} else {
175+
const joinPairs = buildJoinPairs(this.schema, model, parentAlias, relationField, relationModelAlias);
176+
query = query.where((eb) =>
177+
this.and(eb, ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))),
178+
);
179+
}
180+
return query;
181+
}
182+
157183
private buildRelationObjectSelect(
158184
relationModel: string,
159185
relationModelAlias: string,
160-
relationField: string,
161186
relationFieldDef: FieldDef,
162187
qb: SelectQueryBuilder<any, any, any>,
163188
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
164-
parentName: string,
189+
parentResultName: string,
165190
) {
166191
qb = qb.select((eb) => {
167192
const objArgs = this.buildRelationObjectArgs(
168193
relationModel,
169194
relationModelAlias,
170-
relationField,
171195
eb,
172196
payload,
173-
parentName,
197+
parentResultName,
174198
);
175199

176200
if (relationFieldDef.array) {
177201
return eb.fn
178202
.coalesce(sql`jsonb_agg(jsonb_build_object(${sql.join(objArgs)}))`, sql`'[]'::jsonb`)
179-
.as('$t');
203+
.as('$data');
180204
} else {
181-
return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$t');
205+
return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$data');
182206
}
183207
});
184208

@@ -188,17 +212,15 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
188212
private buildRelationObjectArgs(
189213
relationModel: string,
190214
relationModelAlias: string,
191-
relationField: string,
192215
eb: ExpressionBuilder<any, any>,
193216
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
194-
parentAlias: string,
217+
parentResultName: string,
195218
) {
196219
const relationModelDef = requireModel(this.schema, relationModel);
197220
const objArgs: Array<
198221
string | ExpressionWrapper<any, any, any> | SelectQueryBuilder<any, any, any> | RawBuilder<any>
199222
> = [];
200223

201-
// TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected
202224
const descendantModels = getDelegateDescendantModels(this.schema, relationModel);
203225
if (descendantModels.length > 0) {
204226
// select all JSONs built from delegate descendants
@@ -234,15 +256,15 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
234256
const subJson = this.buildCountJson(
235257
relationModel as GetModels<Schema>,
236258
eb,
237-
`${parentAlias}$${relationField}`,
259+
relationModelAlias,
238260
value,
239261
);
240262
return [sql.lit(field), subJson];
241263
} else {
242264
const fieldDef = requireField(this.schema, relationModel, field);
243265
const fieldValue = fieldDef.relation
244266
? // reference the synthesized JSON field
245-
eb.ref(`${parentAlias}$${relationField}$${field}.$t`)
267+
eb.ref(`${parentResultName}$${field}.$data`)
246268
: // reference a plain field
247269
this.fieldRef(relationModel, field, eb, undefined, false);
248270
return [sql.lit(field), fieldValue];
@@ -260,7 +282,7 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
260282
.map(([field]) => [
261283
sql.lit(field),
262284
// reference the synthesized JSON field
263-
eb.ref(`${parentAlias}$${relationField}$${field}.$t`),
285+
eb.ref(`${parentResultName}$${field}.$data`),
264286
])
265287
.flatMap((v) => v),
266288
);
@@ -269,13 +291,13 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
269291
}
270292

271293
private buildRelationJoins(
294+
query: SelectQueryBuilder<any, any, any>,
272295
relationModel: string,
273-
relationField: string,
274-
qb: SelectQueryBuilder<any, any, any>,
296+
relationModelAlias: string,
275297
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
276-
parentName: string,
298+
parentResultName: string,
277299
) {
278-
let result = qb;
300+
let result = query;
279301
if (typeof payload === 'object') {
280302
const selectInclude = payload.include ?? payload.select;
281303
if (selectInclude && typeof selectInclude === 'object') {
@@ -287,8 +309,9 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
287309
relationModel,
288310
result,
289311
field,
290-
`${parentName}$${relationField}`,
312+
relationModelAlias,
291313
value,
314+
`${parentResultName}$${field}`,
292315
);
293316
});
294317
}

0 commit comments

Comments
 (0)