From 25533891d46431acfbfda4f03d9c07c059fb23e9 Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Wed, 15 Apr 2026 11:26:41 +0200 Subject: [PATCH] perf(orm): use EXISTS instead of COUNT subquery for to-one relation filters Sibling fix to #2455. `buildToOneRelationFilter` was emitting `(select count(1) ...) > 0` for relation predicates like `{ relation: { field: value } }` and `{ relation: { is: {...} } }`. PostgreSQL cannot convert that into a semi-join, so the aggregate fires once per parent row and performance collapses on large tables. Switch to the `buildExistsExpression` helper already used by `buildToManyRelationFilter`, mapping: - `is: {...}` / default payload -> EXISTS - `is: null` -> NOT EXISTS - `isNot: null` -> EXISTS - `isNot: {...}` -> NOT EXISTS OR NOT EXISTS(with filter) Same semantics, but the planner can now turn it into a proper semi-join. On a 2.6M-row product_site table with the right index, execution drops from ~2100 ms to ~49 ms (~43x). Fixes #2578 --- .../src/client/crud/dialects/base-dialect.ts | 47 +++---- tests/regression/test/issue-2578.test.ts | 123 ++++++++++++++++++ 2 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 tests/regression/test/issue-2578.test.ts diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index b525ac486..ee4d97406 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -355,14 +355,17 @@ export abstract class BaseCrudDialect { field, joinAlias, ); - const filterResultField = tmpAlias(`${field}$flt`); - - const joinSelect = this.eb + const baseJoin = this.eb .selectFrom(`${fieldDef.type} as ${joinAlias}`) + .select(this.eb.lit(1).as('_')) .where(() => this.and(...joinPairs.map(([left, right]) => this.eb(this.eb.ref(left), '=', this.eb.ref(right)))), - ) - .select(() => this.eb.fn.count(this.eb.lit(1)).as(filterResultField)); + ); + + const existsSelect = (extraFilter?: () => Expression) => { + const q = extraFilter ? baseJoin.where(extraFilter) : baseJoin; + return this.buildExistsExpression(q); + }; const conditions: Expression[] = []; @@ -370,46 +373,30 @@ export abstract class BaseCrudDialect { if ('is' in payload) { if (payload.is === null) { // check if not found - conditions.push(this.eb(joinSelect, '=', 0)); + conditions.push(this.eb.not(existsSelect())); } else { - // check if found - conditions.push( - this.eb( - joinSelect.where(() => this.buildFilter(fieldDef.type, joinAlias, payload.is)), - '>', - 0, - ), - ); + // check if found that matches the filter + conditions.push(existsSelect(() => this.buildFilter(fieldDef.type, joinAlias, payload.is))); } } if ('isNot' in payload) { if (payload.isNot === null) { // check if found - conditions.push(this.eb(joinSelect, '>', 0)); + conditions.push(existsSelect()); } else { conditions.push( this.or( - // is null - this.eb(joinSelect, '=', 0), - // found one that matches the filter - this.eb( - joinSelect.where(() => this.buildFilter(fieldDef.type, joinAlias, payload.isNot)), - '=', - 0, - ), + // no related row + this.eb.not(existsSelect()), + // related row exists but doesn't match the filter + this.eb.not(existsSelect(() => this.buildFilter(fieldDef.type, joinAlias, payload.isNot))), ), ); } } } else { - conditions.push( - this.eb( - joinSelect.where(() => this.buildFilter(fieldDef.type, joinAlias, payload)), - '>', - 0, - ), - ); + conditions.push(existsSelect(() => this.buildFilter(fieldDef.type, joinAlias, payload))); } return this.and(...conditions); diff --git a/tests/regression/test/issue-2578.test.ts b/tests/regression/test/issue-2578.test.ts new file mode 100644 index 000000000..cc599e361 --- /dev/null +++ b/tests/regression/test/issue-2578.test.ts @@ -0,0 +1,123 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2578 +// Sibling of issue 2440, covering the to-one (non-array) relation filter path: +// `buildToOneRelationFilter` used to emit `(select count(1) ...) > 0` which +// PostgreSQL can't convert to a semi-join; it now emits `EXISTS (...)`. +describe('Regression for issue 2578', () => { + const schema = ` +model Post { + id Int @id @default(autoincrement()) + title String + value Int + userId Int? + user User? @relation(fields: [userId], references: [id]) +} + +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + `; + + it('to-one relation filter with field predicate returns matching children', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ data: { name: 'A' } }); + const userB = await db.user.create({ data: { name: 'B' } }); + + const p1 = await db.post.create({ data: { title: 'p1', value: 1, userId: userA.id } }); + const p2 = await db.post.create({ data: { title: 'p2', value: 2, userId: userB.id } }); + const p3 = await db.post.create({ data: { title: 'p3', value: 3, userId: null } }); + + // `user: { name: 'A' }` is a to-one relation filter + const result = await db.post.findMany({ + where: { user: { name: 'A' } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(p1.id); + + // posts with no user should not match a user filter + const result2 = await db.post.findMany({ + where: { user: { name: 'C' } }, + orderBy: { id: 'asc' }, + }); + expect(result2).toHaveLength(0); + + // keep references live so the test intent is readable + expect([p1.id, p2.id, p3.id].length).toBe(3); + }); + + it('`is` with field predicate matches the related row', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ data: { name: 'A' } }); + const userB = await db.user.create({ data: { name: 'B' } }); + const p1 = await db.post.create({ data: { title: 'p1', value: 1, userId: userA.id } }); + const p2 = await db.post.create({ data: { title: 'p2', value: 2, userId: userB.id } }); + await db.post.create({ data: { title: 'p3', value: 3, userId: null } }); + + const result = await db.post.findMany({ + where: { user: { is: { name: 'B' } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(p2.id); + + // sanity: other rows are still reachable + expect(p1.id).toBeDefined(); + }); + + it('`is: null` matches rows with no related record', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ data: { name: 'A' } }); + await db.post.create({ data: { title: 'p1', value: 1, userId: userA.id } }); + const p2 = await db.post.create({ data: { title: 'p2', value: 2, userId: null } }); + + const result = await db.post.findMany({ + where: { user: { is: null } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(p2.id); + }); + + it('`isNot: null` matches rows with a related record', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ data: { name: 'A' } }); + const p1 = await db.post.create({ data: { title: 'p1', value: 1, userId: userA.id } }); + await db.post.create({ data: { title: 'p2', value: 2, userId: null } }); + + const result = await db.post.findMany({ + where: { user: { isNot: null } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(p1.id); + }); + + it('`isNot` with field predicate matches rows where the related record does not satisfy the filter or has no related record', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ data: { name: 'A' } }); + const userB = await db.user.create({ data: { name: 'B' } }); + const p1 = await db.post.create({ data: { title: 'p1', value: 1, userId: userA.id } }); + const p2 = await db.post.create({ data: { title: 'p2', value: 2, userId: userB.id } }); + const p3 = await db.post.create({ data: { title: 'p3', value: 3, userId: null } }); + + // posts whose related user is NOT named 'A' (includes the no-user case) + const result = await db.post.findMany({ + where: { user: { isNot: { name: 'A' } } }, + orderBy: { id: 'asc' }, + }); + const ids = result.map((p: any) => p.id); + expect(ids).toContain(p2.id); + expect(ids).toContain(p3.id); + expect(ids).not.toContain(p1.id); + }); +});