diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 1fefdc7d..da70be0d 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -441,6 +441,11 @@ type CommonPrimitiveFilter< */ gte?: DataType; + /** + * Checks if the value is between the specified values (inclusive). + */ + between?: [start: DataType, end: DataType]; + /** * Builds a negated filter. */ diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 30e330a4..45cf1fff 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -765,6 +765,12 @@ export abstract class BaseCrudDialect { .with('lte', () => this.eb(lhs, '<=', rhs)) .with('gt', () => this.eb(lhs, '>', rhs)) .with('gte', () => this.eb(lhs, '>=', rhs)) + .with('between', () => { + invariant(Array.isArray(rhs), 'right hand side must be an array'); + invariant(rhs.length === 2, 'right hand side must have a length of 2'); + const [start, end] = rhs; + return this.eb.and([this.eb(lhs, '>=', start), this.eb(lhs, '<=', end)]); + }) .with('not', () => this.eb.not(recurse(value))) // aggregations .with(P.union(...AGGREGATE_OPERATORS), (op) => { diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index dc1a6f83..8ae9744c 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -745,6 +745,7 @@ export class InputValidator { lte: baseSchema.optional(), gt: baseSchema.optional(), gte: baseSchema.optional(), + between: baseSchema.array().length(2).optional(), not: makeThis().optional(), ...(withAggregations?.includes('_count') ? { _count: this.makeNumberFilterSchema(z.number().int(), false, false).optional() } diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index ed270cb1..68d0239e 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -96,6 +96,7 @@ const FilterOperations = [ 'lte', 'gt', 'gte', + 'between', 'contains', 'icontains', 'search', @@ -1939,6 +1940,15 @@ export class RestApiHandler implements Api } } } else { + if (op === 'between') { + const parts = value + .split(',') + .map((v) => this.coerce(fieldDef, v)); + if (parts.length !== 2) { + throw new InvalidValueError(`"between" expects exactly 2 comma-separated values`); + } + return { between: [parts[0]!, parts[1]!] }; + } const coerced = this.coerce(fieldDef, value); switch (op) { case 'icontains': diff --git a/packages/server/test/api/rest.test.ts b/packages/server/test/api/rest.test.ts index b40ff604..7540caf6 100644 --- a/packages/server/test/api/rest.test.ts +++ b/packages/server/test/api/rest.test.ts @@ -420,6 +420,8 @@ describe('REST server tests', () => { }); it('toplevel filtering', async () => { + const now = new Date(); + const past = new Date(now.getTime() - 1); await client.user.create({ data: { myId: 'user1', @@ -436,7 +438,7 @@ describe('REST server tests', () => { myId: 'user2', email: 'user2@abc.com', posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + create: { id: 2, title: 'Post2', viewCount: 1, published: true, publishedAt: now }, }, }, }); @@ -523,6 +525,38 @@ describe('REST server tests', () => { }); expect(r.body.data).toHaveLength(0); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$between]']: ',user1@abc.com' }, + client, + }); + expect(r.body.data).toHaveLength(1); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$between]']: 'user1@abc.com,' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$between]']: ',user2@abc.com' }, + client, + }); + expect(r.body.data).toHaveLength(2); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$between]']: 'user1@abc.com,user2@abc.com' }, + client, + }); + expect(r.body.data).toHaveLength(2); + // Int filter r = await handler({ method: 'get', @@ -568,6 +602,58 @@ describe('REST server tests', () => { expect(r.body.data).toHaveLength(1); expect(r.body.data[0]).toMatchObject({ id: 1 }); + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$between]']: '1,2' }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$between]']: '2,1' }, + client, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$between]']: '0,2' }, + client, + }); + expect(r.body.data).toHaveLength(2); + + // DateTime filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[publishedAt$between]']: `${now.toISOString()},${now.toISOString()}` }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[publishedAt$between]']: `${past.toISOString()},${now.toISOString()}` }, + client, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[publishedAt$between]']: `${now.toISOString()},${past.toISOString()}` }, + client, + }); + expect(r.body.data).toHaveLength(0); + // Boolean filter r = await handler({ method: 'get', diff --git a/tests/e2e/orm/client-api/filter.test.ts b/tests/e2e/orm/client-api/filter.test.ts index f7774291..d4594ab7 100644 --- a/tests/e2e/orm/client-api/filter.test.ts +++ b/tests/e2e/orm/client-api/filter.test.ts @@ -315,6 +315,58 @@ describe('Client filter tests ', () => { }), ).toResolveWithLength(2); + // between + await expect( + client.user.findMany({ + where: { email: { between: ['a@test.com', 'a@test.com'] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { email: { between: ['a@test.com', 'b@test.com'] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { email: { between: ['z@test.com', 'a@test.com'] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { email: { between: ['u1@test.com', 'u1@test.com'] } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findMany({ + where: { email: { between: ['u2@test.com', 'u2@test.com'] } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findMany({ + where: { email: { between: ['u1@test.com', 'u2@test.com'] } }, + }), + ).toResolveWithLength(2); + await expect( + client.user.findMany({ + where: { email: { between: ['u2@test.com', 'u3%@test.com'] } }, + }), + ).toResolveWithLength(2); + await expect( + client.user.findMany({ + where: { email: { between: ['a@test.com', 'u3%@test.com'] } }, + }), + ).toResolveWithLength(3); + await expect( + client.user.findMany({ + where: { email: { between: ['a@test.com', 'z@test.com'] } }, + }), + ).toResolveWithLength(3); + await expect( + client.user.findMany({ + where: { email: { between: ['u1@test.com', 'u3%@test.com'] } }, + }), + ).toResolveWithLength(3); + // contains await expect( client.user.findFirst({ @@ -409,6 +461,14 @@ describe('Client filter tests ', () => { await expect(client.profile.findMany({ where: { age: { gte: 20 } } })).toResolveWithLength(1); await expect(client.profile.findMany({ where: { age: { gte: 21 } } })).toResolveWithLength(0); + // between + await expect(client.profile.findMany({ where: { age: { between: [20, 20] } } })).toResolveWithLength(1); + await expect(client.profile.findMany({ where: { age: { between: [19, 20] } } })).toResolveWithLength(1); + await expect(client.profile.findMany({ where: { age: { between: [20, 21] } } })).toResolveWithLength(1); + await expect(client.profile.findMany({ where: { age: { between: [19, 19] } } })).toResolveWithLength(0); + await expect(client.profile.findMany({ where: { age: { between: [21, 21] } } })).toResolveWithLength(0); + await expect(client.profile.findMany({ where: { age: { between: [21, 20] } } })).toResolveWithLength(0); + // not await expect( client.profile.findFirst({ @@ -460,11 +520,14 @@ describe('Client filter tests ', () => { }); it('supports date filters', async () => { + const now = new Date(); + const past = new Date(now.getTime() - 1); + const future = new Date(now.getTime() + 2); const user1 = await createUser('u1@test.com', { - createdAt: new Date(), + createdAt: now, }); const user2 = await createUser('u2@test.com', { - createdAt: new Date(Date.now() + 1000), + createdAt: new Date(now.getTime() + 1), }); // equals @@ -577,6 +640,73 @@ describe('Client filter tests ', () => { }), ).resolves.toMatchObject(user2); + // between + await expect( + client.user.findMany({ + where: { createdAt: { between: [user1.createdAt, user1.createdAt] } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findMany({ + where: { createdAt: { between: [user1.createdAt, user2.createdAt] } }, + }), + ).toResolveWithLength(2); + await expect( + client.user.findMany({ + where: { createdAt: { between: [user2.createdAt, user2.createdAt] } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findMany({ + where: { createdAt: { between: [user2.createdAt, user1.createdAt] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { createdAt: { between: [past, past] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { createdAt: { between: [past, user1.createdAt] } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findMany({ + where: { createdAt: { between: [past.toISOString(), user1.createdAt] } }, + }), + ).toResolveWithLength(1); + await expect( + client.user.findMany({ + where: { createdAt: { between: [past, user2.createdAt] } }, + }), + ).toResolveWithLength(2); + await expect( + client.user.findMany({ + where: { createdAt: { between: [past, future] } }, + }), + ).toResolveWithLength(2); + await expect( + client.user.findMany({ + where: { createdAt: { between: [past.toISOString(), future.toISOString()] } }, + }), + ).toResolveWithLength(2); + await expect( + client.user.findMany({ + where: { createdAt: { between: [future, past] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { createdAt: { between: [future, user1.createdAt] } }, + }), + ).toResolveWithLength(0); + await expect( + client.user.findMany({ + where: { createdAt: { between: [future, future] } }, + }), + ).toResolveWithLength(0); + // not await expect( client.user.findFirst({