Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/runtime/src/enhancements/node/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,7 +959,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
this.injectWhereHierarchy(model, (args as any)?.where);
this.doProcessUpdatePayload(model, (args as any)?.data);
} else {
const where = this.queryUtils.buildReversedQuery(context, false, false);
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
await this.queryUtils.transaction(db, async (tx) => {
await this.doUpdateMany(tx, model, { ...args, where }, simpleUpdateMany);
});
Expand Down Expand Up @@ -1022,15 +1022,15 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler {
},

delete: async (model, _args, context) => {
const where = this.queryUtils.buildReversedQuery(context, false, false);
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
await this.queryUtils.transaction(db, async (tx) => {
await this.doDelete(tx, model, { where });
});
delete context.parent['delete'];
},

deleteMany: async (model, _args, context) => {
const where = this.queryUtils.buildReversedQuery(context, false, false);
const where = await this.queryUtils.buildReversedQuery(db, context, false, false);
await this.queryUtils.transaction(db, async (tx) => {
await this.doDeleteMany(tx, model, where);
});
Expand Down
20 changes: 10 additions & 10 deletions packages/runtime/src/enhancements/node/policy/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,7 +809,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
const unsafe = isUnsafeMutate(model, args, this.modelMeta);

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

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

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

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

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

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

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

// handle not-found
await this.policyUtils.checkExistence(db, model, uniqueFilter, true);
Expand All @@ -1179,7 +1179,7 @@ export class PolicyProxyHandler<DbClient extends DbClientContract> implements Pr
} else {
// we have to process `deleteMany` separately because the guard may contain
// filters using relation fields which are not allowed in nested `deleteMany`
const reversedQuery = this.policyUtils.buildReversedQuery(context);
const reversedQuery = await this.policyUtils.buildReversedQuery(db, context);
const deleteWhere = this.policyUtils.and(reversedQuery, guard);
if (this.shouldLogQuery) {
this.logger.info(`[policy] \`deleteMany\` ${model}:\n${formatObject({ where: deleteWhere })}`);
Expand Down
39 changes: 34 additions & 5 deletions packages/runtime/src/enhancements/node/query-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,17 @@ import {
} from '../../cross';
import type { CrudContract, DbClientContract } from '../../types';
import { getVersion } from '../../version';
import { formatObject } from '../edge';
import { InternalEnhancementOptions } from './create-enhancement';
import { Logger } from './logger';
import { prismaClientUnknownRequestError, prismaClientValidationError } from './utils';

export class QueryUtils {
constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {}
private readonly logger: Logger;

constructor(private readonly prisma: DbClientContract, protected readonly options: InternalEnhancementOptions) {
this.logger = new Logger(prisma);
}

getIdFields(model: string) {
return getIdFields(this.options.modelMeta, model, true);
Expand Down Expand Up @@ -60,7 +66,12 @@ export class QueryUtils {
/**
* Builds a reversed query for the given nested path.
*/
buildReversedQuery(context: NestedWriteVisitorContext, forMutationPayload = false, unsafeOperation = false) {
async buildReversedQuery(
db: CrudContract,
context: NestedWriteVisitorContext,
forMutationPayload = false,
uncheckedOperation = false
) {
let result, currQuery: any;
let currField: FieldInfo | undefined;

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

if (fkMapping && !shouldPreserveRelationCondition) {
// turn relation condition into foreign key condition, e.g.:
// { user: { id: 1 } } => { userId: 1 }

let parentPk = visitWhere;
if (Object.keys(fkMapping).some((k) => !(k in parentPk) || parentPk[k] === undefined)) {
// it can happen that the parent condition actually doesn't contain all id fields
// (when the parent condition is not a primary key but unique constraints)
// and in such case we need to load it to get the pks

if (this.options.logPrismaQuery && this.logger.enabled('info')) {
this.logger.info(
`[reverseLookup] \`findUniqueOrThrow\` ${model}: ${formatObject(where)}`
);
}
parentPk = await db[model].findUniqueOrThrow({
where,
select: this.makeIdSelection(model),
});
}

for (const [r, fk] of Object.entries<string>(fkMapping)) {
currQuery[fk] = visitWhere[r];
currQuery[fk] = parentPk[r];
}

if (i > 0) {
Expand Down
128 changes: 128 additions & 0 deletions tests/regression/tests/issue-1964.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { loadSchema } from '@zenstackhq/testtools';

describe('issue 1964', () => {
it('regression1', async () => {
const { enhance } = await loadSchema(
`
model User {
id Int @id
orgId String
}

model Author {
id Int @id @default(autoincrement())
orgId String
name String
posts Post[]

@@unique([orgId, name])
@@allow('all', auth().orgId == orgId)
}

model Post {
id Int @id @default(autoincrement())
orgId String
title String
author Author @relation(fields: [authorId], references: [id])
authorId Int

@@allow('all', auth().orgId == orgId)
}
`,
{
previewFeatures: ['strictUndefinedChecks'],
}
);

const db = enhance({ id: 1, orgId: 'org' });

const newauthor = await db.author.create({
data: {
name: `Foo ${Date.now()}`,
orgId: 'org',
posts: {
createMany: { data: [{ title: 'Hello', orgId: 'org' }] },
},
},
include: { posts: true },
});

await expect(
db.author.update({
where: { orgId_name: { orgId: 'org', name: newauthor.name } },
data: {
name: `Bar ${Date.now()}`,
posts: { deleteMany: { id: { equals: newauthor.posts[0].id } } },
},
})
).toResolveTruthy();
});

it('regression2', async () => {
const { enhance } = await loadSchema(
`
model User {
id Int @id @default(autoincrement())
slug String @unique
profile Profile?
@@allow('all', true)
}

model Profile {
id Int @id @default(autoincrement())
slug String @unique
name String
addresses Address[]
userId Int? @unique
user User? @relation(fields: [userId], references: [id])
@@allow('all', true)
}

model Address {
id Int @id @default(autoincrement())
profileId Int @unique
profile Profile @relation(fields: [profileId], references: [id])
city String
@@allow('all', true)
}
`,
{
previewFeatures: ['strictUndefinedChecks'],
}
);

const db = enhance({ id: 1, orgId: 'org' });

await db.user.create({
data: {
slug: `user1`,
profile: {
create: {
name: `My Profile`,
slug: 'profile1',
addresses: {
create: { id: 1, city: 'City' },
},
},
},
},
});

await expect(
db.user.update({
where: { slug: 'user1' },
data: {
profile: {
update: {
addresses: {
deleteMany: { id: { equals: 1 } },
},
},
},
},
})
).toResolveTruthy();

await expect(db.address.count()).resolves.toEqual(0);
});
});
Loading