Skip to content

Commit 8a1289c

Browse files
authored
fix(policy): revers lookup condition to parent entity is not properly built with compound id fields (#2053)
1 parent e0676f2 commit 8a1289c

File tree

9 files changed

+316
-29
lines changed

9 files changed

+316
-29
lines changed

packages/runtime/src/enhancements/node/delegate.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
902902
} else {
903903
// translate to plain `update` for nested write into base fields
904904
const findArgs = {
905-
where: clone(args.where),
905+
where: clone(args.where ?? {}),
906906
select: this.queryUtils.makeIdSelection(model),
907907
};
908908
await this.injectUpdateHierarchy(db, model, findArgs);
@@ -959,7 +959,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
959959
this.injectWhereHierarchy(model, (args as any)?.where);
960960
this.doProcessUpdatePayload(model, (args as any)?.data);
961961
} else {
962-
const where = this.queryUtils.buildReversedQuery(context, false, false);
962+
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
963963
await this.queryUtils.transaction(db, async (tx) => {
964964
await this.doUpdateMany(tx, model, { ...args, where }, simpleUpdateMany);
965965
});
@@ -1022,15 +1022,15 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
10221022
},
10231023

10241024
delete: async (model, _args, context) => {
1025-
const where = this.queryUtils.buildReversedQuery(context, false, false);
1025+
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
10261026
await this.queryUtils.transaction(db, async (tx) => {
10271027
await this.doDelete(tx, model, { where });
10281028
});
10291029
delete context.parent['delete'];
10301030
},
10311031

10321032
deleteMany: async (model, _args, context) => {
1033-
const where = this.queryUtils.buildReversedQuery(context, false, false);
1033+
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
10341034
await this.queryUtils.transaction(db, async (tx) => {
10351035
await this.doDeleteMany(tx, model, where);
10361036
});
@@ -1095,7 +1095,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
10951095
private async doDeleteMany(db: CrudContract, model: string, where: any): Promise<{ count: number }> {
10961096
// query existing entities with id
10971097
const idSelection = this.queryUtils.makeIdSelection(model);
1098-
const findArgs = { where: clone(where), select: idSelection };
1098+
const findArgs = { where: clone(where ?? {}), select: idSelection };
10991099
this.injectWhereHierarchy(model, findArgs.where);
11001100

11011101
if (this.options.logPrismaQuery) {

packages/runtime/src/enhancements/node/policy/handler.ts

+16-13
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
809809
const unsafe = isUnsafeMutate(model, args, this.modelMeta);
810810

811811
// handles the connection to upstream entity
812-
const reversedQuery = this.policyUtils.buildReversedQuery(context, true, unsafe);
812+
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context, true, unsafe);
813813
if ((!unsafe || context.field.isRelationOwner) && reversedQuery[context.field.backLink]) {
814814
// if mutation is safe, or current field owns the relation (so the other side has no fk),
815815
// and the reverse query contains the back link, then we can build a "connect" with it
@@ -885,7 +885,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
885885
if (args.skipDuplicates) {
886886
// get a reversed query to include fields inherited from upstream mutation,
887887
// it'll be merged with the create payload for unique constraint checking
888-
const upstreamQuery = this.policyUtils.buildReversedQuery(context);
888+
const upstreamQuery = await this.policyUtils.buildReversedQuery(db, context);
889889
if (await this.hasDuplicatedUniqueConstraint(model, item, upstreamQuery, db)) {
890890
if (this.shouldLogQuery) {
891891
this.logger.info(`[policy] \`createMany\` skipping duplicate ${formatObject(item)}`);
@@ -910,7 +910,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
910910
if (operation === 'disconnect') {
911911
// disconnect filter is not unique, need to build a reversed query to
912912
// locate the entity and use its id fields as unique filter
913-
const reversedQuery = this.policyUtils.buildReversedQuery(context);
913+
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
914914
const found = await db[model].findUnique({
915915
where: reversedQuery,
916916
select: this.policyUtils.makeIdSelection(model),
@@ -936,7 +936,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
936936
const visitor = new NestedWriteVisitor(this.modelMeta, {
937937
update: async (model, args, context) => {
938938
// build a unique query including upstream conditions
939-
const uniqueFilter = this.policyUtils.buildReversedQuery(context);
939+
const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context);
940940

941941
// handle not-found
942942
const existing = await this.policyUtils.checkExistence(db, model, uniqueFilter, true);
@@ -997,7 +997,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
997997
if (preValueSelect) {
998998
select = { ...select, ...preValueSelect };
999999
}
1000-
const reversedQuery = this.policyUtils.buildReversedQuery(context);
1000+
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
10011001
const currentSetQuery = { select, where: reversedQuery };
10021002
this.policyUtils.injectAuthGuardAsWhere(db, currentSetQuery, model, 'read');
10031003

@@ -1027,7 +1027,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
10271027
} else {
10281028
// we have to process `updateMany` separately because the guard may contain
10291029
// filters using relation fields which are not allowed in nested `updateMany`
1030-
const reversedQuery = this.policyUtils.buildReversedQuery(context);
1030+
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
10311031
const updateWhere = this.policyUtils.and(reversedQuery, updateGuard);
10321032
if (this.shouldLogQuery) {
10331033
this.logger.info(
@@ -1066,7 +1066,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
10661066

10671067
upsert: async (model, args, context) => {
10681068
// build a unique query including upstream conditions
1069-
const uniqueFilter = this.policyUtils.buildReversedQuery(context);
1069+
const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context);
10701070

10711071
// branch based on if the update target exists
10721072
const existing = await this.policyUtils.checkExistence(db, model, uniqueFilter);
@@ -1090,7 +1090,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
10901090

10911091
// convert upsert to update
10921092
const convertedUpdate = {
1093-
where: args.where,
1093+
where: args.where ?? {},
10941094
data: this.validateUpdateInputSchema(model, args.update),
10951095
};
10961096
this.mergeToParent(context.parent, 'update', convertedUpdate);
@@ -1143,7 +1143,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
11431143

11441144
set: async (model, args, context) => {
11451145
// find the set of items to be replaced
1146-
const reversedQuery = this.policyUtils.buildReversedQuery(context);
1146+
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
11471147
const findCurrSetArgs = {
11481148
select: this.policyUtils.makeIdSelection(model),
11491149
where: reversedQuery,
@@ -1162,7 +1162,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
11621162

11631163
delete: async (model, args, context) => {
11641164
// build a unique query including upstream conditions
1165-
const uniqueFilter = this.policyUtils.buildReversedQuery(context);
1165+
const uniqueFilter = await this.policyUtils.buildReversedQuery(db, context);
11661166

11671167
// handle not-found
11681168
await this.policyUtils.checkExistence(db, model, uniqueFilter, true);
@@ -1179,7 +1179,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
11791179
} else {
11801180
// we have to process `deleteMany` separately because the guard may contain
11811181
// filters using relation fields which are not allowed in nested `deleteMany`
1182-
const reversedQuery = this.policyUtils.buildReversedQuery(context);
1182+
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
11831183
const deleteWhere = this.policyUtils.and(reversedQuery, guard);
11841184
if (this.shouldLogQuery) {
11851185
this.logger.info(`[policy] \`deleteMany\` ${model}:\n${formatObject({ where: deleteWhere })}`);
@@ -1579,12 +1579,15 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
15791579
if (this.shouldLogQuery) {
15801580
this.logger.info(
15811581
`[policy] \`findMany\` ${this.model}: ${formatObject({
1582-
where: args.where,
1582+
where: args.where ?? {},
15831583
select: candidateSelect,
15841584
})}`
15851585
);
15861586
}
1587-
const candidates = await tx[this.model].findMany({ where: args.where, select: candidateSelect });
1587+
const candidates = await tx[this.model].findMany({
1588+
where: args.where ?? {},
1589+
select: candidateSelect,
1590+
});
15881591

15891592
// build a ID filter based on id values filtered by the additional checker
15901593
const { idFilter } = this.buildIdFilterWithEntityChecker(candidates, entityChecker.func);

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

-3
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import {
3030
} from '../../../types';
3131
import { getVersion } from '../../../version';
3232
import type { InternalEnhancementOptions } from '../create-enhancement';
33-
import { Logger } from '../logger';
3433
import { QueryUtils } from '../query-utils';
3534
import type {
3635
DelegateConstraint,
@@ -47,7 +46,6 @@ import { formatObject, prismaClientKnownRequestError } from '../utils';
4746
* Access policy enforcement utilities
4847
*/
4948
export class PolicyUtil extends QueryUtils {
50-
private readonly logger: Logger;
5149
private readonly modelMeta: ModelMeta;
5250
private readonly policy: PolicyDef;
5351
private readonly zodSchemas?: ZodSchemas;
@@ -62,7 +60,6 @@ export class PolicyUtil extends QueryUtils {
6260
) {
6361
super(db, options);
6462

65-
this.logger = new Logger(db);
6663
this.user = context?.user;
6764

6865
({

packages/runtime/src/enhancements/node/query-utils.ts

+34-5
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@ import {
1010
} from '../../cross';
1111
import type { CrudContract, DbClientContract } from '../../types';
1212
import { getVersion } from '../../version';
13+
import { formatObject } from '../edge';
1314
import { InternalEnhancementOptions } from './create-enhancement';
15+
import { Logger } from './logger';
1416
import { prismaClientUnknownRequestError, prismaClientValidationError } from './utils';
1517

1618
export class QueryUtils {
17-
constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {}
19+
protected readonly logger: Logger;
20+
21+
constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {
22+
this.logger = new Logger(prisma);
23+
}
1824

1925
getIdFields(model: string) {
2026
return getIdFields(this.options.modelMeta, model, true);
@@ -60,7 +66,12 @@ export class QueryUtils {
6066
/**
6167
* Builds a reversed query for the given nested path.
6268
*/
63-
buildReversedQuery(context: NestedWriteVisitorContext, forMutationPayload = false, unsafeOperation = false) {
69+
async buildReversedQuery(
70+
db: CrudContract,
71+
context: NestedWriteVisitorContext,
72+
forMutationPayload = false,
73+
uncheckedOperation = false
74+
) {
6475
let result, currQuery: any;
6576
let currField: FieldInfo | undefined;
6677

@@ -102,17 +113,35 @@ export class QueryUtils {
102113
const shouldPreserveRelationCondition =
103114
// doing a mutation
104115
forMutationPayload &&
105-
// and it's a safe mutate
106-
!unsafeOperation &&
116+
// and it's not an unchecked mutate
117+
!uncheckedOperation &&
107118
// and the current segment is the direct parent (the last one is the mutate itself),
108119
// the relation condition should be preserved and will be converted to a "connect" later
109120
i === context.nestingPath.length - 2;
110121

111122
if (fkMapping && !shouldPreserveRelationCondition) {
112123
// turn relation condition into foreign key condition, e.g.:
113124
// { user: { id: 1 } } => { userId: 1 }
125+
126+
let parentPk = visitWhere;
127+
if (Object.keys(fkMapping).some((k) => !(k in parentPk) || parentPk[k] === undefined)) {
128+
// it can happen that the parent condition actually doesn't contain all id fields
129+
// (when the parent condition is not a primary key but unique constraints)
130+
// and in such case we need to load it to get the pks
131+
132+
if (this.options.logPrismaQuery && this.logger.enabled('info')) {
133+
this.logger.info(
134+
`[reverseLookup] \`findUniqueOrThrow\` ${model}: ${formatObject(where)}`
135+
);
136+
}
137+
parentPk = await db[model].findUniqueOrThrow({
138+
where,
139+
select: this.makeIdSelection(model),
140+
});
141+
}
142+
114143
for (const [r, fk] of Object.entries<string>(fkMapping)) {
115-
currQuery[fk] = visitWhere[r];
144+
currQuery[fk] = parentPk[r];
116145
}
117146

118147
if (i > 0) {

packages/server/src/api/rest/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ class RequestHandler extends APIHandlerBase {
678678
args.take = limit;
679679
const [entities, count] = await Promise.all([
680680
prisma[type].findMany(args),
681-
prisma[type].count({ where: args.where }),
681+
prisma[type].count({ where: args.where ?? {} }),
682682
]);
683683
const total = count as number;
684684

packages/testtools/src/schema.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ datasource db {
9696
9797
generator js {
9898
provider = 'prisma-client-js'
99-
${options.previewFeatures ? `previewFeatures = ${JSON.stringify(options.previewFeatures)}` : ''}
99+
${
100+
options.previewFeatures
101+
? `previewFeatures = ${JSON.stringify(options.previewFeatures)}`
102+
: 'previewFeatures = ["strictUndefinedChecks"]'
103+
}
100104
}
101105
102106
plugin enhancer {

0 commit comments

Comments
 (0)