diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index 4449e29fb..66628491c 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -33,6 +33,7 @@ import type { CreateManyArgs, DeleteArgs, DeleteManyArgs, + ExistsArgs, FindFirstArgs, FindManyArgs, FindUniqueArgs, @@ -165,6 +166,11 @@ export type ModelQueryHooks< options?: ModelSuspenseQueryOptions | null>, ): ModelSuspenseQueryResult | null>; + useExists>( + args?: Subset>, + options?: ModelQueryOptions, + ): ModelQueryResult; + useFindMany>( args?: SelectSubset>, options?: ModelQueryOptions[]>, @@ -308,6 +314,10 @@ export function useModelQueries< return useInternalSuspenseQuery(schema, modelName, 'findFirst', args, { ...rootOptions, ...options }); }, + useExists: (args: any, options?: any) => { + return useInternalQuery(schema, modelName, 'exists', args, { ...rootOptions, ...options }); + }, + useFindMany: (args: any, options?: any) => { return useInternalQuery(schema, modelName, 'findMany', args, { ...rootOptions, ...options }); }, diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index a94941c40..035dfe83b 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -34,6 +34,7 @@ import type { CreateManyArgs, DeleteArgs, DeleteManyArgs, + ExistsArgs, FindFirstArgs, FindManyArgs, FindUniqueArgs, @@ -144,6 +145,11 @@ export type ModelQueryHooks< options?: Accessor | null>>, ): ModelQueryResult | null>; + useExists>( + args?: Accessor>>, + options?: Accessor>, + ): ModelQueryResult; + useFindMany>( args?: Accessor>>, options?: Accessor[]>>, @@ -257,6 +263,10 @@ export function useModelQueries< return useInternalQuery(schema, modelName, 'findFirst', args, merge(rootOptions, options)); }, + useExists: (args: any, options?: any) => { + return useInternalQuery(schema, modelName, 'exists', args, merge(rootOptions, options)); + }, + useFindMany: (args: any, options?: any) => { return useInternalQuery(schema, modelName, 'findMany', args, merge(rootOptions, options)); }, diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index bd4dcf74b..b0c54ac80 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -32,6 +32,7 @@ import type { CreateManyArgs, DeleteArgs, DeleteManyArgs, + ExistsArgs, FindFirstArgs, FindManyArgs, FindUniqueArgs, @@ -145,6 +146,11 @@ export type ModelQueryHooks< options?: MaybeRefOrGetter | null>>, ): ModelQueryResult | null>; + useExists>( + args?: MaybeRefOrGetter>>, + options?: MaybeRefOrGetter>, + ): ModelQueryResult; + useFindMany>( args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter[]>>, @@ -258,6 +264,10 @@ export function useModelQueries< return useInternalQuery(schema, modelName, 'findFirst', args, merge(rootOptions, options)); }, + useExists: (args: any, options?: any) => { + return useInternalQuery(schema, modelName, 'exists', args, merge(rootOptions, options)); + }, + useFindMany: (args: any, options?: any) => { return useInternalQuery(schema, modelName, 'findMany', args, merge(rootOptions, options)); }, diff --git a/packages/clients/tanstack-query/test/react-typing-test.ts b/packages/clients/tanstack-query/test/react-typing-test.ts index 8f57ec670..b9f6337cc 100644 --- a/packages/clients/tanstack-query/test/react-typing-test.ts +++ b/packages/clients/tanstack-query/test/react-typing-test.ts @@ -18,6 +18,9 @@ check(client.user.useFindUnique({ where: { id: '1' }, include: { posts: true } } check(client.user.useFindFirst().data?.email); check(client.user.useFindFirst().data?.$optimistic); +check(client.user.useExists().data); +check(client.user.useExists({ where: { id: '1' } }).data); + check(client.user.useFindMany().data?.[0]?.email); check(client.user.useFindMany().data?.[0]?.$optimistic); diff --git a/packages/clients/tanstack-query/test/schemas/basic/input.ts b/packages/clients/tanstack-query/test/schemas/basic/input.ts index 766d0ca90..47638e49d 100644 --- a/packages/clients/tanstack-query/test/schemas/basic/input.ts +++ b/packages/clients/tanstack-query/test/schemas/basic/input.ts @@ -6,11 +6,12 @@ /* eslint-disable */ import { type SchemaType as $Schema } from "./schema-lite"; -import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserExistsArgs = $ExistsArgs<$Schema, "User">; export type UserCreateArgs = $CreateArgs<$Schema, "User">; export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; @@ -31,6 +32,7 @@ export type UserGetPayload; export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostExistsArgs = $ExistsArgs<$Schema, "Post">; export type PostCreateArgs = $CreateArgs<$Schema, "Post">; export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; @@ -51,6 +53,7 @@ export type PostGetPayload; export type CategoryFindUniqueArgs = $FindUniqueArgs<$Schema, "Category">; export type CategoryFindFirstArgs = $FindFirstArgs<$Schema, "Category">; +export type CategoryExistsArgs = $ExistsArgs<$Schema, "Category">; export type CategoryCreateArgs = $CreateArgs<$Schema, "Category">; export type CategoryCreateManyArgs = $CreateManyArgs<$Schema, "Category">; export type CategoryCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Category">; @@ -71,6 +74,7 @@ export type CategoryGetPayload; export type FooFindUniqueArgs = $FindUniqueArgs<$Schema, "Foo">; export type FooFindFirstArgs = $FindFirstArgs<$Schema, "Foo">; +export type FooExistsArgs = $ExistsArgs<$Schema, "Foo">; export type FooCreateArgs = $CreateArgs<$Schema, "Foo">; export type FooCreateManyArgs = $CreateManyArgs<$Schema, "Foo">; export type FooCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Foo">; @@ -91,6 +95,7 @@ export type FooGetPayload, export type BarFindManyArgs = $FindManyArgs<$Schema, "Bar">; export type BarFindUniqueArgs = $FindUniqueArgs<$Schema, "Bar">; export type BarFindFirstArgs = $FindFirstArgs<$Schema, "Bar">; +export type BarExistsArgs = $ExistsArgs<$Schema, "Bar">; export type BarCreateArgs = $CreateArgs<$Schema, "Bar">; export type BarCreateManyArgs = $CreateManyArgs<$Schema, "Bar">; export type BarCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Bar">; diff --git a/packages/clients/tanstack-query/test/svelte-typing-test.ts b/packages/clients/tanstack-query/test/svelte-typing-test.ts index 9c8788ebf..b599431d8 100644 --- a/packages/clients/tanstack-query/test/svelte-typing-test.ts +++ b/packages/clients/tanstack-query/test/svelte-typing-test.ts @@ -18,6 +18,9 @@ check(client.user.useFindUnique(() => ({ where: { id: '1' }, include: { posts: t check(client.user.useFindFirst().data?.email); check(client.user.useFindFirst().data?.$optimistic); +check(client.user.useExists().data); +check(client.user.useExists(() => ({ where: { id: '1' } })).data); + check(client.user.useFindMany().data?.[0]?.email); check(client.user.useFindMany().data?.[0]?.$optimistic); diff --git a/packages/clients/tanstack-query/test/vue-typing-test.ts b/packages/clients/tanstack-query/test/vue-typing-test.ts index f134378cb..29478f875 100644 --- a/packages/clients/tanstack-query/test/vue-typing-test.ts +++ b/packages/clients/tanstack-query/test/vue-typing-test.ts @@ -18,6 +18,9 @@ check(client.user.useFindUnique({ where: { id: '1' }, include: { posts: true } } check(client.user.useFindFirst().data.value?.email); check(client.user.useFindFirst().data.value?.$optimistic); +check(client.user.useExists().data.value); +check(client.user.useExists({ where: { id: '1' } }).data.value); + check(client.user.useFindMany().data.value?.[0]?.email); check(client.user.useFindMany().data.value?.[0]?.$optimistic); diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 8e2c8382b..7d7303148 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -27,6 +27,7 @@ import { CountOperationHandler } from './crud/operations/count'; import { CreateOperationHandler } from './crud/operations/create'; import { DeleteOperationHandler } from './crud/operations/delete'; import { FindOperationHandler } from './crud/operations/find'; +import { ExistsOperationHandler } from './crud/operations/exists'; import { GroupByOperationHandler } from './crud/operations/group-by'; import { UpdateOperationHandler } from './crud/operations/update'; import { InputValidator } from './crud/validator'; @@ -598,5 +599,15 @@ function createModelCrudHandler( true, ); }, + + exists: (args: unknown) => { + return createPromise( + 'exists', + 'exists', + args, + new ExistsOperationHandler(client, model, inputValidator), + false, + ); + }, } as ModelOperations; } diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 050381fd2..3b9bb2e33 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -24,6 +24,7 @@ import type { DefaultModelResult, DeleteArgs, DeleteManyArgs, + ExistsArgs, FindFirstArgs, FindManyArgs, FindUniqueArgs, @@ -828,6 +829,27 @@ export type AllModelOperations< groupBy>( args: Subset>, ): ZenStackPromise>>; + + /** + * Checks if an entity exists. + * @param args - exists args + * @returns whether a matching entity was found + * + * @example + * ```ts + * // check if a user exists + * await db.user.exists({ + * where: { id: 1 }, + * }); // result: `boolean` + * + * // check with a relation + * await db.user.exists({ + * where: { posts: { some: { published: true } } }, + * }); // result: `boolean` + */ + exists>( + args?: Subset>, + ): ZenStackPromise; }; export type OperationsIneligibleForDelegateModels = 'create' | 'createMany' | 'createManyAndReturn' | 'upsert'; diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 1fefdc7dd..471c43619 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1079,6 +1079,8 @@ export type FindManyArgs> = FindArgs; +export type ExistsArgs> = FilterArgs; + export type FindUniqueArgs> = { where: WhereUniqueInput; } & SelectIncludeOmit; diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 769f47f21..fd32aa723 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -69,7 +69,8 @@ export type CoreCrudOperation = | 'deleteMany' | 'count' | 'aggregate' - | 'groupBy'; + | 'groupBy' + | 'exists'; export type AllCrudOperation = CoreCrudOperation | 'findUniqueOrThrow' | 'findFirstOrThrow'; @@ -146,6 +147,32 @@ export abstract class BaseOperationHandler { }); } + protected async existsNonUnique( + kysely: ToKysely, + model: GetModels, + filter: any, + ): Promise { + const query = kysely.selectNoFrom((eb) => ( + eb.exists( + this.dialect + .buildSelectModel(model, model) + .select(sql.lit(1).as('$t')) + .where(() => this.dialect.buildFilter(model, model, filter)) + ).as('exists') + )).modifyEnd(this.makeContextComment({ model, operation: 'read' })); + + let result: { exists: number | boolean }[] = []; + const compiled = kysely.getExecutor().compileQuery(query.toOperationNode(), createQueryId()); + try { + const r = await kysely.getExecutor().executeQuery(compiled); + result = r.rows as { exists: number | boolean}[]; + } catch (err) { + throw createDBQueryError('Failed to execute query', err, compiled.sql, compiled.parameters); + } + + return !!result[0]?.exists; + } + protected async read( kysely: AnyKysely, model: string, diff --git a/packages/orm/src/client/crud/operations/exists.ts b/packages/orm/src/client/crud/operations/exists.ts new file mode 100644 index 000000000..d4c6896fc --- /dev/null +++ b/packages/orm/src/client/crud/operations/exists.ts @@ -0,0 +1,12 @@ +import type { SchemaDef } from '../../../schema'; +import { BaseOperationHandler } from './base'; + +export class ExistsOperationHandler extends BaseOperationHandler { + async handle(_operation: 'exists', args: unknown): Promise { + // normalize args to strip `undefined` fields + const normalizedArgs = this.normalizeArgs(args); + const parsedArgs = this.inputValidator.validateExistsArgs(this.model, normalizedArgs); + + return await this.existsNonUnique(this.client.$qb, this.model, parsedArgs?.where); + } +} diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index dc1a6f836..29f6bd028 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -25,6 +25,7 @@ import { type CreateManyArgs, type DeleteArgs, type DeleteManyArgs, + type ExistsArgs, type FindArgs, type GroupByArgs, type UpdateArgs, @@ -81,6 +82,19 @@ export class InputValidator { >(model, 'find', options, (model, options) => this.makeFindSchema(model, options), args); } + validateExistsArgs( + model: GetModels, + args: unknown, + ): ExistsArgs> | undefined { + return this.validate>>( + model, + 'exists', + undefined, + (model) => this.makeExistsSchema(model), + args, + ); + } + validateCreateArgs(model: GetModels, args: unknown): CreateArgs> { return this.validate>>( model, @@ -297,6 +311,12 @@ export class InputValidator { return result; } + private makeExistsSchema(model: string) { + return z.strictObject({ + where: this.makeWhereSchema(model, false).optional(), + }).optional(); + } + private makeScalarSchema(type: string, attributes?: readonly AttributeApplication[]) { if (this.schema.typeDefs && type in this.schema.typeDefs) { return this.makeTypeDefSchema(type); diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index f68bb0bc6..c24436952 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -1539,6 +1539,7 @@ export class TsSchemaGenerator { 'FindManyArgs', 'FindUniqueArgs', 'FindFirstArgs', + 'ExistsArgs', 'CreateArgs', 'CreateManyArgs', 'CreateManyAndReturnArgs', diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index e821366fd..7ede37f8e 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -73,6 +73,7 @@ export class RPCApiHandler implements ApiH case 'aggregate': case 'groupBy': case 'count': + case 'exists': if (method !== 'GET') { return this.makeBadInputErrorResponse('invalid request method, only GET is supported'); } diff --git a/packages/server/test/api/rpc.test.ts b/packages/server/test/api/rpc.test.ts index 4329e857e..77cc44302 100644 --- a/packages/server/test/api/rpc.test.ts +++ b/packages/server/test/api/rpc.test.ts @@ -27,6 +27,15 @@ describe('RPC API Handler Tests', () => { expect(r.status).toBe(200); expect(r.data).toHaveLength(0); + r = await handleRequest({ + method: 'get', + path: '/user/exists', + query: { q: JSON.stringify({ where: { id: 'user1' }})}, + client: rawClient, + }); + expect(r.status).toBe(200); + expect(r.data).toBe(false); + r = await handleRequest({ method: 'post', path: '/user/create', @@ -57,6 +66,15 @@ describe('RPC API Handler Tests', () => { }), ); + r = await handleRequest({ + method: 'get', + path: '/user/exists', + query: { q: JSON.stringify({ where: { id: 'user1' }})}, + client: rawClient, + }); + expect(r.status).toBe(200); + expect(r.data).toBe(true); + r = await handleRequest({ method: 'get', path: '/post/findMany', diff --git a/tests/e2e/orm/client-api/exists.test.ts b/tests/e2e/orm/client-api/exists.test.ts new file mode 100644 index 000000000..42e4e8c19 --- /dev/null +++ b/tests/e2e/orm/client-api/exists.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { ClientContract } from '@zenstackhq/orm'; +import { schema } from '../schemas/basic'; +import { createTestClient } from '@zenstackhq/testtools'; + +describe('Client exists tests', () => { + let client: ClientContract; + + beforeEach(async () => { + client = await createTestClient(schema); + }); + + afterEach(async () => { + await client?.$disconnect(); + }); + + it('works with no args', async () => { + await expect(client.user.exists()).resolves.toBe(false); + + await client.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await expect(client.user.exists()).resolves.toBe(true); + }); + + it('works with empty args', async () => { + await expect(client.user.exists({})).resolves.toBe(false); + + await client.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await expect(client.user.exists({})).resolves.toBe(true); + }); + + it('works with empty where', async () => { + await expect(client.user.exists({ where: {} })).resolves.toBe(false); + + await client.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await expect(client.user.exists({ where: {} })).resolves.toBe(true); + }); + + it('works with toplevel', async () => { + await client.user.create({ + data: { + email: 'test@email.com', + }, + }); + + await expect(client.user.exists({ + where: { + email: 'test@email.com', + }, + })).resolves.toBe(true); + + await expect(client.user.exists({ + where: { + email: 'wrong@email.com', + }, + })).resolves.toBe(false); + }); + + it('works with nested', async () => { + await client.user.create({ + data: { + email: 'test@email.com', + posts: { + create: { + title: 'Test title', + }, + }, + }, + }); + + await expect(client.user.exists({ + where: { + posts: { + some: { + title: 'Test title', + }, + }, + }, + })).resolves.toBe(true); + + await expect(client.user.exists({ + where: { + posts: { + some: { + title: 'Wrong test title', + }, + }, + }, + })).resolves.toBe(false); + + await expect(client.post.exists({ + where: { + title: 'Test title', + } + })).resolves.toBe(true); + + await expect(client.post.exists({ + where: { + title: 'Wrong test title', + } + })).resolves.toBe(false); + }); + + it('works with deeply nested', async () => { + await client.user.create({ + data: { + email: 'test@email.com', + posts: { + create: { + title: 'Test title', + comments: { + create: { + content: 'Test content', + }, + }, + }, + }, + }, + }); + + await expect(client.user.exists({ + where: { + posts: { + some: { + title: 'Test title', + comments: { + some: { + content: 'Test content', + }, + }, + }, + }, + }, + })).resolves.toBe(true); + + await expect(client.user.exists({ + where: { + posts: { + some: { + title: 'Test title', + comments: { + some: { + content: 'Wrong test content', + }, + }, + }, + }, + }, + })).resolves.toBe(false); + + await expect(client.user.exists({ + where: { + posts: { + some: { + title: 'Wrong test title', + comments: { + some: { + content: 'Test content', + }, + }, + }, + }, + }, + })).resolves.toBe(false); + }); +}); \ No newline at end of file