Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,35 +444,28 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
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;
}
}
Expand Down Expand Up @@ -1400,6 +1393,15 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {

// #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<any, any, any>): Expression<SqlBool> {
return this.eb.exists(innerQuery);
}

// #region abstract methods

abstract get provider(): DataSourceProviderType;
Expand Down
7 changes: 7 additions & 0 deletions packages/orm/src/client/crud/dialects/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale

// #region other overrides

protected override buildExistsExpression(innerQuery: SelectQueryBuilder<any, any, any>): Expression<SqlBool> {
// 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<any>): AliasableExpression<any> {
return this.eb.fn.coalesce(sql`JSON_ARRAYAGG(${arg})`, sql`JSON_ARRAY()`);
}
Expand Down
119 changes: 119 additions & 0 deletions tests/regression/test/issue-2440.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
Loading