Skip to content

Commit 93b85c7

Browse files
authored
refactor: perf improvement for nullable to-one nested read (#603)
1 parent 13d58eb commit 93b85c7

File tree

13 files changed

+156
-101
lines changed

13 files changed

+156
-101
lines changed

packages/runtime/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"deepcopy": "^2.1.0",
5656
"lower-case-first": "^2.0.2",
5757
"pluralize": "^8.0.0",
58+
"semver": "^7.3.8",
5859
"superjson": "^1.11.0",
5960
"tslib": "^2.4.1",
6061
"upper-case-first": "^2.0.2",
@@ -71,6 +72,7 @@
7172
"@types/jest": "^29.5.0",
7273
"@types/node": "^18.0.0",
7374
"@types/pluralize": "^0.0.29",
75+
"@types/semver": "^7.3.13",
7476
"copyfiles": "^2.4.1",
7577
"rimraf": "^3.0.2",
7678
"typescript": "^4.9.3"

packages/runtime/src/enhancements/policy/policy-utils.ts

Lines changed: 79 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
PolicyOperationKind,
2424
PrismaWriteActionType,
2525
} from '../../types';
26-
import { getVersion } from '../../version';
26+
import { getPrismaVersion, getVersion } from '../../version';
2727
import { getFields, resolveField } from '../model-meta';
2828
import { NestedWriteVisitor, type NestedWriteVisitorContext } from '../nested-write-vistor';
2929
import type { ModelMeta, PolicyDef, PolicyFunc, ZodSchemas } from '../types';
@@ -36,6 +36,7 @@ import {
3636
prismaClientUnknownRequestError,
3737
} from '../utils';
3838
import { Logger } from './logger';
39+
import semver from 'semver';
3940

4041
/**
4142
* Access policy enforcement utilities
@@ -45,6 +46,8 @@ export class PolicyUtil {
4546
// @ts-ignore
4647
private readonly logger: Logger;
4748

49+
private supportNestedToOneFilter = false;
50+
4851
constructor(
4952
private readonly db: DbClientContract,
5053
private readonly modelMeta: ModelMeta,
@@ -54,6 +57,10 @@ export class PolicyUtil {
5457
private readonly logPrismaQuery?: boolean
5558
) {
5659
this.logger = new Logger(db);
60+
61+
// use Prisma version to detect if we can filter when nested-fetching to-one relation
62+
const prismaVersion = getPrismaVersion();
63+
this.supportNestedToOneFilter = prismaVersion ? semver.gte(prismaVersion, '4.8.0') : false;
5764
}
5865

5966
/**
@@ -334,20 +341,29 @@ export class PolicyUtil {
334341
}
335342

336343
const idFields = this.getIdFields(model);
344+
337345
for (const field of getModelFields(injectTarget)) {
338346
const fieldInfo = resolveField(this.modelMeta, model, field);
339347
if (!fieldInfo || !fieldInfo.isDataModel) {
340348
// only care about relation fields
341349
continue;
342350
}
343351

344-
if (fieldInfo.isArray) {
352+
if (
353+
fieldInfo.isArray ||
354+
// if Prisma version is high enough to support filtering directly when
355+
// fetching a nullable to-one relation, let's do it that way
356+
// https://github.com/prisma/prisma/discussions/20350
357+
(this.supportNestedToOneFilter && fieldInfo.isOptional)
358+
) {
345359
if (typeof injectTarget[field] !== 'object') {
346360
injectTarget[field] = {};
347361
}
348-
// inject extra condition for to-many relation
349-
362+
// inject extra condition for to-many or nullable to-one relation
350363
await this.injectAuthGuard(injectTarget[field], fieldInfo.type, 'read');
364+
365+
// recurse
366+
await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]);
351367
} else {
352368
// there's no way of injecting condition for to-one relation, so if there's
353369
// "select" clause we make sure 'id' fields are selected and check them against
@@ -361,9 +377,6 @@ export class PolicyUtil {
361377
}
362378
}
363379
}
364-
365-
// recurse
366-
await this.injectNestedReadConditions(fieldInfo.type, injectTarget[field]);
367380
}
368381
}
369382

@@ -373,69 +386,79 @@ export class PolicyUtil {
373386
* omitted.
374387
*/
375388
async postProcessForRead(data: any, model: string, args: any, operation: PolicyOperationKind) {
376-
for (const entityData of enumerate(data)) {
377-
if (typeof entityData !== 'object' || !entityData) {
378-
continue;
379-
}
389+
await Promise.all(
390+
enumerate(data).map((entityData) => this.postProcessSingleEntityForRead(entityData, model, args, operation))
391+
);
392+
}
380393

381-
// strip auxiliary fields
382-
for (const auxField of AUXILIARY_FIELDS) {
383-
if (auxField in entityData) {
384-
delete entityData[auxField];
385-
}
394+
private async postProcessSingleEntityForRead(data: any, model: string, args: any, operation: PolicyOperationKind) {
395+
if (typeof data !== 'object' || !data) {
396+
return;
397+
}
398+
399+
// strip auxiliary fields
400+
for (const auxField of AUXILIARY_FIELDS) {
401+
if (auxField in data) {
402+
delete data[auxField];
386403
}
404+
}
405+
406+
const injectTarget = args.select ?? args.include;
407+
if (!injectTarget) {
408+
return;
409+
}
387410

388-
const injectTarget = args.select ?? args.include;
389-
if (!injectTarget) {
411+
// recurse into nested entities
412+
for (const field of Object.keys(injectTarget)) {
413+
const fieldData = data[field];
414+
if (typeof fieldData !== 'object' || !fieldData) {
390415
continue;
391416
}
392417

393-
// recurse into nested entities
394-
for (const field of Object.keys(injectTarget)) {
395-
const fieldData = entityData[field];
396-
if (typeof fieldData !== 'object' || !fieldData) {
397-
continue;
398-
}
418+
const fieldInfo = resolveField(this.modelMeta, model, field);
419+
if (fieldInfo) {
420+
if (
421+
fieldInfo.isDataModel &&
422+
!fieldInfo.isArray &&
423+
// if Prisma version supports filtering nullable to-one relation, no need to further check
424+
!(this.supportNestedToOneFilter && fieldInfo.isOptional)
425+
) {
426+
// to-one relation data cannot be trimmed by injected guards, we have to
427+
// post-check them
428+
const ids = this.getEntityIds(fieldInfo.type, fieldData);
429+
430+
if (Object.keys(ids).length !== 0) {
431+
if (this.logger.enabled('info')) {
432+
this.logger.info(
433+
`Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`
434+
);
435+
}
399436

400-
const fieldInfo = resolveField(this.modelMeta, model, field);
401-
if (fieldInfo) {
402-
if (fieldInfo.isDataModel && !fieldInfo.isArray) {
403-
// to-one relation data cannot be trimmed by injected guards, we have to
404-
// post-check them
405-
const ids = this.getEntityIds(fieldInfo.type, fieldData);
406-
407-
if (Object.keys(ids).length !== 0) {
408-
// if (this.logger.enabled('info')) {
409-
// this.logger.info(
410-
// `Validating read of to-one relation: ${fieldInfo.type}#${formatObject(ids)}`
411-
// );
412-
// }
413-
try {
414-
await this.checkPolicyForFilter(fieldInfo.type, ids, operation, this.db);
415-
} catch (err) {
416-
if (
417-
isPrismaClientKnownRequestError(err) &&
418-
err.code === PrismaErrorCode.CONSTRAINED_FAILED
419-
) {
420-
// denied by policy
421-
if (fieldInfo.isOptional) {
422-
// if the relation is optional, just nullify it
423-
entityData[field] = null;
424-
} else {
425-
// otherwise reject
426-
throw err;
427-
}
437+
try {
438+
await this.checkPolicyForFilter(fieldInfo.type, ids, operation, this.db);
439+
} catch (err) {
440+
if (
441+
isPrismaClientKnownRequestError(err) &&
442+
err.code === PrismaErrorCode.CONSTRAINED_FAILED
443+
) {
444+
// denied by policy
445+
if (fieldInfo.isOptional) {
446+
// if the relation is optional, just nullify it
447+
data[field] = null;
428448
} else {
429-
// unknown error
449+
// otherwise reject
430450
throw err;
431451
}
452+
} else {
453+
// unknown error
454+
throw err;
432455
}
433456
}
434457
}
435-
436-
// recurse
437-
await this.postProcessForRead(fieldData, fieldInfo.type, injectTarget[field], operation);
438458
}
459+
460+
// recurse
461+
await this.postProcessForRead(fieldData, fieldInfo.type, injectTarget[field], operation);
439462
}
440463
}
441464
}

packages/runtime/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './enhancements';
33
export * from './error';
44
export * from './types';
55
export * from './validation';
6+
export * from './version';

packages/runtime/src/version.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import path from 'path';
2+
13
/* eslint-disable @typescript-eslint/no-var-requires */
24
export function getVersion() {
35
try {
@@ -11,3 +13,30 @@ export function getVersion() {
1113
}
1214
}
1315
}
16+
17+
/**
18+
* Gets installed Prisma version by first checking "@prisma/client" and if not available,
19+
* "prisma".
20+
*/
21+
export function getPrismaVersion(): string | undefined {
22+
try {
23+
// eslint-disable-next-line @typescript-eslint/no-var-requires
24+
return require('@prisma/client/package.json').version;
25+
} catch {
26+
try {
27+
// eslint-disable-next-line @typescript-eslint/no-var-requires
28+
return require('prisma/package.json').version;
29+
} catch {
30+
if (process.env.ZENSTACK_TEST === '1') {
31+
// test environment
32+
try {
33+
return require(path.resolve('./node_modules/@prisma/client/package.json')).version;
34+
} catch {
35+
return undefined;
36+
}
37+
}
38+
39+
return undefined;
40+
}
41+
}
42+
}

packages/schema/src/plugins/prisma/schema-generator.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -251,8 +251,7 @@ export default class PrismaSchemaGenerator {
251251
const provider = generator.fields.find((f) => f.name === 'provider');
252252
if (provider?.value === 'prisma-client-js') {
253253
const prismaVersion = getPrismaVersion();
254-
if (prismaVersion && semver.lt(prismaVersion, '4.7.0')) {
255-
// insert interactiveTransactions preview feature
254+
if (prismaVersion) {
256255
let previewFeatures = generator.fields.find((f) => f.name === 'previewFeatures');
257256
if (!previewFeatures) {
258257
previewFeatures = { name: 'previewFeatures', value: [] };
@@ -261,8 +260,19 @@ export default class PrismaSchemaGenerator {
261260
if (!Array.isArray(previewFeatures.value)) {
262261
throw new PluginError(name, 'option "previewFeatures" must be an array');
263262
}
264-
if (!previewFeatures.value.includes('interactiveTransactions')) {
265-
previewFeatures.value.push('interactiveTransactions');
263+
264+
if (semver.lt(prismaVersion, '4.7.0')) {
265+
// interactiveTransactions feature is opt-in before 4.7.0
266+
if (!previewFeatures.value.includes('interactiveTransactions')) {
267+
previewFeatures.value.push('interactiveTransactions');
268+
}
269+
}
270+
271+
if (semver.gte(prismaVersion, '4.8.0') && semver.lt(prismaVersion, '5.0.0')) {
272+
// extendedWhereUnique feature is opt-in during [4.8.0, 5.0.0)
273+
if (!previewFeatures.value.includes('extendedWhereUnique')) {
274+
previewFeatures.value.push('extendedWhereUnique');
275+
}
266276
}
267277
}
268278
}

packages/schema/src/telemetry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createId } from '@paralleldrive/cuid2';
2+
import { getPrismaVersion } from '@zenstackhq/sdk';
23
import exitHook from 'async-exit-hook';
34
import { CommanderError } from 'commander';
45
import { init, Mixpanel } from 'mixpanel';
@@ -8,7 +9,6 @@ import sleep from 'sleep-promise';
89
import { CliError } from './cli/cli-error';
910
import { TELEMETRY_TRACKING_TOKEN } from './constants';
1011
import { getVersion } from './utils/version-utils';
11-
import { getPrismaVersion } from '@zenstackhq/sdk';
1212

1313
/**
1414
* Telemetry events

packages/sdk/src/prisma.ts

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import type { DMMF } from '@prisma/generator-helper';
22
import { getDMMF as getDMMF4 } from '@prisma/internals';
33
import { getDMMF as getDMMF5 } from '@prisma/internals-v5';
4+
import { getPrismaVersion } from '@zenstackhq/runtime';
45
import path from 'path';
56
import * as semver from 'semver';
67
import { GeneratorDecl, Model, Plugin, isGeneratorDecl, isPlugin } from './ast';
78
import { getLiteral } from './utils';
89

10+
// reexport
11+
export { getPrismaVersion } from '@zenstackhq/runtime';
12+
913
/**
1014
* Given a ZModel and an import context directory, compute the import spec for the Prisma Client.
1115
*/
@@ -65,24 +69,6 @@ function normalizePath(p: string) {
6569
return p ? p.split(path.sep).join(path.posix.sep) : p;
6670
}
6771

68-
/**
69-
* Gets installed Prisma version by first checking "@prisma/client" and if not available,
70-
* "prisma".
71-
*/
72-
export function getPrismaVersion(): string | undefined {
73-
try {
74-
// eslint-disable-next-line @typescript-eslint/no-var-requires
75-
return require('@prisma/client/package.json').version;
76-
} catch {
77-
try {
78-
// eslint-disable-next-line @typescript-eslint/no-var-requires
79-
return require('prisma/package.json').version;
80-
} catch {
81-
return undefined;
82-
}
83-
}
84-
}
85-
8672
export type GetDMMFOptions = {
8773
datamodel?: string;
8874
cwd?: string;

packages/testtools/src/package.template.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
"author": "",
88
"license": "ISC",
99
"dependencies": {
10-
"@prisma/client": "^4.0.0",
10+
"@prisma/client": "^4.8.0",
1111
"@zenstackhq/runtime": "file:<root>/packages/runtime/dist",
1212
"@zenstackhq/swr": "file:<root>/packages/plugins/swr/dist",
1313
"@zenstackhq/trpc": "file:<root>/packages/plugins/trpc/dist",
1414
"@zenstackhq/openapi": "file:<root>/packages/plugins/openapi/dist",
15-
"prisma": "^4.0.0",
15+
"prisma": "^4.8.0",
1616
"typescript": "^4.9.3",
1717
"zenstack": "file:<root>/packages/schema/dist",
1818
"zod": "3.21.1"

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)