Skip to content

Commit f3e04b5

Browse files
committed
extra fixes and tests
1 parent 79dcba9 commit f3e04b5

File tree

5 files changed

+438
-29
lines changed

5 files changed

+438
-29
lines changed

packages/runtime/src/plugins/policy/expression-transformer.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ import type { ClientContract, CRUD } from '../../client/contract';
2424
import { getCrudDialect } from '../../client/crud/dialects';
2525
import type { BaseCrudDialect } from '../../client/crud/dialects/base-dialect';
2626
import { InternalError, QueryError } from '../../client/errors';
27-
import { getModel, getRelationForeignKeyFieldPairs, requireField, requireIdFields } from '../../client/query-utils';
27+
import {
28+
getManyToManyRelation,
29+
getModel,
30+
getRelationForeignKeyFieldPairs,
31+
requireField,
32+
requireIdFields,
33+
} from '../../client/query-utils';
2834
import type {
2935
BinaryExpression,
3036
BinaryOperator,
@@ -543,6 +549,11 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
543549
relationModel: string,
544550
context: ExpressionTransformerContext<Schema>,
545551
): SelectQueryNode {
552+
const m2m = getManyToManyRelation(this.schema, context.model, field);
553+
if (m2m) {
554+
return this.transformManyToManyRelationAccess(m2m, context);
555+
}
556+
546557
const fromModel = context.model;
547558
const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(this.schema, fromModel, field);
548559

@@ -580,6 +591,28 @@ export class ExpressionTransformer<Schema extends SchemaDef> {
580591
};
581592
}
582593

594+
private transformManyToManyRelationAccess(
595+
m2m: NonNullable<ReturnType<typeof getManyToManyRelation>>,
596+
context: ExpressionTransformerContext<Schema>,
597+
) {
598+
const eb = expressionBuilder<any, any>();
599+
const relationQuery = eb
600+
.selectFrom(m2m.otherModel)
601+
// inner join with join table and additionally filter by the parent model
602+
.innerJoin(m2m.joinTable, (join) =>
603+
join
604+
// relation model pk to join table fk
605+
.onRef(`${m2m.otherModel}.${m2m.otherPKName}`, '=', `${m2m.joinTable}.${m2m.otherFkName}`)
606+
// parent model pk to join table fk
607+
.onRef(
608+
`${m2m.joinTable}.${m2m.parentFkName}`,
609+
'=',
610+
`${context.alias ?? context.model}.${m2m.parentPKName}`,
611+
),
612+
);
613+
return relationQuery.toOperationNode();
614+
}
615+
583616
private createColumnRef(column: string, context: ExpressionTransformerContext<Schema>): ReferenceNode {
584617
return ReferenceNode.create(ColumnNode.create(column), TableNode.create(context.alias ?? context.model));
585618
}

packages/runtime/src/plugins/policy/policy-handler.ts

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { invariant, zip } from '@zenstackhq/common-helpers';
1+
import { invariant } from '@zenstackhq/common-helpers';
22
import {
33
AliasNode,
44
BinaryOperationNode,
@@ -28,7 +28,6 @@ import {
2828
type OperationNode,
2929
type QueryResult,
3030
type RootOperationNode,
31-
type SelectQueryBuilder,
3231
} from 'kysely';
3332
import { match } from 'ts-pattern';
3433
import type { ClientContract } from '../../client';
@@ -758,41 +757,33 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
758757
return undefined;
759758
}
760759

761-
const sortedRecords = [
762-
{
763-
model: m2m.firstModel,
764-
field: m2m.firstField,
765-
},
766-
{
767-
model: m2m.secondModel,
768-
field: m2m.secondField,
769-
},
770-
];
771-
772760
// join table's permission:
773761
// - read: requires both sides to be readable
774762
// - mutation: requires both sides to be updatable
775763

776-
const queries: SelectQueryBuilder<any, any, any>[] = [];
764+
const checkForOperation = operation === 'read' ? 'read' : 'update';
777765
const eb = expressionBuilder<any, any>();
766+
const joinTable = alias ?? tableName;
778767

779-
for (const [fk, entry] of zip(['A', 'B'], sortedRecords)) {
780-
const idFields = requireIdFields(this.client.$schema, entry.model);
781-
invariant(idFields.length === 1, 'only single-field id is supported for implicit many-to-many join table');
768+
const aQuery = eb
769+
.selectFrom(m2m.firstModel)
770+
.whereRef(`${m2m.firstModel}.${m2m.firstIdField}`, '=', `${joinTable}.A`)
771+
.select(() =>
772+
new ExpressionWrapper(
773+
this.buildPolicyFilter(m2m.firstModel as GetModels<Schema>, undefined, checkForOperation),
774+
).as('$conditionA'),
775+
);
782776

783-
const policyFilter = this.buildPolicyFilter(
784-
entry.model as GetModels<Schema>,
785-
undefined,
786-
operation === 'read' ? 'read' : 'update',
777+
const bQuery = eb
778+
.selectFrom(m2m.secondModel)
779+
.whereRef(`${m2m.secondModel}.${m2m.secondIdField}`, '=', `${joinTable}.B`)
780+
.select(() =>
781+
new ExpressionWrapper(
782+
this.buildPolicyFilter(m2m.secondModel as GetModels<Schema>, undefined, checkForOperation),
783+
).as('$conditionB'),
787784
);
788-
const query = eb
789-
.selectFrom(entry.model)
790-
.whereRef(`${entry.model}.${idFields[0]}`, '=', `${alias ?? tableName}.${fk}`)
791-
.select(new ExpressionWrapper(policyFilter).as(`$condition${fk}`));
792-
queries.push(query);
793-
}
794785

795-
return eb.and(queries).toOperationNode();
786+
return eb.and([aQuery, bQuery]).toOperationNode();
796787
}
797788

798789
// #endregion

packages/runtime/test/policy/crud/create.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,4 +273,98 @@ model Profile {
273273
},
274274
});
275275
});
276+
277+
it('works with unnamed many-to-many relation', async () => {
278+
const db = await createPolicyTestClient(
279+
`
280+
model User {
281+
id Int @id
282+
groups Group[]
283+
private Boolean
284+
@@allow('create,read', true)
285+
@@allow('update', !private)
286+
}
287+
288+
model Group {
289+
id Int @id
290+
private Boolean
291+
users User[]
292+
@@allow('create,read', true)
293+
@@allow('update', !private)
294+
}
295+
`,
296+
{ usePrismaPush: true },
297+
);
298+
299+
await expect(
300+
db.user.create({
301+
data: { id: 1, private: false, groups: { create: [{ id: 1, private: false }] } },
302+
}),
303+
).toResolveTruthy();
304+
305+
await expect(
306+
db.user.create({
307+
data: { id: 2, private: true, groups: { create: [{ id: 2, private: false }] } },
308+
}),
309+
).toBeRejectedByPolicy();
310+
311+
await expect(
312+
db.user.create({
313+
data: { id: 2, private: false, groups: { create: [{ id: 2, private: true }] } },
314+
}),
315+
).toBeRejectedByPolicy();
316+
317+
await expect(
318+
db.user.create({
319+
data: { id: 2, private: true, groups: { create: [{ id: 2, private: true }] } },
320+
}),
321+
).toBeRejectedByPolicy();
322+
});
323+
324+
it('works with named many-to-many relation', async () => {
325+
const db = await createPolicyTestClient(
326+
`
327+
model User {
328+
id Int @id
329+
groups Group[] @relation("UserGroups")
330+
private Boolean
331+
@@allow('create,read', true)
332+
@@allow('update', !private)
333+
}
334+
335+
model Group {
336+
id Int @id
337+
private Boolean
338+
users User[] @relation("UserGroups")
339+
@@allow('create,read', true)
340+
@@allow('update', !private)
341+
}
342+
`,
343+
{ usePrismaPush: true },
344+
);
345+
346+
await expect(
347+
db.user.create({
348+
data: { id: 1, private: false, groups: { create: [{ id: 1, private: false }] } },
349+
}),
350+
).toResolveTruthy();
351+
352+
await expect(
353+
db.user.create({
354+
data: { id: 2, private: true, groups: { create: [{ id: 2, private: false }] } },
355+
}),
356+
).toBeRejectedByPolicy();
357+
358+
await expect(
359+
db.user.create({
360+
data: { id: 2, private: false, groups: { create: [{ id: 2, private: true }] } },
361+
}),
362+
).toBeRejectedByPolicy();
363+
364+
await expect(
365+
db.user.create({
366+
data: { id: 2, private: true, groups: { create: [{ id: 2, private: true }] } },
367+
}),
368+
).toBeRejectedByPolicy();
369+
});
276370
});

packages/runtime/test/policy/crud/read.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,86 @@ model Bar {
165165
});
166166
});
167167

168+
it('works with unnamed many-to-many relation read', async () => {
169+
const db = await createPolicyTestClient(
170+
`
171+
model User {
172+
id Int @id
173+
groups Group[]
174+
@@allow('all', true)
175+
}
176+
177+
model Group {
178+
id Int @id
179+
private Boolean
180+
users User[]
181+
@@allow('read', !private)
182+
}
183+
`,
184+
{ usePrismaPush: true },
185+
);
186+
187+
await db.$unuseAll().user.create({
188+
data: {
189+
id: 1,
190+
groups: {
191+
create: [
192+
{ id: 1, private: true },
193+
{ id: 2, private: false },
194+
],
195+
},
196+
},
197+
});
198+
await expect(db.user.findFirst({ include: { groups: true } })).resolves.toMatchObject({
199+
groups: [{ id: 2 }],
200+
});
201+
await expect(
202+
db.user.findFirst({ where: { id: 1 }, select: { _count: { select: { groups: true } } } }),
203+
).resolves.toMatchObject({
204+
_count: { groups: 1 },
205+
});
206+
});
207+
208+
it('works with named many-to-many relation read', async () => {
209+
const db = await createPolicyTestClient(
210+
`
211+
model User {
212+
id Int @id
213+
groups Group[] @relation("UserGroups")
214+
@@allow('all', true)
215+
}
216+
217+
model Group {
218+
id Int @id
219+
private Boolean
220+
users User[] @relation("UserGroups")
221+
@@allow('read', !private)
222+
}
223+
`,
224+
{ usePrismaPush: true },
225+
);
226+
227+
await db.$unuseAll().user.create({
228+
data: {
229+
id: 1,
230+
groups: {
231+
create: [
232+
{ id: 1, private: true },
233+
{ id: 2, private: false },
234+
],
235+
},
236+
},
237+
});
238+
await expect(db.user.findFirst({ include: { groups: true } })).resolves.toMatchObject({
239+
groups: [{ id: 2 }],
240+
});
241+
await expect(
242+
db.user.findFirst({ where: { id: 1 }, select: { _count: { select: { groups: true } } } }),
243+
).resolves.toMatchObject({
244+
_count: { groups: 1 },
245+
});
246+
});
247+
168248
it('works with filtered by to-one relation field', async () => {
169249
const db = await createPolicyTestClient(
170250
`

0 commit comments

Comments
 (0)