diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 1f5102121..e38382f1f 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -757,11 +757,21 @@ export abstract class BaseCrudDialect { } protected buildJsonEqualityFilter(lhs: Expression, rhs: unknown) { - return this.buildLiteralFilter(lhs, 'Json', rhs); + return this.buildValueFilter(lhs, 'Json', rhs); } - private buildLiteralFilter(lhs: Expression, type: BuiltinType, rhs: unknown) { - return this.eb(lhs, '=', rhs !== null && rhs !== undefined ? this.transformInput(rhs, type, false) : rhs); + private buildValueFilter(lhs: Expression, type: BuiltinType, rhs: unknown) { + if (rhs === undefined) { + // undefined filter is no-op, always true + return this.true(); + } + + if (rhs === null) { + // null comparison + return this.eb(lhs, 'is', null); + } + + return this.eb(lhs, '=', this.transformInput(rhs, type, false)); } private buildStandardFilter( @@ -776,7 +786,7 @@ export abstract class BaseCrudDialect { ) { if (payload === null || !isPlainObject(payload)) { return { - conditions: [this.buildLiteralFilter(lhs, type, payload)], + conditions: [this.buildValueFilter(lhs, type, payload)], consumedKeys: [], }; } diff --git a/tests/e2e/orm/client-api/filter.test.ts b/tests/e2e/orm/client-api/filter.test.ts index d4594ab75..36173ca2d 100644 --- a/tests/e2e/orm/client-api/filter.test.ts +++ b/tests/e2e/orm/client-api/filter.test.ts @@ -414,6 +414,18 @@ describe('Client filter tests ', () => { where: { email: { not: { not: { contains: 'test' } } } }, }), ).toResolveTruthy(); + + // not null (issue #2472) + await expect( + client.user.findMany({ + where: { name: { not: null } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findFirst({ + where: { name: { not: null } }, + }), + ).resolves.toMatchObject({ id: user1.id }); }); it('supports numeric filters', async () => { @@ -490,6 +502,18 @@ describe('Client filter tests ', () => { where: { age: { not: { not: { equals: null } } } }, }), ).toResolveTruthy(); + + // not null shorthand (issue #2472) + await expect( + client.profile.findMany({ + where: { age: { not: null } }, + }), + ).toResolveWithLength(1); + await expect( + client.profile.findFirst({ + where: { age: { not: null } }, + }), + ).resolves.toMatchObject({ id: '1' }); }); it('supports boolean filters', async () => { diff --git a/tests/regression/test/issue-2472.test.ts b/tests/regression/test/issue-2472.test.ts new file mode 100644 index 000000000..c4389069f --- /dev/null +++ b/tests/regression/test/issue-2472.test.ts @@ -0,0 +1,53 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2472 +// Filtering by `{ not: null }` returns empty array instead of non-null records +describe('Regression for issue 2472', () => { + const schema = ` +model Post { + id Int @id @default(autoincrement()) + title String + published_at DateTime? +} + `; + + it('should filter records where nullable field is not null', async () => { + const db = await createTestClient(schema); + + await db.post.create({ data: { title: 'published', published_at: new Date('2025-01-01') } }); + await db.post.create({ data: { title: 'draft' } }); + + // { not: null } should return only records where the field is NOT NULL + const results = await db.post.findMany({ + where: { + published_at: { + not: null, + }, + }, + }); + + expect(results).toHaveLength(1); + expect(results[0].title).toBe('published'); + }); + + it('should also work with { not: null } on string fields', async () => { + const db = await createTestClient(` +model Item { + id Int @id @default(autoincrement()) + name String + note String? +} + `); + + await db.item.create({ data: { name: 'a', note: 'has note' } }); + await db.item.create({ data: { name: 'b' } }); + + const results = await db.item.findMany({ + where: { note: { not: null } }, + }); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('a'); + }); +});