diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 3ab2d4979..1f5102121 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -444,35 +444,28 @@ export abstract class BaseCrudDialect { continue; } - const countSelect = (negate: boolean) => { + const existsSelect = (negate: boolean) => { const filter = this.buildFilter(relationModel, relationFilterSelectAlias, subPayload); - return ( - this.eb - // the outer select is needed to avoid mysql's scope issue - .selectFrom( - this.buildSelectModel(relationModel, relationFilterSelectAlias) - .select(() => this.eb.fn.count(this.eb.lit(1)).as('$count')) - .where(buildPkFkWhereRefs(this.eb)) - .where(() => (negate ? this.eb.not(filter) : filter)) - .as('$sub'), - ) - .select('$count') - ); + const innerQuery = this.buildSelectModel(relationModel, relationFilterSelectAlias) + .select(this.eb.lit(1).as('_')) + .where(buildPkFkWhereRefs(this.eb)) + .where(() => (negate ? this.eb.not(filter) : filter)); + return this.buildExistsExpression(innerQuery); }; switch (key) { case 'some': { - result = this.and(result, this.eb(countSelect(false), '>', 0)); + result = this.and(result, existsSelect(false)); break; } case 'every': { - result = this.and(result, this.eb(countSelect(true), '=', 0)); + result = this.and(result, this.eb.not(existsSelect(true))); break; } case 'none': { - result = this.and(result, this.eb(countSelect(false), '=', 0)); + result = this.and(result, this.eb.not(existsSelect(false))); break; } } @@ -1400,6 +1393,15 @@ export abstract class BaseCrudDialect { // #endregion + /** + * Builds an EXISTS expression from an inner SELECT query. + * Can be overridden by dialects that need special handling (e.g., MySQL wraps + * in a derived table to avoid "can't specify target table for update in FROM clause"). + */ + protected buildExistsExpression(innerQuery: SelectQueryBuilder): Expression { + return this.eb.exists(innerQuery); + } + // #region abstract methods abstract get provider(): DataSourceProviderType; diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index e9ca2e4aa..6e444227b 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -177,6 +177,13 @@ export class MySqlCrudDialect extends LateralJoinDiale // #region other overrides + protected override buildExistsExpression(innerQuery: SelectQueryBuilder): Expression { + // MySQL doesn't allow referencing the target table of a DELETE/UPDATE in a subquery + // directly within the same statement. Wrapping in a derived table materializes the + // subquery, making it a separate virtual table that MySQL accepts. + return this.eb.exists(this.eb.selectFrom(innerQuery.as('$exists_sub')).select(this.eb.lit(1).as('_'))); + } + protected buildArrayAgg(arg: Expression): AliasableExpression { return this.eb.fn.coalesce(sql`JSON_ARRAYAGG(${arg})`, sql`JSON_ARRAY()`); } diff --git a/tests/regression/test/issue-2440.test.ts b/tests/regression/test/issue-2440.test.ts new file mode 100644 index 000000000..f61423ab2 --- /dev/null +++ b/tests/regression/test/issue-2440.test.ts @@ -0,0 +1,119 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2440 +describe('Regression for issue 2440', () => { + const schema = ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + value Int + userId Int + user User @relation(fields: [userId], references: [id]) +} + `; + + it('some filter should return users that have at least one matching post', async () => { + const db = await createTestClient(schema); + + // userA has posts with value 1 and 3 + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 1 }, + { title: 'p2', value: 3 }, + ], + }, + }, + }); + // userB has only a post with value 2 + const userB = await db.user.create({ data: { name: 'B', posts: { create: [{ title: 'p3', value: 2 }] } } }); + // userC has no posts + await db.user.create({ data: { name: 'C' } }); + + const result = await db.user.findMany({ + where: { posts: { some: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(userA.id); + + const result2 = await db.user.findMany({ where: { posts: { some: {} } }, orderBy: { id: 'asc' } }); + expect(result2).toHaveLength(2); + expect(result2.map((u: any) => u.id)).toEqual([userA.id, userB.id]); + }); + + it('none filter should return users that have no matching posts', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 1 }, + { title: 'p2', value: 3 }, + ], + }, + }, + }); + await db.user.create({ data: { name: 'B', posts: { create: [{ title: 'p3', value: 2 }] } } }); + const userC = await db.user.create({ data: { name: 'C' } }); + + const result = await db.user.findMany({ + where: { posts: { none: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + // userB (value 2, not > 2) and userC (no posts) have none with value > 2 + expect(result).toHaveLength(2); + const ids = result.map((u: any) => u.id); + expect(ids).not.toContain(userA.id); + expect(ids).toContain(userC.id); + }); + + it('every filter should return users where all posts match the condition', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 3 }, + { title: 'p2', value: 5 }, + ], + }, + }, + }); + await db.user.create({ + data: { + name: 'B', + posts: { + create: [ + { title: 'p3', value: 2 }, + { title: 'p4', value: 4 }, + ], + }, + }, + }); + const userC = await db.user.create({ data: { name: 'C' } }); + + // userA: all posts have value > 2 (3 and 5) ✓ + // userB: has a post with value 2, not > 2 ✗ + // userC: no posts, every filter vacuously true ✓ + const result = await db.user.findMany({ + where: { posts: { every: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(2); + expect(result.map((u: any) => u.id)).toEqual([userA.id, userC.id]); + }); +});