Skip to content

Commit f30b8d6

Browse files
authored
fix: issues found testing with formbricks (#77)
* fix: issues found testing with formbricks - Nested count during find - Result processing - "undefined" field normalization - Enum fields used as unique * fix test * more tests * fix test
1 parent b656805 commit f30b8d6

File tree

15 files changed

+304
-116
lines changed

15 files changed

+304
-116
lines changed

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
buildJoinPairs,
1616
getIdFields,
1717
getManyToManyRelation,
18+
isRelationField,
1819
requireField,
1920
requireModel,
2021
} from '../../query-utils';
@@ -216,10 +217,15 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
216217
objArgs.push(
217218
...Object.entries(payload.select)
218219
.filter(([, value]) => value)
219-
.map(([field]) => [
220-
sql.lit(field),
221-
buildFieldRef(this.schema, relationModel, field, this.options, eb),
222-
])
220+
.map(([field]) => {
221+
const fieldDef = requireField(this.schema, relationModel, field);
222+
const fieldValue = fieldDef.relation
223+
? // reference the synthesized JSON field
224+
eb.ref(`${parentName}$${relationField}$${field}.$j`)
225+
: // reference a plain field
226+
buildFieldRef(this.schema, relationModel, field, this.options, eb);
227+
return [sql.lit(field), fieldValue];
228+
})
223229
.flatMap((v) => v),
224230
);
225231
}
@@ -229,27 +235,41 @@ export class PostgresCrudDialect<Schema extends SchemaDef> extends BaseCrudDiale
229235
objArgs.push(
230236
...Object.entries<any>(payload.include)
231237
.filter(([, value]) => value)
232-
.map(([field]) => [sql.lit(field), eb.ref(`${parentName}$${relationField}$${field}.$j`)])
238+
.map(([field]) => [
239+
sql.lit(field),
240+
// reference the synthesized JSON field
241+
eb.ref(`${parentName}$${relationField}$${field}.$j`),
242+
])
233243
.flatMap((v) => v),
234244
);
235245
}
236246
return objArgs;
237247
}
238248

239249
private buildRelationJoins(
240-
model: string,
250+
relationModel: string,
241251
relationField: string,
242252
qb: SelectQueryBuilder<any, any, any>,
243253
payload: true | FindArgs<Schema, GetModels<Schema>, true>,
244254
parentName: string,
245255
) {
246256
let result = qb;
247-
if (typeof payload === 'object' && payload.include && typeof payload.include === 'object') {
248-
Object.entries<any>(payload.include)
249-
.filter(([, value]) => value)
250-
.forEach(([field, value]) => {
251-
result = this.buildRelationJSON(model, result, field, `${parentName}$${relationField}`, value);
252-
});
257+
if (typeof payload === 'object') {
258+
const selectInclude = payload.include ?? payload.select;
259+
if (selectInclude && typeof selectInclude === 'object') {
260+
Object.entries<any>(selectInclude)
261+
.filter(([, value]) => value)
262+
.filter(([field]) => isRelationField(this.schema, relationModel, field))
263+
.forEach(([field, value]) => {
264+
result = this.buildRelationJSON(
265+
relationModel,
266+
result,
267+
field,
268+
`${parentName}$${relationField}`,
269+
value,
270+
);
271+
});
272+
}
253273
}
254274
return result;
255275
}

packages/runtime/src/client/crud/operations/aggregate.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { BaseOperationHandler } from './base';
66

77
export class AggregateOperationHandler<Schema extends SchemaDef> extends BaseOperationHandler<Schema> {
88
async handle(_operation: 'aggregate', args: unknown | undefined) {
9-
const validatedArgs = this.inputValidator.validateAggregateArgs(this.model, args);
9+
// normalize args to strip `undefined` fields
10+
const normalizeArgs = this.normalizeArgs(args);
11+
12+
// parse args
13+
const parsedArgs = this.inputValidator.validateAggregateArgs(this.model, normalizeArgs);
1014

1115
let query = this.kysely.selectFrom((eb) => {
1216
// nested query for filtering and pagination
@@ -15,11 +19,11 @@ export class AggregateOperationHandler<Schema extends SchemaDef> extends BaseOpe
1519
let subQuery = eb
1620
.selectFrom(this.model)
1721
.selectAll(this.model as any) // TODO: check typing
18-
.where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, validatedArgs?.where));
22+
.where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where));
1923

2024
// skip & take
21-
const skip = validatedArgs?.skip;
22-
let take = validatedArgs?.take;
25+
const skip = parsedArgs?.skip;
26+
let take = parsedArgs?.take;
2327
let negateOrderBy = false;
2428
if (take !== undefined && take < 0) {
2529
negateOrderBy = true;
@@ -32,7 +36,7 @@ export class AggregateOperationHandler<Schema extends SchemaDef> extends BaseOpe
3236
subQuery,
3337
this.model,
3438
this.model,
35-
validatedArgs.orderBy,
39+
parsedArgs.orderBy,
3640
skip !== undefined || take !== undefined,
3741
negateOrderBy,
3842
);
@@ -41,7 +45,7 @@ export class AggregateOperationHandler<Schema extends SchemaDef> extends BaseOpe
4145
});
4246

4347
// aggregations
44-
for (const [key, value] of Object.entries(validatedArgs)) {
48+
for (const [key, value] of Object.entries(parsedArgs)) {
4549
switch (key) {
4650
case '_count': {
4751
if (value === true) {

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

Lines changed: 76 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { createId } from '@paralleldrive/cuid2';
2-
import { invariant } from '@zenstackhq/common-helpers';
2+
import { invariant, isPlainObject } from '@zenstackhq/common-helpers';
33
import {
44
DeleteResult,
55
expressionBuilder,
66
ExpressionWrapper,
77
sql,
88
UpdateResult,
9-
type ExpressionBuilder,
109
type Expression as KyselyExpression,
1110
type SelectQueryBuilder,
1211
} from 'kysely';
@@ -292,45 +291,36 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
292291
for (const [field, value] of Object.entries(selections.select)) {
293292
const fieldDef = requireField(this.schema, model, field);
294293
const fieldModel = fieldDef.type;
295-
const jointTable = `${parentAlias}$${field}$count`;
296-
const joinPairs = buildJoinPairs(this.schema, model, parentAlias, field, jointTable);
297-
298-
query = query.leftJoin(
299-
(eb) => {
300-
let result = eb.selectFrom(fieldModel).selectAll();
301-
if (
302-
value &&
303-
typeof value === 'object' &&
304-
'where' in value &&
305-
value.where &&
306-
typeof value.where === 'object'
307-
) {
308-
const filter = this.dialect.buildFilter(eb, fieldModel, fieldModel, value.where);
309-
result = result.where(filter);
310-
}
311-
return result.as(jointTable);
312-
},
313-
(join) => {
314-
for (const [left, right] of joinPairs) {
315-
join = join.onRef(left, '=', right);
316-
}
317-
return join;
318-
},
319-
);
294+
const joinPairs = buildJoinPairs(this.schema, model, parentAlias, field, fieldModel);
295+
296+
// build a nested query to count the number of records in the relation
297+
let fieldCountQuery = eb.selectFrom(fieldModel).select(eb.fn.countAll().as(`_count$${field}`));
298+
299+
// join conditions
300+
for (const [left, right] of joinPairs) {
301+
fieldCountQuery = fieldCountQuery.whereRef(left, '=', right);
302+
}
303+
304+
// merge _count filter
305+
if (
306+
value &&
307+
typeof value === 'object' &&
308+
'where' in value &&
309+
value.where &&
310+
typeof value.where === 'object'
311+
) {
312+
const filter = this.dialect.buildFilter(eb, fieldModel, fieldModel, value.where);
313+
fieldCountQuery = fieldCountQuery.where(filter);
314+
}
320315

321-
jsonObject[field] = this.countIdDistinct(eb, fieldDef.type, jointTable);
316+
jsonObject[field] = fieldCountQuery;
322317
}
323318

324319
query = query.select((eb) => this.dialect.buildJsonObject(eb, jsonObject).as('_count'));
325320

326321
return query;
327322
}
328323

329-
private countIdDistinct(eb: ExpressionBuilder<any, any>, model: string, table: string) {
330-
const idFields = getIdFields(this.schema, model);
331-
return eb.fn.count(sql.join(idFields.map((f) => sql.ref(`${table}.${f}`)))).distinct();
332-
}
333-
334324
private buildSelectAllScalarFields(
335325
model: string,
336326
query: SelectQueryBuilder<any, any, any>,
@@ -479,7 +469,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
479469
} else {
480470
const subM2M = getManyToManyRelation(this.schema, model, field);
481471
if (!subM2M && fieldDef.relation?.fields && fieldDef.relation?.references) {
482-
const fkValues = await this.processOwnedRelation(kysely, fieldDef, value);
472+
const fkValues = await this.processOwnedRelationForCreate(kysely, fieldDef, value);
483473
for (let i = 0; i < fieldDef.relation.fields.length; i++) {
484474
createFields[fieldDef.relation.fields[i]!] = fkValues[fieldDef.relation.references[i]!];
485475
}
@@ -519,7 +509,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
519509
if (Object.keys(postCreateRelations).length > 0) {
520510
// process nested creates that need to happen after the current entity is created
521511
const relationPromises = Object.entries(postCreateRelations).map(([field, subPayload]) => {
522-
return this.processNoneOwnedRelation(kysely, model, field, subPayload, createdEntity);
512+
return this.processNoneOwnedRelationForCreate(kysely, model, field, subPayload, createdEntity);
523513
});
524514

525515
// await relation creation
@@ -633,7 +623,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
633623
.execute();
634624
}
635625

636-
private async processOwnedRelation(kysely: ToKysely<Schema>, relationField: FieldDef, payload: any) {
626+
private async processOwnedRelationForCreate(kysely: ToKysely<Schema>, relationField: FieldDef, payload: any) {
637627
if (!payload) {
638628
return;
639629
}
@@ -696,7 +686,7 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
696686
return result;
697687
}
698688

699-
private processNoneOwnedRelation(
689+
private processNoneOwnedRelationForCreate(
700690
kysely: ToKysely<Schema>,
701691
contextModel: GetModels<Schema>,
702692
relationFieldName: string,
@@ -706,6 +696,11 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
706696
const relationFieldDef = this.requireField(contextModel, relationFieldName);
707697
const relationModel = relationFieldDef.type as GetModels<Schema>;
708698
const tasks: Promise<unknown>[] = [];
699+
const fromRelationContext = {
700+
model: contextModel,
701+
field: relationFieldName,
702+
ids: parentEntity,
703+
};
709704

710705
for (const [action, subPayload] of Object.entries<any>(payload)) {
711706
if (!subPayload) {
@@ -716,11 +711,21 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
716711
// create with a parent entity
717712
tasks.push(
718713
...enumerate(subPayload).map((item) =>
719-
this.create(kysely, relationModel, item, {
720-
model: contextModel,
721-
field: relationFieldName,
722-
ids: parentEntity,
723-
}),
714+
this.create(kysely, relationModel, item, fromRelationContext),
715+
),
716+
);
717+
break;
718+
}
719+
720+
case 'createMany': {
721+
invariant(relationFieldDef.array, 'relation must be an array for createMany');
722+
tasks.push(
723+
this.createMany(
724+
kysely,
725+
relationModel,
726+
subPayload as { data: any; skipDuplicates: boolean },
727+
false,
728+
fromRelationContext,
724729
),
725730
);
726731
break;
@@ -776,6 +781,11 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
776781
returnData: ReturnData,
777782
fromRelation?: FromRelationContext<Schema>,
778783
): Promise<Result> {
784+
if (!input.data || (Array.isArray(input.data) && input.data.length === 0)) {
785+
// nothing todo
786+
return returnData ? ([] as Result) : ({ count: 0 } as Result);
787+
}
788+
779789
const modelDef = this.requireModel(model);
780790

781791
let relationKeyPairs: { fk: string; pk: string }[] = [];
@@ -1916,4 +1926,28 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
19161926
where: uniqueFilter,
19171927
});
19181928
}
1929+
1930+
/**
1931+
* Normalize input args to strip `undefined` fields
1932+
*/
1933+
protected normalizeArgs(args: unknown) {
1934+
if (!args) {
1935+
return;
1936+
}
1937+
const newArgs = clone(args);
1938+
this.doNormalizeArgs(newArgs);
1939+
return newArgs;
1940+
}
1941+
1942+
private doNormalizeArgs(args: unknown) {
1943+
if (args && typeof args === 'object') {
1944+
for (const [key, value] of Object.entries(args)) {
1945+
if (value === undefined) {
1946+
delete args[key as keyof typeof args];
1947+
} else if (value && isPlainObject(value)) {
1948+
this.doNormalizeArgs(value);
1949+
}
1950+
}
1951+
}
1952+
}
19191953
}

packages/runtime/src/client/crud/operations/count.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,26 @@ import { BaseOperationHandler } from './base';
44

55
export class CountOperationHandler<Schema extends SchemaDef> extends BaseOperationHandler<Schema> {
66
async handle(_operation: 'count', args: unknown | undefined) {
7-
const validatedArgs = this.inputValidator.validateCountArgs(this.model, args);
7+
// normalize args to strip `undefined` fields
8+
const normalizeArgs = this.normalizeArgs(args);
9+
10+
// parse args
11+
const parsedArgs = this.inputValidator.validateCountArgs(this.model, normalizeArgs);
812

913
let query = this.kysely.selectFrom((eb) => {
1014
// nested query for filtering and pagination
1115
let subQuery = eb
1216
.selectFrom(this.model)
1317
.selectAll()
14-
.where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, validatedArgs?.where));
15-
subQuery = this.dialect.buildSkipTake(subQuery, validatedArgs?.skip, validatedArgs?.take);
18+
.where((eb1) => this.dialect.buildFilter(eb1, this.model, this.model, parsedArgs?.where));
19+
subQuery = this.dialect.buildSkipTake(subQuery, parsedArgs?.skip, parsedArgs?.take);
1620
return subQuery.as('$sub');
1721
});
1822

19-
if (validatedArgs?.select && typeof validatedArgs.select === 'object') {
23+
if (parsedArgs?.select && typeof parsedArgs.select === 'object') {
2024
// count with field selection
2125
query = query.select((eb) =>
22-
Object.keys(validatedArgs.select!).map((key) =>
26+
Object.keys(parsedArgs.select!).map((key) =>
2327
key === '_all'
2428
? eb.cast(eb.fn.countAll(), 'integer').as('_all')
2529
: eb.cast(eb.fn.count(sql.ref(`$sub.${key}`)), 'integer').as(key),

packages/runtime/src/client/crud/operations/create.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@ import { BaseOperationHandler } from './base';
77

88
export class CreateOperationHandler<Schema extends SchemaDef> extends BaseOperationHandler<Schema> {
99
async handle(operation: 'create' | 'createMany' | 'createManyAndReturn', args: unknown | undefined) {
10+
// normalize args to strip `undefined` fields
11+
const normalizeArgs = this.normalizeArgs(args);
12+
1013
return match(operation)
11-
.with('create', () => this.runCreate(this.inputValidator.validateCreateArgs(this.model, args)))
14+
.with('create', () => this.runCreate(this.inputValidator.validateCreateArgs(this.model, normalizeArgs)))
1215
.with('createMany', () => {
13-
return this.runCreateMany(this.inputValidator.validateCreateManyArgs(this.model, args));
16+
return this.runCreateMany(this.inputValidator.validateCreateManyArgs(this.model, normalizeArgs));
1417
})
1518
.with('createManyAndReturn', () => {
1619
return this.runCreateManyAndReturn(
17-
this.inputValidator.validateCreateManyAndReturnArgs(this.model, args),
20+
this.inputValidator.validateCreateManyAndReturnArgs(this.model, normalizeArgs),
1821
);
1922
})
2023
.exhaustive();

packages/runtime/src/client/crud/operations/delete.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ import { BaseOperationHandler } from './base';
66

77
export class DeleteOperationHandler<Schema extends SchemaDef> extends BaseOperationHandler<Schema> {
88
async handle(operation: 'delete' | 'deleteMany', args: unknown | undefined) {
9+
// normalize args to strip `undefined` fields
10+
const normalizeArgs = this.normalizeArgs(args);
11+
912
return match(operation)
10-
.with('delete', () => this.runDelete(this.inputValidator.validateDeleteArgs(this.model, args)))
11-
.with('deleteMany', () => this.runDeleteMany(this.inputValidator.validateDeleteManyArgs(this.model, args)))
13+
.with('delete', () => this.runDelete(this.inputValidator.validateDeleteArgs(this.model, normalizeArgs)))
14+
.with('deleteMany', () =>
15+
this.runDeleteMany(this.inputValidator.validateDeleteManyArgs(this.model, normalizeArgs)),
16+
)
1217
.exhaustive();
1318
}
1419

0 commit comments

Comments
 (0)