From 318018f17bcd0f35e248dda605c9d5e366e1328c Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 11 Aug 2026 11:30:32 +0200 Subject: [PATCH] fix(orm): parenthesize an inlined computed field expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A computed field is inlined into larger expressions — a boolean filter renders it as ` = $n` — so an implementation whose top-level node is a bare comparison produced a chained `"authorId" = $2 = $3`. Postgres rejects that as a syntax error; sqlite and mysql only parse it correctly by accident of left associativity. Wrap the implementation's expression in parens so its operator precedence stays contained. Kysely doesn't double-wrap an already parenthesized expression, so `eb.or` /`eb.and`-based and subquery implementations compile unchanged. Fixes #2795 Co-Authored-By: Claude Opus 5 (1M context) --- .../src/client/crud/dialects/base-dialect.ts | 7 +-- .../orm/client-api/computed-fields.test.ts | 47 +++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 4d97b685e..5d71fe78f 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1689,9 +1689,10 @@ export abstract class BaseCrudDialect { // every query issued through the ORM builds the dialect from a client, and a dialect // built from a standalone schema/options pair never inlines computed fields invariant(this.client, `computed field "${field}" of model "${model}" needs a client to be evaluated`); - // `computedArgs` is the query-time args object for a parameterized computed - // field (undefined otherwise); forwarded as the implementation's 3rd argument. - return computer(this.eb, { modelAlias, client: this.client }, computedArgs); + // `computedArgs` is the query-time args of a parameterized computed field (undefined + // otherwise), forwarded as the implementation's 3rd argument. The result is parenthesized + // as it gets embedded into larger expressions: `where: { isMine: true }` → `() = $n`. + return this.eb.parens(computer(this.eb, { modelAlias, client: this.client }, computedArgs)); } } diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 2be62ad94..01f74c662 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1002,9 +1002,8 @@ model Post { { computedFields: { Post: { - // parenthesized: the expression gets embedded into larger ones - // (e.g. `where: { isMine: true }` wraps it with `= true`), and an - // unparenthesized chained comparison is a syntax error on postgres + // the dialect parenthesizes an inlined implementation itself; the explicit + // `eb.parens` here guards that an already-parenthesized one isn't wrapped twice isMine: (eb: any, { client }: any) => eb.parens(eb('authorId', '=', client.$auth?.id ?? -1)), }, }, @@ -1026,4 +1025,46 @@ model Post { // the original client is unaffected await expect(db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ isMine: false }); }); + + it('contains the precedence of an inlined computed field expression', async () => { + const db = await createTestClient( + ` +model Post { + id Int @id @default(autoincrement()) + authorId Int + isMine Boolean @computed + isSpecial Boolean @computed +} +`, + { + computedFields: { + Post: { + // top-level node is a binary operation, which is embedded into + // ` = $n` when the field is used as a boolean filter + isMine: (eb: any) => eb('authorId', '=', 1), + // top-level node is a logical combinator + isSpecial: (eb: any) => eb.or([eb('authorId', '=', 1), eb('id', '=', 2)]), + }, + }, + } as any, + ); + + await db.post.create({ data: { id: 1, authorId: 1 } }); + await db.post.create({ data: { id: 2, authorId: 2 } }); + await db.post.create({ data: { id: 3, authorId: 3 } }); + + const findIds = async (where: any) => + (await db.post.findMany({ where, orderBy: { id: 'asc' } })).map((r: any) => r.id); + + expect(await findIds({ isMine: true })).toEqual([1]); + expect(await findIds({ isMine: false })).toEqual([2, 3]); + expect(await findIds({ NOT: { isMine: true } })).toEqual([2, 3]); + expect(await findIds({ isSpecial: true })).toEqual([1, 2]); + + // reading the fields is unaffected + await expect(db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + isMine: true, + isSpecial: true, + }); + }); });