From 9fc0a524516c286f6b10ec4a1441e8711ffc251f Mon Sep 17 00:00:00 2001 From: Lucian Buzzo Date: Thu, 14 Dec 2023 16:45:37 +0000 Subject: [PATCH] fix: allow non-scalar values when using client expressions This fixes an issue where client expressions fail if the value is not a scalar, e.g. if using `some`. Signed-off-by: Lucian Buzzo --- src/expressions.ts | 26 +++- test/integration/expressions.spec.ts | 207 +++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/src/expressions.ts b/src/expressions.ts index 15dc036..518927a 100644 --- a/src/expressions.ts +++ b/src/expressions.ts @@ -89,6 +89,30 @@ const tokenizeWhereExpression = ( let astFragment = {}; const value = where[field]; + + // Check if the field is an object, if so, we need to recurse + // This is a fairly simple approach but covers most cases like "some", "every", "none" etc. + if (fieldData.kind === "object") { + for (const subField in value) { + const subValue = value[subField]; + + const { tokens: subTokens, where: subWhere } = tokenizeWhereExpression( + client, + subValue, + table, + fieldData.type, + tokens, + ); + + tokens = { + ...tokens, + ...subTokens, + }; + + where[field][subField] = subWhere; + } + continue; + } const isNumeric = PRISMA_NUMERIC_TYPES.includes(fieldData.type); const isColumnName = typeof value === "string" && !!value.match(/^___yates_row_/); const isContext = typeof value === "string" && !!value.match(/^___yates_context_/); @@ -230,7 +254,7 @@ export const expressionToSQL = async (getExpression: Expression, table: string): expressionContext, ); // If the raw expression is a promise, then this is a client subselect, - // as opoosed to a plain SQL expression or "where" object + // as opposed to a plain SQL expression or "where" object const isSubselect = typeof rawExpression === "object" && typeof rawExpression.then === "function"; baseClient.$on("query", (e: any) => { diff --git a/test/integration/expressions.spec.ts b/test/integration/expressions.spec.ts index b617460..7f40e5e 100644 --- a/test/integration/expressions.spec.ts +++ b/test/integration/expressions.spec.ts @@ -625,5 +625,212 @@ describe("expressions", () => { expect(post.id).toBeDefined(); }); + + // This test case creates an ability that uses a nested "some" clause that filters on a related model + it("should be able to handle expressions that are multi-level objects", async () => { + const initial = new PrismaClient(); + + const role = `USER_${uuid()}`; + + const client = await setup({ + prisma: initial, + customAbilities: { + User: { + customReadAbility: { + description: "Read user that made a post with a tag labeled with the tag context value", + operation: "SELECT", + expression: (client: PrismaClient, row, context) => { + return client.post.findFirst({ + where: { + authorId: row("id"), + tags: { + some: { + label: context("ctx.label"), + }, + }, + }, + }); + }, + }, + }, + }, + getRoles(abilities) { + return { + [role]: [abilities.Post.create, abilities.Post.read, abilities.User.customReadAbility, abilities.Tag.read], + }; + }, + getContext: () => ({ + role, + context: { + "ctx.label": "foo", + }, + }), + }); + + const testTitle = `test_${uuid()}`; + + const user1 = await adminClient.user.create({ + data: { + email: `test-user-${uuid()}@example.com`, + posts: { + create: { + title: testTitle, + tags: { + create: { + label: "foo", + }, + }, + }, + }, + }, + }); + + const user2 = await adminClient.user.create({ + data: { + email: `test-user-${uuid()}@example.com`, + posts: { + create: { + title: testTitle, + tags: { + create: { + label: "bar", + }, + }, + }, + }, + }, + }); + + const result1 = await client.user.findFirst({ + where: { + id: user1.id, + }, + }); + + expect(result1).not.toBeNull(); + + const result2 = await client.user.findFirst({ + where: { + id: user2.id, + }, + }); + + expect(result2).toBeNull(); + }); + + // This test case creates an ability that uses multiple nested "some" clauses that span models + it("should be able to handle expressions that are deep multi-level objects", async () => { + const initial = new PrismaClient(); + + const role = `USER_${uuid()}`; + + const testTitle = `test_${uuid()}`; + + const client = await setup({ + prisma: initial, + customAbilities: { + User: { + customReadAbility: { + description: + "Read user that made a post with a tag that is also attached to a post with the title context value", + operation: "SELECT", + expression: (client: PrismaClient, row, context) => { + return client.post.findFirst({ + where: { + authorId: row("id"), + tags: { + some: { + posts: { + some: { + title: context("ctx.title"), + }, + }, + }, + }, + }, + }); + }, + }, + }, + }, + getRoles(abilities) { + return { + [role]: [abilities.Post.create, abilities.Post.read, abilities.User.customReadAbility, abilities.Tag.read], + }; + }, + getContext: () => ({ + role, + context: { + "ctx.title": testTitle, + }, + }), + }); + + const user1 = await adminClient.user.create({ + data: { + email: `test-user-${uuid()}@example.com`, + posts: { + create: { + title: testTitle, + tags: { + create: { + label: uuid(), + }, + }, + }, + }, + }, + include: { + posts: { + include: { + tags: true, + }, + }, + }, + }); + + await adminClient.post.create({ + data: { + title: testTitle, + tags: { + connect: { + id: user1.posts[0].tags[0].id, + }, + }, + }, + }); + + const user2 = await adminClient.user.create({ + data: { + email: `test-user-${uuid()}@example.com`, + posts: { + create: { + title: `test_${uuid()}`, + tags: { + create: { + label: "bar", + }, + }, + }, + }, + }, + }); + + const result1 = await client.user.findFirst({ + where: { + id: user1.id, + }, + }); + + expect(result1).not.toBeNull(); + + const result2 = await client.user.findFirst({ + where: { + id: user2.id, + }, + }); + + expect(result2).toBeNull(); + }); }); });