From 5c90ef5bae75eea8a177c3842c7da9cc5eac847d Mon Sep 17 00:00:00 2001 From: Natan Salmon Date: Thu, 1 Oct 2026 13:04:48 +0300 Subject: [PATCH] fix(orm): pass the model alias to `$expr` filters (#2863) Relation filters select the related model under a generated alias, so a `$expr` that qualifies references with the model name fails with a missing FROM-clause entry. `$expr` now receives a context with `modelAlias`, typed as the model name like the expression builder scope. Co-authored-by: Cursor --- packages/orm/src/client/crud-types.ts | 19 +++- .../src/client/crud/dialects/base-dialect.ts | 2 +- tests/e2e/orm/client-api/find.test.ts | 26 +++++ tests/regression/test/issue-2863.test.ts | 97 +++++++++++++++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tests/regression/test/issue-2863.test.ts diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index d0e9ef940..0fe062579 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -353,6 +353,20 @@ export type BatchResult = { count: number }; //#region Common structures +/** + * Context object passed to `$expr` filters. + */ +export type ExprFilterContext> = { + /** + * The alias that can be used to refer to the filtered model. It's the model name for top-level + * filters, but relation filters select the model under a generated alias. + * + * Typed as the model name to match the expression builder's scope, so qualified references + * like `` eb.ref(`${modelAlias}.field`) `` type-check. + */ + modelAlias: Model; +}; + export type WhereInput< Schema extends SchemaDef, Model extends GetModels, @@ -370,7 +384,10 @@ export type WhereInput< { args: ComputedFieldArgs } & FieldFilter : FieldFilter; } & { - $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; + $expr?: ( + eb: ExpressionBuilder, Model>, + context: ExprFilterContext, + ) => OperandExpression; } & { AND?: OrArray>; OR?: WhereInput[]; diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index e3e852ed2..ca6b3709b 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -295,7 +295,7 @@ export abstract class BaseCrudDialect { // call expression builder and combine the results if ('$expr' in _where && typeof _where['$expr'] === 'function') { - result = this.and(result, _where['$expr'](this.eb)); + result = this.and(result, _where['$expr'](this.eb, { modelAlias })); } return result; diff --git a/tests/e2e/orm/client-api/find.test.ts b/tests/e2e/orm/client-api/find.test.ts index 69d9b1c0d..1e4bc9c54 100644 --- a/tests/e2e/orm/client-api/find.test.ts +++ b/tests/e2e/orm/client-api/find.test.ts @@ -1277,4 +1277,30 @@ describe('Client find tests ', () => { }), ).resolves.toHaveLength(0); }); + + it('supports qualified references with the $expr model alias', async () => { + const user1 = await createUser(client, 'yiming@zenstack.dev'); + const user2 = await createUser(client, 'yiming@gmail.com'); + await createPosts(client, user1.id); + await createPosts(client, user2.id); + + await expect( + client.user.findMany({ + where: { + $expr: (eb, { modelAlias }) => eb(eb.ref(`${modelAlias}.email`), 'like', '%@zenstack.dev'), + }, + }), + ).resolves.toHaveLength(1); + + // relation filters select the related model under a generated alias + const posts = await client.post.findMany({ + where: { + author: { + $expr: (eb, { modelAlias }) => eb(eb.ref(`${modelAlias}.email`), 'like', '%@zenstack.dev'), + }, + }, + }); + expect(posts.length).toBeGreaterThan(0); + expect(posts.every((p) => p.authorId === user1.id)).toBe(true); + }); }); diff --git a/tests/regression/test/issue-2863.test.ts b/tests/regression/test/issue-2863.test.ts new file mode 100644 index 000000000..d1197336d --- /dev/null +++ b/tests/regression/test/issue-2863.test.ts @@ -0,0 +1,97 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #2863', () => { + const schema = ` +model User { + id Int @id @default(autoincrement()) + email String @unique + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + authorId Int + author User @relation(fields: [authorId], references: [id]) +} +`; + + async function setup() { + const db = await createTestClient(schema); + await db.user.create({ + data: { email: 'u1@zenstack.dev', posts: { create: [{ title: 'p1' }] } }, + }); + await db.user.create({ + data: { email: 'u2@example.com', posts: { create: [{ title: 'p2' }, { title: 'p3' }] } }, + }); + return db; + } + + it('passes the model alias to top-level $expr', async () => { + const db = await setup(); + let alias: string | undefined; + + const users = await db.user.findMany({ + where: { + $expr: (eb: any, { modelAlias }: { modelAlias: string }) => { + alias = modelAlias; + return eb(eb.ref(`${modelAlias}.email`), 'like', '%@zenstack.dev'); + }, + }, + }); + + expect(alias).toBe('User'); + expect(users.map((u: any) => u.email)).toEqual(['u1@zenstack.dev']); + }); + + it('passes the generated alias to $expr inside a to-one relation filter', async () => { + const db = await setup(); + let alias: string | undefined; + + const posts = await db.post.findMany({ + where: { + author: { + $expr: (eb: any, { modelAlias }: { modelAlias: string }) => { + alias = modelAlias; + return eb(eb.ref(`${modelAlias}.email`), 'like', '%@zenstack.dev'); + }, + }, + }, + }); + + expect(alias).not.toBe('User'); + expect(posts.map((p: any) => p.title)).toEqual(['p1']); + }); + + it('passes the generated alias to $expr inside a to-many relation filter', async () => { + const db = await setup(); + + const users = await db.user.findMany({ + where: { + posts: { + some: { + $expr: (eb: any, { modelAlias }: { modelAlias: string }) => + eb(eb.ref(`${modelAlias}.title`), '=', 'p3'), + }, + }, + }, + }); + + expect(users.map((u: any) => u.email)).toEqual(['u2@example.com']); + }); + + it('keeps supporting unqualified references inside relation filters', async () => { + const db = await setup(); + + const posts = await db.post.findMany({ + where: { + author: { + $expr: (eb: any) => eb('email', 'like', '%@zenstack.dev'), + }, + }, + }); + + expect(posts.map((p: any) => p.title)).toEqual(['p1']); + }); +});