diff --git a/packages/auth-adapters/better-auth/src/adapter.ts b/packages/auth-adapters/better-auth/src/adapter.ts index 935f5fde3..5e1138f63 100644 --- a/packages/auth-adapters/better-auth/src/adapter.ts +++ b/packages/auth-adapters/better-auth/src/adapter.ts @@ -171,7 +171,7 @@ export const zenstackAdapter = (db: ClientContract>, + data: update as UpdateInput, any>, }); }, diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index 2e61d286d..f745ceeb4 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -1,6 +1,12 @@ import type { Logger, OptimisticDataProvider } from '@zenstackhq/client-helpers'; import type { FetchFn } from '@zenstackhq/client-helpers/fetch'; -import type { GetProcedureNames, OperationsIneligibleForDelegateModels, ProcedureFunc } from '@zenstackhq/orm'; +import type { + GetProcedureNames, + GetSlicedOperations, + OperationsIneligibleForDelegateModels, + ProcedureFunc, + QueryOptions, +} from '@zenstackhq/orm'; import type { GetModels, IsDelegateModel, SchemaDef } from '@zenstackhq/schema'; /** @@ -57,14 +63,27 @@ type HooksOperationsIneligibleForDelegateModels = OperationsIneligibleForDelegat ? `use${Capitalize}` : never; +type Modifiers = '' | 'Suspense' | 'Infinite' | 'SuspenseInfinite'; + /** - * Trim operations that are ineligible for delegate models from the given model operations type. + * Trim CRUD operation hooks to include only eligible operations. */ -export type TrimDelegateModelOperations< +export type TrimSlicedOperations< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions, T extends Record, -> = IsDelegateModel extends true ? Omit : T; +> = { + // trim operations based on slicing options + [Key in keyof T as Key extends `use${Modifiers}${Capitalize>}` + ? IsDelegateModel extends true + ? // trim operations ineligible for delegate models + Key extends HooksOperationsIneligibleForDelegateModels + ? never + : Key + : Key + : never]: T[Key]; +}; type WithOptimisticFlag = T extends object ? T & { diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index f467f116a..5129750bf 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -39,6 +39,8 @@ import type { FindUniqueArgs, GetProcedure, GetProcedureNames, + GetSlicedModels, + GetSlicedProcedures, GroupByArgs, GroupByResult, ProcedureEnvelope, @@ -62,7 +64,7 @@ import type { ExtraQueryOptions, ProcedureReturn, QueryContext, - TrimDelegateModelOperations, + TrimSlicedOperations, WithOptimistic, } from './common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -148,11 +150,11 @@ export type ModelMutationModelResult< }; export type ClientHooks = QueryOptions> = { - [Model in GetModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; +} & ProcedureHooks; -type ProcedureHookGroup = { - [Name in GetProcedureNames]: GetProcedure extends { mutation: true } +type ProcedureHookGroup> = { + [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } ? { useMutation( options?: Omit< @@ -195,14 +197,15 @@ type ProcedureHookGroup = { }; }; -export type ProcedureHooks = Schema['procedures'] extends Record - ? { - /** - * Custom procedures. - */ - $procs: ProcedureHookGroup; - } - : Record; +export type ProcedureHooks> = + Schema['procedures'] extends Record + ? { + /** + * Custom procedures. + */ + $procs: ProcedureHookGroup; + } + : Record; // Note that we can potentially use TypeScript's mapped type to directly map from ORM contract, but that seems // to significantly slow down tsc performance ... @@ -210,56 +213,57 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimDelegateModelOperations< +> = TrimSlicedOperations< Schema, Model, + Options, { - useFindUnique>( - args: SelectSubset>, + useFindUnique>( + args: SelectSubset>, options?: ModelQueryOptions | null>, ): ModelQueryResult | null>; - useSuspenseFindUnique>( - args: SelectSubset>, + useSuspenseFindUnique>( + args: SelectSubset>, options?: ModelSuspenseQueryOptions | null>, ): ModelSuspenseQueryResult | null>; - useFindFirst>( - args?: SelectSubset>, + useFindFirst>( + args?: SelectSubset>, options?: ModelQueryOptions | null>, ): ModelQueryResult | null>; - useSuspenseFindFirst>( - args?: SelectSubset>, + useSuspenseFindFirst>( + args?: SelectSubset>, options?: ModelSuspenseQueryOptions | null>, ): ModelSuspenseQueryResult | null>; - useExists>( - args?: Subset>, + useExists>( + args?: Subset>, options?: ModelQueryOptions, ): ModelQueryResult; - useFindMany>( - args?: SelectSubset>, + useFindMany>( + args?: SelectSubset>, options?: ModelQueryOptions[]>, ): ModelQueryResult[]>; - useSuspenseFindMany>( - args?: SelectSubset>, + useSuspenseFindMany>( + args?: SelectSubset>, options?: ModelSuspenseQueryOptions[]>, ): ModelSuspenseQueryResult[]>; - useInfiniteFindMany>( - args?: SelectSubset>, + useInfiniteFindMany>( + args?: SelectSubset>, options?: ModelInfiniteQueryOptions[]>, ): ModelInfiniteQueryResult[]>>; - useSuspenseInfiniteFindMany>( - args?: SelectSubset>, + useSuspenseInfiniteFindMany>( + args?: SelectSubset>, options?: ModelSuspenseInfiniteQueryOptions[]>, ): ModelSuspenseInfiniteQueryResult[]>>; - useCreate>( + useCreate>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; @@ -267,61 +271,61 @@ export type ModelQueryHooks< options?: ModelMutationOptions, ): ModelMutationResult; - useCreateManyAndReturn>( + useCreateManyAndReturn>( options?: ModelMutationOptions[], T>, ): ModelMutationModelResult; - useUpdate>( + useUpdate>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useUpdateMany>( + useUpdateMany>( options?: ModelMutationOptions, ): ModelMutationResult; - useUpdateManyAndReturn>( + useUpdateManyAndReturn>( options?: ModelMutationOptions[], T>, ): ModelMutationModelResult; - useUpsert>( + useUpsert>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useDelete>( + useDelete>( options?: ModelMutationOptions, T>, ): ModelMutationModelResult; - useDeleteMany>( + useDeleteMany>( options?: ModelMutationOptions, ): ModelMutationResult; - useCount>( - args?: Subset>, + useCount>( + args?: Subset>, options?: ModelQueryOptions>, ): ModelQueryResult>; - useSuspenseCount>( - args?: Subset>, + useSuspenseCount>( + args?: Subset>, options?: ModelSuspenseQueryOptions>, ): ModelSuspenseQueryResult>; - useAggregate>( - args: Subset>, + useAggregate>( + args: Subset>, options?: ModelQueryOptions>, ): ModelQueryResult>; - useSuspenseAggregate>( - args: Subset>, + useSuspenseAggregate>( + args: Subset>, options?: ModelSuspenseQueryOptions>, ): ModelSuspenseQueryResult>; - useGroupBy>( - args: Subset>, + useGroupBy>( + args: Subset>, options?: ModelQueryOptions>, ): ModelQueryResult>; - useSuspenseGroupBy>( - args: Subset>, + useSuspenseGroupBy>( + args: Subset>, options?: ModelSuspenseQueryOptions>, ): ModelSuspenseQueryResult>; } diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index ed743baf2..45af47cb3 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -40,6 +40,8 @@ import type { FindUniqueArgs, GetProcedure, GetProcedureNames, + GetSlicedModels, + GetSlicedProcedures, GroupByArgs, GroupByResult, ProcedureEnvelope, @@ -63,7 +65,7 @@ import type { ExtraQueryOptions, ProcedureReturn, QueryContext, - TrimDelegateModelOperations, + TrimSlicedOperations, WithOptimistic, } from '../common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -145,11 +147,11 @@ export type ModelMutationModelResult< }; export type ClientHooks = QueryOptions> = { - [Model in GetModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; +} & ProcedureHooks; -type ProcedureHookGroup = { - [Name in GetProcedureNames]: GetProcedure extends { mutation: true } +type ProcedureHookGroup> = { + [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } ? { useMutation( options?: Omit< @@ -182,14 +184,15 @@ type ProcedureHookGroup = { }; }; -export type ProcedureHooks = Schema['procedures'] extends Record - ? { - /** - * Custom procedures. - */ - $procs: ProcedureHookGroup; - } - : Record; +export type ProcedureHooks> = + Schema['procedures'] extends Record + ? { + /** + * Custom procedures. + */ + $procs: ProcedureHookGroup; + } + : Record; // Note that we can potentially use TypeScript's mapped type to directly map from ORM contract, but that seems // to significantly slow down tsc performance ... @@ -197,36 +200,37 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimDelegateModelOperations< +> = TrimSlicedOperations< Schema, Model, + Options, { - useFindUnique>( - args: Accessor>>, + useFindUnique>( + args: Accessor>>, options?: Accessor | null>>, ): ModelQueryResult | null>; - useFindFirst>( - args?: Accessor>>, + useFindFirst>( + args?: Accessor>>, options?: Accessor | null>>, ): ModelQueryResult | null>; - useExists>( - args?: Accessor>>, + useExists>( + args?: Accessor>>, options?: Accessor>, ): ModelQueryResult; - useFindMany>( - args?: Accessor>>, + useFindMany>( + args?: Accessor>>, options?: Accessor[]>>, ): ModelQueryResult[]>; - useInfiniteFindMany>( - args?: Accessor>>, + useInfiniteFindMany>( + args?: Accessor>>, options?: Accessor[]>>, ): ModelInfiniteQueryResult[]>>; - useCreate>( + useCreate>( options?: Accessor, T>>, ): ModelMutationModelResult; @@ -234,44 +238,44 @@ export type ModelQueryHooks< options?: Accessor>, ): ModelMutationResult; - useCreateManyAndReturn>( + useCreateManyAndReturn>( options?: Accessor[], T>>, ): ModelMutationModelResult; - useUpdate>( + useUpdate>( options?: Accessor, T>>, ): ModelMutationModelResult; - useUpdateMany>( + useUpdateMany>( options?: Accessor>, ): ModelMutationResult; - useUpdateManyAndReturn>( + useUpdateManyAndReturn>( options?: Accessor[], T>>, ): ModelMutationModelResult; - useUpsert>( + useUpsert>( options?: Accessor, T>>, ): ModelMutationModelResult; - useDelete>( + useDelete>( options?: Accessor, T>>, ): ModelMutationModelResult; - useDeleteMany>( + useDeleteMany>( options?: Accessor>, ): ModelMutationResult; - useCount>( - args?: Accessor>>, + useCount>( + args?: Accessor>>, options?: Accessor>>, ): ModelQueryResult>; - useAggregate>( - args: Accessor>>, + useAggregate>( + args: Accessor>>, options?: Accessor>>, ): ModelQueryResult>; - useGroupBy>( - args: Accessor>>, + useGroupBy>( + args: Accessor>>, options?: Accessor>>, ): ModelQueryResult>; } diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index ab8821a0f..b45a28333 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -38,6 +38,8 @@ import type { FindUniqueArgs, GetProcedure, GetProcedureNames, + GetSlicedModels, + GetSlicedProcedures, GroupByArgs, GroupByResult, ProcedureEnvelope, @@ -61,7 +63,7 @@ import type { ExtraQueryOptions, ProcedureReturn, QueryContext, - TrimDelegateModelOperations, + TrimSlicedOperations, WithOptimistic, } from './common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -138,11 +140,11 @@ export type ModelMutationModelResult< }; export type ClientHooks = QueryOptions> = { - [Model in GetModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; +} & ProcedureHooks; -type ProcedureHookGroup = { - [Name in GetProcedureNames]: GetProcedure extends { mutation: true } +type ProcedureHookGroup> = { + [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } ? { useMutation( options?: MaybeRefOrGetter< @@ -184,14 +186,15 @@ type ProcedureHookGroup = { }; }; -export type ProcedureHooks = Schema['procedures'] extends Record - ? { - /** - * Custom procedures. - */ - $procs: ProcedureHookGroup; - } - : Record; +export type ProcedureHooks> = + Schema['procedures'] extends Record + ? { + /** + * Custom procedures. + */ + $procs: ProcedureHookGroup; + } + : Record; // Note that we can potentially use TypeScript's mapped type to directly map from ORM contract, but that seems // to significantly slow down tsc performance ... @@ -199,36 +202,37 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimDelegateModelOperations< +> = TrimSlicedOperations< Schema, Model, + Options, { - useFindUnique>( - args: MaybeRefOrGetter>>, + useFindUnique>( + args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter | null>>, ): ModelQueryResult | null>; - useFindFirst>( - args?: MaybeRefOrGetter>>, + useFindFirst>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter | null>>, ): ModelQueryResult | null>; - useExists>( - args?: MaybeRefOrGetter>>, + useExists>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>, ): ModelQueryResult; - useFindMany>( - args?: MaybeRefOrGetter>>, + useFindMany>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter[]>>, ): ModelQueryResult[]>; - useInfiniteFindMany>( - args?: MaybeRefOrGetter>>, + useInfiniteFindMany>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter[]>>, ): ModelInfiniteQueryResult[]>>; - useCreate>( + useCreate>( options?: MaybeRefOrGetter, T>>, ): ModelMutationModelResult; @@ -236,46 +240,46 @@ export type ModelQueryHooks< options?: MaybeRefOrGetter>, ): ModelMutationResult; - useCreateManyAndReturn>( + useCreateManyAndReturn>( options?: MaybeRefOrGetter[], T>>, ): ModelMutationModelResult; - useUpdate>( + useUpdate>( options?: MaybeRefOrGetter, T>>, ): ModelMutationModelResult; - useUpdateMany>( + useUpdateMany>( options?: MaybeRefOrGetter>, ): ModelMutationResult; - useUpdateManyAndReturn>( + useUpdateManyAndReturn>( options?: MaybeRefOrGetter[], T>>, ): ModelMutationModelResult; - useUpsert>( + useUpsert>( options?: MaybeRefOrGetter, T>>, ): ModelMutationModelResult; - useDelete>( + useDelete>( options?: MaybeRefOrGetter, T>>, ): ModelMutationModelResult; - useDeleteMany>( + useDeleteMany>( options?: MaybeRefOrGetter>, ): ModelMutationResult; - useCount>( - args?: MaybeRefOrGetter>>, + useCount>( + args?: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>>, ): ModelQueryResult>; - useAggregate>( - args: MaybeRefOrGetter>>, + useAggregate>( + args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>>, ): ModelQueryResult>; - useGroupBy>( - args: MaybeRefOrGetter>>, + useGroupBy>( + args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter>>, ): ModelQueryResult>; } diff --git a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts new file mode 100644 index 000000000..8b31551d7 --- /dev/null +++ b/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts @@ -0,0 +1,93 @@ +import { ZenStackClient, type GetQueryOptions } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useClientQueries } from '../src/react'; +import { schema } from './schemas/basic/schema-lite'; +import { schema as procSchema } from './schemas/procedures/schema-lite'; + +describe('React client sliced client test', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + slicing: { + includedModels: ['User', 'Post'], + models: { + user: { + includedOperations: ['findUnique', 'findMany', 'update'], + excludedOperations: ['update'], + }, + }, + }, + omit: {}, + }); + + it('works with sliced models', () => { + const client = useClientQueries>(schema); + + expectTypeOf(client).toHaveProperty('user'); + expectTypeOf(client).toHaveProperty('post'); + expectTypeOf(client).not.toHaveProperty('category'); + }); + + it('works with sliced operations', () => { + const client = useClientQueries< + typeof schema, + { + slicing: { + models: { + user: { + includedOperations: ['findUnique', 'findMany', 'update']; + }; + }; + }; + } + >(schema); + + expectTypeOf(client.user).toHaveProperty('useFindUnique'); + expectTypeOf(client.user).toHaveProperty('useFindMany'); + expectTypeOf(client.user).toHaveProperty('useUpdate'); + expectTypeOf(client.user).not.toHaveProperty('useFindFirst'); + }); + + it('works with sliced filters', () => { + const client = useClientQueries< + typeof schema, + { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality']; + }; + }; + }; + }; + }; + } + >(schema); + + // Equality filter should be allowed + client.user.useFindMany({ + where: { name: { equals: 'test' } }, + }); + + // 'Like' filter kind should not be available + // @ts-expect-error - 'contains' is not allowed when only 'Equality' filter kind is included + client.user.useFindMany({ where: { name: { contains: 'test' } } }); + }); + + it('works with sliced procedures', () => { + const client = useClientQueries< + typeof procSchema, + { + slicing: { + includedProcedures: ['greet', 'sum']; + excludedProcedures: ['sum']; + }; + } + >(procSchema); + + expectTypeOf(client.$procs).toHaveProperty('greet'); + expectTypeOf(client.$procs).not.toHaveProperty('sum'); + expectTypeOf(client.$procs).not.toHaveProperty('greetMany'); + }); +}); diff --git a/packages/clients/tanstack-query/test/react-typing-test.ts b/packages/clients/tanstack-query/test/react-typing-test.ts deleted file mode 100644 index b99a31217..000000000 --- a/packages/clients/tanstack-query/test/react-typing-test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { useClientQueries } from '../src/react'; -import { schema } from './schemas/basic/schema-lite'; -import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; - -const client = useClientQueries(schema); -const proceduresClient = useClientQueries(proceduresSchema); - -// @ts-expect-error missing args -client.user.useFindUnique(); - -check(client.user.useFindUnique({ where: { id: '1' } }).data?.email); -check(client.user.useFindUnique({ where: { id: '1' } }).queryKey); -check(client.user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true, enabled: false })); - -// @ts-expect-error unselected field -check(client.user.useFindUnique({ select: { email: true } }).data.name); - -check(client.user.useFindUnique({ where: { id: '1' }, include: { posts: true } }).data?.posts[0]?.title); - -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); - -check(client.user.useInfiniteFindMany().data?.pages[0]?.[0]?.email); -check( - client.user.useInfiniteFindMany( - {}, - { - getNextPageParam: () => ({ id: '2' }), - }, - ).data?.pages[1]?.[0]?.email, -); -// @ts-expect-error -check(client.user.useInfiniteFindMany().data?.pages[0]?.[0]?.$optimistic); - -check(client.user.useSuspenseFindMany().data[0]?.email); -check(client.user.useSuspenseInfiniteFindMany().data.pages[0]?.[0]?.email); -check(client.user.useCount().data?.toFixed(2)); -check(client.user.useCount({ select: { email: true } }).data?.email.toFixed(2)); - -check(client.user.useAggregate({ _max: { email: true } }).data?._max.email); - -check(client.user.useGroupBy({ by: ['email'], _max: { name: true } }).data?.[0]?._max.name); - -// @ts-expect-error missing args -client.user.useCreate().mutate(); -client.user.useCreate().mutate({ data: { email: 'test@example.com' } }); -client.user - .useCreate({ optimisticUpdate: true, invalidateQueries: false, retry: 3 }) - .mutate({ data: { email: 'test@example.com' } }); - -client.user - .useCreate() - .mutateAsync({ data: { email: 'test@example.com' }, include: { posts: true } }) - .then((d) => check(d.posts[0]?.title)); - -client.user - .useCreateMany() - .mutateAsync({ - data: [{ email: 'test@example.com' }, { email: 'test2@example.com' }], - skipDuplicates: true, - }) - .then((d) => d.count); - -client.user - .useCreateManyAndReturn() - .mutateAsync({ - data: [{ email: 'test@example.com' }], - }) - .then((d) => check(d[0]?.name)); - -client.user - .useCreateManyAndReturn() - .mutateAsync({ - data: [{ email: 'test@example.com' }], - select: { email: true }, - }) - // @ts-expect-error unselected field - .then((d) => check(d[0].name)); - -client.user.useUpdate().mutate( - { data: { email: 'updated@example.com' }, where: { id: '1' } }, - { - onSuccess: (d) => { - check(d.email); - }, - }, -); - -client.user.useUpdateMany().mutate({ data: { email: 'updated@example.com' } }); - -client.user - .useUpdateManyAndReturn() - .mutateAsync({ data: { email: 'updated@example.com' } }) - .then((d) => check(d[0]?.email)); - -client.user - .useUpsert() - .mutate({ where: { id: '1' }, create: { email: 'new@example.com' }, update: { email: 'updated@example.com' } }); - -client.user.useDelete().mutate({ where: { id: '1' }, include: { posts: true } }); - -client.user.useDeleteMany().mutate({ where: { email: 'test@example.com' } }); - -function check(_value: unknown) { - // noop -} - -// @ts-expect-error delegate model -client.foo.useCreate(); - -client.foo.useUpdate(); -client.bar.useCreate(); - -// procedures (query) -check(proceduresClient.$procs.greet.useQuery().data?.toUpperCase()); -check(proceduresClient.$procs.greet.useQuery({ args: { name: 'bob' } }).data?.toUpperCase()); -check(proceduresClient.$procs.greet.useQuery({ args: { name: 'bob' } }, { enabled: true }).queryKey); -// @ts-expect-error wrong arg shape -proceduresClient.$procs.greet.useQuery({ args: { hello: 'world' } }); - -// Infinite queries for procedures are currently disabled, will add back later if needed -// check(proceduresClient.$procs.greetMany.useInfiniteQuery({ args: { name: 'bob' } }).data?.pages[0]?.[0]?.toUpperCase()); -// check(proceduresClient.$procs.greetMany.useInfiniteQuery({ args: { name: 'bob' } }).queryKey); - -// @ts-expect-error missing args -proceduresClient.$procs.greetMany.useQuery(); -// @ts-expect-error greet is not a mutation procedure -proceduresClient.$procs.greet.useMutation(); - -// procedures (mutation) -proceduresClient.$procs.sum.useMutation().mutate({ args: { a: 1, b: 2 } }); -// @ts-expect-error wrong arg shape for multi-param procedure -proceduresClient.$procs.sum.useMutation().mutate([1, 2]); -proceduresClient.$procs.sum - .useMutation() - .mutateAsync({ args: { a: 1, b: 2 } }) - .then((d) => check(d.toFixed(2))); diff --git a/packages/clients/tanstack-query/test/react-typing.test-d.ts b/packages/clients/tanstack-query/test/react-typing.test-d.ts new file mode 100644 index 000000000..3dbcd8446 --- /dev/null +++ b/packages/clients/tanstack-query/test/react-typing.test-d.ts @@ -0,0 +1,153 @@ +import { describe, it } from 'vitest'; +import { useClientQueries } from '../src/react'; +import { schema } from './schemas/basic/schema-lite'; +import { schema as proceduresSchema } from './schemas/procedures/schema-lite'; + +describe('React client typing test', () => { + it('types model queries correctly', () => { + const client = useClientQueries(schema); + + // @ts-expect-error missing args + client.user.useFindUnique(); + + check(client.user.useFindUnique({ where: { id: '1' } }).data?.email); + check(client.user.useFindUnique({ where: { id: '1' } }).queryKey); + check(client.user.useFindUnique({ where: { id: '1' } }, { optimisticUpdate: true, enabled: false })); + + // @ts-expect-error unselected field + check(client.user.useFindUnique({ select: { email: true } }).data.name); + + check(client.user.useFindUnique({ where: { id: '1' }, include: { posts: true } }).data?.posts[0]?.title); + + 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); + + check(client.user.useInfiniteFindMany().data?.pages[0]?.[0]?.email); + check( + client.user.useInfiniteFindMany( + {}, + { + getNextPageParam: () => ({ id: '2' }), + }, + ).data?.pages[1]?.[0]?.email, + ); + // @ts-expect-error + check(client.user.useInfiniteFindMany().data?.pages[0]?.[0]?.$optimistic); + + check(client.user.useSuspenseFindMany().data[0]?.email); + check(client.user.useSuspenseInfiniteFindMany().data.pages[0]?.[0]?.email); + check(client.user.useCount().data?.toFixed(2)); + check(client.user.useCount({ select: { email: true } }).data?.email.toFixed(2)); + + check(client.user.useAggregate({ _max: { email: true } }).data?._max.email); + + check(client.user.useGroupBy({ by: ['email'], _max: { name: true } }).data?.[0]?._max.name); + + // @ts-expect-error missing args + client.user.useCreate().mutate(); + client.user.useCreate().mutate({ data: { email: 'test@example.com' } }); + client.user + .useCreate({ optimisticUpdate: true, invalidateQueries: false, retry: 3 }) + .mutate({ data: { email: 'test@example.com' } }); + + client.user + .useCreate() + .mutateAsync({ data: { email: 'test@example.com' }, include: { posts: true } }) + .then((d) => check(d.posts[0]?.title)); + + client.user + .useCreateMany() + .mutateAsync({ + data: [{ email: 'test@example.com' }, { email: 'test2@example.com' }], + skipDuplicates: true, + }) + .then((d) => d.count); + + client.user + .useCreateManyAndReturn() + .mutateAsync({ + data: [{ email: 'test@example.com' }], + }) + .then((d) => check(d[0]?.name)); + + client.user + .useCreateManyAndReturn() + .mutateAsync({ + data: [{ email: 'test@example.com' }], + select: { email: true }, + }) + // @ts-expect-error unselected field + .then((d) => check(d[0].name)); + + client.user.useUpdate().mutate( + { data: { email: 'updated@example.com' }, where: { id: '1' } }, + { + onSuccess: (d) => { + check(d.email); + }, + }, + ); + + client.user.useUpdateMany().mutate({ data: { email: 'updated@example.com' } }); + + client.user + .useUpdateManyAndReturn() + .mutateAsync({ data: { email: 'updated@example.com' } }) + .then((d) => check(d[0]?.email)); + + client.user.useUpsert().mutate({ + where: { id: '1' }, + create: { email: 'new@example.com' }, + update: { email: 'updated@example.com' }, + }); + + client.user.useDelete().mutate({ where: { id: '1' }, include: { posts: true } }); + + client.user.useDeleteMany().mutate({ where: { email: 'test@example.com' } }); + + // @ts-expect-error delegate model + client.foo.useCreate(); + + client.foo.useUpdate(); + client.bar.useCreate(); + }); + + it('types procedure queries correctly', () => { + const proceduresClient = useClientQueries(proceduresSchema); + + // procedures (query) + check(proceduresClient.$procs.greet.useQuery().data?.toUpperCase()); + check(proceduresClient.$procs.greet.useQuery({ args: { name: 'bob' } }).data?.toUpperCase()); + check(proceduresClient.$procs.greet.useQuery({ args: { name: 'bob' } }, { enabled: true }).queryKey); + // @ts-expect-error wrong arg shape + proceduresClient.$procs.greet.useQuery({ args: { hello: 'world' } }); + + // Infinite queries for procedures are currently disabled, will add back later if needed + // check(proceduresClient.$procs.greetMany.useInfiniteQuery({ args: { name: 'bob' } }).data?.pages[0]?.[0]?.toUpperCase()); + // check(proceduresClient.$procs.greetMany.useInfiniteQuery({ args: { name: 'bob' } }).queryKey); + + // @ts-expect-error missing args + proceduresClient.$procs.greetMany.useQuery(); + // @ts-expect-error greet is not a mutation procedure + proceduresClient.$procs.greet.useMutation(); + + // procedures (mutation) + proceduresClient.$procs.sum.useMutation().mutate({ args: { a: 1, b: 2 } }); + // @ts-expect-error wrong arg shape for multi-param procedure + proceduresClient.$procs.sum.useMutation().mutate([1, 2]); + proceduresClient.$procs.sum + .useMutation() + .mutateAsync({ args: { a: 1, b: 2 } }) + .then((d) => check(d.toFixed(2))); + }); +}); + +function check(_value: unknown) { + // noop +} diff --git a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts new file mode 100644 index 000000000..5b83f6ff4 --- /dev/null +++ b/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts @@ -0,0 +1,93 @@ +import { ZenStackClient, type GetQueryOptions } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useClientQueries } from '../src/svelte/index.svelte'; +import { schema } from './schemas/basic/schema-lite'; +import { schema as procSchema } from './schemas/procedures/schema-lite'; + +describe('Svelte client sliced client test', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + slicing: { + includedModels: ['User', 'Post'], + models: { + user: { + includedOperations: ['findUnique', 'findMany', 'update'], + excludedOperations: ['update'], + }, + }, + }, + omit: {}, + }); + + it('works with sliced models', () => { + const client = useClientQueries>(schema); + + expectTypeOf(client).toHaveProperty('user'); + expectTypeOf(client).toHaveProperty('post'); + expectTypeOf(client).not.toHaveProperty('category'); + }); + + it('works with sliced operations', () => { + const client = useClientQueries< + typeof schema, + { + slicing: { + models: { + user: { + includedOperations: ['findUnique', 'findMany', 'update']; + }; + }; + }; + } + >(schema); + + expectTypeOf(client.user).toHaveProperty('useFindUnique'); + expectTypeOf(client.user).toHaveProperty('useFindMany'); + expectTypeOf(client.user).toHaveProperty('useUpdate'); + expectTypeOf(client.user).not.toHaveProperty('useFindFirst'); + }); + + it('works with sliced filters', () => { + const client = useClientQueries< + typeof schema, + { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality']; + }; + }; + }; + }; + }; + } + >(schema); + + // Equality filter should be allowed + client.user.useFindMany(() => ({ + where: { name: { equals: 'test' } }, + })); + + // 'Like' filter kind should not be available + // @ts-expect-error - 'contains' is not allowed when only 'Equality' filter kind is included + client.user.useFindMany(() => ({ where: { name: { contains: 'test' } } })); + }); + + it('works with sliced procedures', () => { + const client = useClientQueries< + typeof procSchema, + { + slicing: { + includedProcedures: ['greet', 'sum']; + excludedProcedures: ['sum']; + }; + } + >(procSchema); + + expectTypeOf(client.$procs).toHaveProperty('greet'); + expectTypeOf(client.$procs).not.toHaveProperty('sum'); + expectTypeOf(client.$procs).not.toHaveProperty('greetMany'); + }); +}); diff --git a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts new file mode 100644 index 000000000..5fdd11d6a --- /dev/null +++ b/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts @@ -0,0 +1,93 @@ +import { ZenStackClient, type GetQueryOptions } from '@zenstackhq/orm'; +import { describe, expectTypeOf, it } from 'vitest'; +import { useClientQueries } from '../src/vue'; +import { schema } from './schemas/basic/schema-lite'; +import { schema as procSchema } from './schemas/procedures/schema-lite'; + +describe('Vue client sliced client test', () => { + const _db = new ZenStackClient(schema, { + dialect: {} as any, + slicing: { + includedModels: ['User', 'Post'], + models: { + user: { + includedOperations: ['findUnique', 'findMany', 'update'], + excludedOperations: ['update'], + }, + }, + }, + omit: {}, + }); + + it('works with sliced models', () => { + const client = useClientQueries>(schema); + + expectTypeOf(client).toHaveProperty('user'); + expectTypeOf(client).toHaveProperty('post'); + expectTypeOf(client).not.toHaveProperty('category'); + }); + + it('works with sliced operations', () => { + const client = useClientQueries< + typeof schema, + { + slicing: { + models: { + user: { + includedOperations: ['findUnique', 'findMany', 'update']; + }; + }; + }; + } + >(schema); + + expectTypeOf(client.user).toHaveProperty('useFindUnique'); + expectTypeOf(client.user).toHaveProperty('useFindMany'); + expectTypeOf(client.user).toHaveProperty('useUpdate'); + expectTypeOf(client.user).not.toHaveProperty('useFindFirst'); + }); + + it('works with sliced filters', () => { + const client = useClientQueries< + typeof schema, + { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality']; + }; + }; + }; + }; + }; + } + >(schema); + + // Equality filter should be allowed + client.user.useFindMany({ + where: { name: { equals: 'test' } }, + }); + + // 'Like' filter kind should not be available + // @ts-expect-error - 'contains' is not allowed when only 'Equality' filter kind is included + client.user.useFindMany({ where: { name: { contains: 'test' } } }); + }); + + it('works with sliced procedures', () => { + const client = useClientQueries< + typeof procSchema, + { + slicing: { + includedProcedures: ['greet', 'sum']; + excludedProcedures: ['sum']; + }; + } + >(procSchema); + + expectTypeOf(client.$procs).toHaveProperty('greet'); + expectTypeOf(client.$procs).not.toHaveProperty('sum'); + expectTypeOf(client.$procs).not.toHaveProperty('greetMany'); + }); +}); diff --git a/packages/clients/tanstack-query/vitest.config.ts b/packages/clients/tanstack-query/vitest.config.ts index 221fe5800..3e6d3bbbb 100644 --- a/packages/clients/tanstack-query/vitest.config.ts +++ b/packages/clients/tanstack-query/vitest.config.ts @@ -6,6 +6,10 @@ export default mergeConfig( defineConfig({ test: { include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], + typecheck: { + enabled: true, + tsconfig: 'tsconfig.test.json', + }, }, }), ); diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index acf888f8a..8eec17a13 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -1,4 +1,4 @@ -import { invariant } from '@zenstackhq/common-helpers'; +import { invariant, lowerCaseFirst } from '@zenstackhq/common-helpers'; import type { QueryExecutor } from 'kysely'; import { CompiledQuery, @@ -257,6 +257,10 @@ export class ClientImpl { get $procs() { return Object.keys(this.$schema.procedures ?? {}).reduce((acc, name) => { + // Filter procedures based on slicing configuration + if (!isProcedureIncluded(this.$options, name)) { + return acc; + } acc[name] = (input?: unknown) => this.handleProc(name, input); return acc; }, {} as any); @@ -462,6 +466,10 @@ function createClientProxy(client: ClientImpl): ClientImpl { if (typeof prop === 'string') { const model = Object.keys(client.$schema.models).find((m) => m.toLowerCase() === prop.toLowerCase()); if (model) { + // Check if model is allowed by slicing configuration + if (!isModelIncluded(client.$options, model)) { + return undefined; + } return createModelCrudHandler(client as any, model, client.inputValidator, resultProcessor); } } @@ -471,6 +479,64 @@ function createClientProxy(client: ClientImpl): ClientImpl { }) as unknown as ClientImpl; } +/** + * Checks if a model should be included based on slicing configuration. + */ +function isModelIncluded(options: ClientOptions, model: string): boolean { + const slicing = options.slicing; + if (!slicing) { + // No slicing config, include all models + return true; + } + + const { includedModels, excludedModels } = slicing; + + // If includedModels is specified (even if empty), only include those models + if (includedModels !== undefined) { + if (!includedModels.includes(model as any)) { + return false; + } + } + + // Then check if model is excluded + if (excludedModels && excludedModels.length > 0) { + if (excludedModels.includes(model as any)) { + return false; + } + } + + return true; +} + +/** + * Checks if a procedure should be included based on slicing configuration. + */ +function isProcedureIncluded(options: ClientOptions, procedureName: string): boolean { + const slicing = options.slicing; + if (!slicing) { + // No slicing config, include all procedures + return true; + } + + const { includedProcedures, excludedProcedures } = slicing; + + // If includedProcedures is specified (even if empty), only include those procedures + if (includedProcedures !== undefined) { + if (!(includedProcedures as readonly string[]).includes(procedureName)) { + return false; + } + } + + // Then check if procedure is excluded (exclusion takes precedence) + if (excludedProcedures && excludedProcedures.length > 0) { + if ((excludedProcedures as readonly string[]).includes(procedureName)) { + return false; + } + } + + return true; +} + function createModelCrudHandler( client: ClientContract, model: string, @@ -527,7 +593,7 @@ function createModelCrudHandler( }; // type parameters to operation handlers are explicitly specified to improve tsc performance - return { + const operations = { findUnique: (args: unknown) => { return createPromise( 'findUnique', @@ -720,5 +786,36 @@ function createModelCrudHandler( false, ); }, - } as ModelOperations; + }; + + // Filter operations based on slicing configuration + const slicing = client.$options.slicing; + if (slicing?.models) { + const modelSlicing = slicing.models[lowerCaseFirst(model) as any]; + const allSlicing = slicing.models.$all; + + // Determine includedOperations: model-specific takes precedence over $all + const includedOperations = modelSlicing?.includedOperations ?? allSlicing?.includedOperations; + + // Determine excludedOperations: model-specific takes precedence over $all + const excludedOperations = modelSlicing?.excludedOperations ?? allSlicing?.excludedOperations; + + // If includedOperations is specified, remove operations not in the list + if (includedOperations !== undefined) { + for (const key of Object.keys(operations)) { + if (!includedOperations.includes(key as any)) { + delete (operations as any)[key]; + } + } + } + + // Then remove explicitly excluded operations + if (excludedOperations && excludedOperations.length > 0) { + for (const operation of excludedOperations) { + delete (operations as any)[operation]; + } + } + } + + return operations as ModelOperations; } diff --git a/packages/orm/src/client/constants.ts b/packages/orm/src/client/constants.ts index 129cf3490..bf62faff6 100644 --- a/packages/orm/src/client/constants.ts +++ b/packages/orm/src/client/constants.ts @@ -26,5 +26,56 @@ export const LOGICAL_COMBINATORS = ['AND', 'OR', 'NOT'] as const; /** * Aggregation operators. */ -export const AGGREGATE_OPERATORS = ['_count', '_sum', '_avg', '_min', '_max'] as const; -export type AGGREGATE_OPERATORS = (typeof AGGREGATE_OPERATORS)[number]; +export const AggregateOperators = ['_count', '_sum', '_avg', '_min', '_max'] as const; +export type AggregateOperators = (typeof AggregateOperators)[number]; + +/** + * Mapping of filter operators to their corresponding filter kind categories. + */ +export const FILTER_PROPERTY_TO_KIND = { + // Equality operators + equals: 'Equality', + not: 'Equality', + in: 'Equality', + notIn: 'Equality', + + // Range operators + lt: 'Range', + lte: 'Range', + gt: 'Range', + gte: 'Range', + between: 'Range', + + // Like operators + contains: 'Like', + startsWith: 'Like', + endsWith: 'Like', + mode: 'Like', + + // Relation operators + is: 'Relation', + isNot: 'Relation', + some: 'Relation', + every: 'Relation', + none: 'Relation', + + // Json operators + path: 'Json', + string_contains: 'Json', + string_starts_with: 'Json', + string_ends_with: 'Json', + array_contains: 'Json', + array_starts_with: 'Json', + array_ends_with: 'Json', + + // List operators + has: 'List', + hasEvery: 'List', + hasSome: 'List', + isEmpty: 'List', +} as const; + +/** + * Mapping of filter operators to their corresponding filter kind categories. + */ +export type FilterPropertyToKind = typeof FILTER_PROPERTY_TO_KIND; diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 7492c02bf..9b038722f 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -27,7 +27,6 @@ import type { FindFirstArgs, FindManyArgs, FindUniqueArgs, - GetProcedureNames, GroupByArgs, GroupByResult, ProcedureFunc, @@ -47,10 +46,11 @@ import type { CoreReadOperations, CoreUpdateOperations, } from './crud/operations/base'; -import type { ClientOptions, QueryOptions, ToQueryOptions } from './options'; +import type { ClientOptions, QueryOptions } from './options'; import type { ExtClientMembersBase, ExtQueryArgsBase, RuntimePlugin } from './plugin'; import type { ZenStackPromise } from './promise'; import type { ToKysely } from './query-builder'; +import type { GetSlicedModels, GetSlicedOperations, GetSlicedProcedures } from './type-utils'; type TransactionUnsupportedMethods = (typeof TRANSACTION_UNSUPPORTED_METHODS)[number]; @@ -238,13 +238,8 @@ export type ClientContract< */ $pushSchema(): Promise; } & { - [Key in GetModels as Uncapitalize]: ModelOperations< - Schema, - Key, - ToQueryOptions, - ExtQueryArgs - >; -} & ProcedureOperations & + [Key in GetSlicedModels as Uncapitalize]: ModelOperations; +} & ProcedureOperations & ExtClientMembers; /** @@ -257,14 +252,17 @@ export type TransactionClientContract< ExtClientMembers extends ExtClientMembersBase, > = Omit, TransactionUnsupportedMethods>; -export type ProcedureOperations = +export type ProcedureOperations< + Schema extends SchemaDef, + Options extends ClientOptions = ClientOptions, +> = Schema['procedures'] extends Record ? { /** * Custom procedures. */ $procs: { - [Key in GetProcedureNames]: ProcedureFunc; + [Key in GetSlicedProcedures]: ProcedureFunc; }; } : {}; @@ -299,7 +297,21 @@ export const CRUD = ['create', 'read', 'update', 'delete'] as const; */ export const CRUD_EXT = [...CRUD, 'post-update'] as const; -//#region Model operations +// #region Model operations + +type SliceOperations< + T extends Record, + Schema extends SchemaDef, + Model extends GetModels, + Options extends ClientOptions, +> = Omit< + { + // keep only operations included by slicing options + [Key in keyof T as Key extends GetSlicedOperations ? Key : never]: T[Key]; + }, + // exclude operations not applicable to delegate models + IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never +>; export type AllModelOperations< Schema extends SchemaDef, @@ -330,12 +342,13 @@ export type AllModelOperations< * ``` */ createManyAndReturn< - T extends CreateManyAndReturnArgs & + T extends CreateManyAndReturnArgs & ExtractExtQueryArgs, >( args?: SelectSubset< T, - CreateManyAndReturnArgs & ExtractExtQueryArgs + CreateManyAndReturnArgs & + ExtractExtQueryArgs >, ): ZenStackPromise[]>; @@ -362,12 +375,13 @@ export type AllModelOperations< * ``` */ updateManyAndReturn< - T extends UpdateManyAndReturnArgs & + T extends UpdateManyAndReturnArgs & ExtractExtQueryArgs, >( args: Subset< T, - UpdateManyAndReturnArgs & ExtractExtQueryArgs + UpdateManyAndReturnArgs & + ExtractExtQueryArgs >, ): ZenStackPromise[]>; }); @@ -459,8 +473,8 @@ type CommonModelOperations< * }); // result: `{ _count: { posts: number } }` * ``` */ - findMany & ExtractExtQueryArgs>( - args?: SelectSubset & ExtractExtQueryArgs>, + findMany & ExtractExtQueryArgs>( + args?: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise[]>; /** @@ -469,8 +483,8 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findUnique & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + findUnique & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise | null>; /** @@ -479,8 +493,10 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findUniqueOrThrow & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + findUniqueOrThrow< + T extends FindUniqueArgs & ExtractExtQueryArgs, + >( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -489,8 +505,8 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findFirst & ExtractExtQueryArgs>( - args?: SelectSubset & ExtractExtQueryArgs>, + findFirst & ExtractExtQueryArgs>( + args?: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise | null>; /** @@ -499,8 +515,8 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findFirstOrThrow & ExtractExtQueryArgs>( - args?: SelectSubset & ExtractExtQueryArgs>, + findFirstOrThrow & ExtractExtQueryArgs>( + args?: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -555,8 +571,8 @@ type CommonModelOperations< * }); * ``` */ - create & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + create & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -705,8 +721,8 @@ type CommonModelOperations< * }); * ``` */ - update & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + update & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -729,8 +745,8 @@ type CommonModelOperations< * limit: 10 * }); */ - updateMany & ExtractExtQueryArgs>( - args: Subset & ExtractExtQueryArgs>, + updateMany & ExtractExtQueryArgs>( + args: Subset & ExtractExtQueryArgs>, ): ZenStackPromise; /** @@ -753,8 +769,8 @@ type CommonModelOperations< * }); * ``` */ - upsert & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + upsert & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -776,8 +792,8 @@ type CommonModelOperations< * }); // result: `{ id: string; email: string }` * ``` */ - delete & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + delete & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -799,8 +815,8 @@ type CommonModelOperations< * }); * ``` */ - deleteMany & ExtractExtQueryArgs>( - args?: Subset & ExtractExtQueryArgs>, + deleteMany & ExtractExtQueryArgs>( + args?: Subset & ExtractExtQueryArgs>, ): ZenStackPromise; /** @@ -821,8 +837,8 @@ type CommonModelOperations< * select: { _all: true, email: true } * }); // result: `{ _all: number, email: number }` */ - count & ExtractExtQueryArgs>( - args?: Subset & ExtractExtQueryArgs>, + count & ExtractExtQueryArgs>( + args?: Subset & ExtractExtQueryArgs>, ): ZenStackPromise>>; /** @@ -842,8 +858,8 @@ type CommonModelOperations< * _max: { age: true } * }); // result: `{ _count: number, _avg: { age: number }, ... }` */ - aggregate & ExtractExtQueryArgs>( - args: Subset & ExtractExtQueryArgs>, + aggregate & ExtractExtQueryArgs>( + args: Subset & ExtractExtQueryArgs>, ): ZenStackPromise>>; /** @@ -879,8 +895,8 @@ type CommonModelOperations< * having: { country: 'US', age: { _avg: { gte: 18 } } } * }); */ - groupBy & ExtractExtQueryArgs>( - args: Subset & ExtractExtQueryArgs>, + groupBy & ExtractExtQueryArgs>( + args: Subset & ExtractExtQueryArgs>, ): ZenStackPromise>>; /** @@ -900,8 +916,8 @@ type CommonModelOperations< * where: { posts: { some: { published: true } } }, * }); // result: `boolean` */ - exists & ExtractExtQueryArgs>( - args?: Subset & ExtractExtQueryArgs>, + exists & ExtractExtQueryArgs>( + args?: Subset & ExtractExtQueryArgs>, ): ZenStackPromise; }; @@ -910,13 +926,9 @@ export type OperationsIneligibleForDelegateModels = 'create' | 'createMany' | 'c export type ModelOperations< Schema extends SchemaDef, Model extends GetModels, - Options extends QueryOptions = QueryOptions, + Options extends ClientOptions = ClientOptions, ExtQueryArgs = {}, -> = Omit< - AllModelOperations, - // exclude operations not applicable to delegate models - IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never ->; +> = SliceOperations, Schema, Model, Options>; //#endregion diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 62be0c199..5b050f56a 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -50,8 +50,9 @@ import type { XOR, } from '../utils/type-utils'; import type { ClientContract } from './contract'; -import type { QueryOptions } from './options'; +import type { FilterKind, QueryOptions } from './options'; import type { ToKyselySchema } from './query-builder'; +import type { GetSlicedFilterKindsForField, GetSlicedModels } from './type-utils'; //#region Query results @@ -149,7 +150,7 @@ type ModelSelectResult< never : Key extends '_count' ? // select "_count" - Select[Key] extends SelectCount + Select[Key] extends SelectCount ? Key : never : Key extends keyof Omit @@ -278,6 +279,7 @@ export type BatchResult = { count: number }; export type WhereInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions = QueryOptions, ScalarOnly extends boolean = false, WithAggregations extends boolean = false, > = { @@ -285,129 +287,161 @@ export type WhereInput< ? Key extends RelationFields ? never : Key - : Key]?: Key extends RelationFields + : Key]?: FieldFilter; +} & { + $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; +} & { + AND?: OrArray>; + OR?: WhereInput[]; + NOT?: OrArray>; +}; + +type FieldFilter< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, + Options extends QueryOptions, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = GetSlicedFilterKindsForField, +> = + Field extends RelationFields ? // relation - RelationFilter - : FieldIsArray extends true - ? ArrayFilter> - : // enum - GetModelFieldType extends GetEnums - ? EnumFilter< + RelationFilter + : FieldIsArray extends true + ? // array + ArrayFilter, AllowedKinds> + : GetModelFieldType extends GetEnums + ? // enum + EnumFilter< Schema, - GetModelFieldType, - ModelFieldIsOptional, - WithAggregations + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds > - : GetModelFieldType extends GetTypeDefs - ? TypedJsonFilter< + : GetModelFieldType extends GetTypeDefs + ? // typedef + TypedJsonFilter< Schema, - GetModelFieldType, - FieldIsArray, - ModelFieldIsOptional + GetModelFieldType, + FieldIsArray, + ModelFieldIsOptional, + AllowedKinds > : // primitive PrimitiveFilter< - GetModelFieldType, - ModelFieldIsOptional, - WithAggregations + GetModelFieldType, + ModelFieldIsOptional, + WithAggregations, + AllowedKinds >; -} & { - $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; -} & { - AND?: OrArray>; - OR?: WhereInput[]; - NOT?: OrArray>; -}; type EnumFilter< Schema extends SchemaDef, T extends GetEnums, Nullable extends boolean, WithAggregations extends boolean, + AllowedKinds extends FilterKind, > = - | NullableIf, Nullable> - | ({ - /** - * Checks for equality with the specified enum value. - */ - equals?: NullableIf, Nullable>; - - /** - * Checks if the enum value is in the specified list of values. - */ - in?: (keyof GetEnum)[]; - - /** - * Checks if the enum value is not in the specified list of values. - */ - notIn?: (keyof GetEnum)[]; - - /** - * Builds a negated filter. - */ - not?: EnumFilter; - } & (WithAggregations extends true + | ('Equality' extends AllowedKinds ? NullableIf, Nullable> : never) + | (('Equality' extends AllowedKinds ? { /** - * Filters against the count of records. + * Checks for equality with the specified enum value. */ - _count?: NumberFilter<'Int', false, false>; + equals?: NullableIf, Nullable>; /** - * Filters against the minimum value. + * Checks if the enum value is in the specified list of values. */ - _min?: EnumFilter; + in?: (keyof GetEnum)[]; /** - * Filters against the maximum value. + * Checks if the enum value is not in the specified list of values. */ - _max?: EnumFilter; + notIn?: (keyof GetEnum)[]; } - : {})); + : {}) & { + /** + * Builds a negated filter. + */ + not?: EnumFilter; + } & (WithAggregations extends true + ? { + /** + * Filters against the count of records. + */ + _count?: NumberFilter<'Int', false, false, AllowedKinds>; -type ArrayFilter = { - /** - * Checks if the array equals the specified array. - */ - equals?: MapScalarType[] | null; + /** + * Filters against the minimum value. + */ + _min?: EnumFilter; - /** - * Checks if the array contains all elements of the specified array. - */ - has?: MapScalarType | null; + /** + * Filters against the maximum value. + */ + _max?: EnumFilter; + } + : {})); - /** - * Checks if the array contains any of the elements of the specified array. - */ - hasEvery?: MapScalarType[]; +type ArrayFilter< + Schema extends SchemaDef, + Type extends string, + AllowedKinds extends FilterKind, +> = ('Equality' extends AllowedKinds + ? { + /** + * Checks if the array equals the specified array. + */ + equals?: MapScalarType[] | null; + } + : {}) & + ('List' extends AllowedKinds + ? { + /** + * Checks if the array contains all elements of the specified array. + */ + has?: MapScalarType | null; - /** - * Checks if the array contains some of the elements of the specified array. - */ - hasSome?: MapScalarType[]; + /** + * Checks if the array contains any of the elements of the specified array. + */ + hasEvery?: MapScalarType[]; - /** - * Checks if the array is empty. - */ - isEmpty?: boolean; -}; + /** + * Checks if the array contains some of the elements of the specified array. + */ + hasSome?: MapScalarType[]; + + /** + * Checks if the array is empty. + */ + isEmpty?: boolean; + } + : {}); // map a scalar type (primitive and enum) to TS type type MapScalarType = Type extends GetEnums ? keyof GetEnum : MapBaseType; -type PrimitiveFilter = T extends 'String' - ? StringFilter +type PrimitiveFilter< + T extends string, + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind, +> = T extends 'String' + ? StringFilter : T extends 'Int' | 'Float' | 'Decimal' | 'BigInt' - ? NumberFilter + ? NumberFilter : T extends 'Boolean' - ? BooleanFilter + ? BooleanFilter : T extends 'DateTime' - ? DateTimeFilter + ? DateTimeFilter : T extends 'Bytes' - ? BytesFilter + ? BytesFilter : T extends 'Json' - ? JsonFilter + ? JsonFilter : never; type CommonPrimitiveFilter< @@ -415,91 +449,105 @@ type CommonPrimitiveFilter< T extends BuiltinType, Nullable extends boolean, WithAggregations extends boolean, -> = { - /** - * Checks for equality with the specified value. - */ - equals?: NullableIf; - - /** - * Checks if the value is in the specified list of values. - */ - in?: DataType[]; - - /** - * Checks if the value is not in the specified list of values. - */ - notIn?: DataType[]; + AllowedKinds extends FilterKind, +> = ('Equality' extends AllowedKinds + ? { + /** + * Checks for equality with the specified value. + */ + equals?: NullableIf; - /** - * Checks if the value is less than the specified value. - */ - lt?: DataType; + /** + * Checks if the value is in the specified list of values. + */ + in?: DataType[]; - /** - * Checks if the value is less than or equal to the specified value. - */ - lte?: DataType; + /** + * Checks if the value is not in the specified list of values. + */ + notIn?: DataType[]; + } + : {}) & + ('Range' extends AllowedKinds + ? { + /** + * Checks if the value is less than the specified value. + */ + lt?: DataType; - /** - * Checks if the value is greater than the specified value. - */ - gt?: DataType; + /** + * Checks if the value is less than or equal to the specified value. + */ + lte?: DataType; - /** - * Checks if the value is greater than or equal to the specified value. - */ - gte?: DataType; + /** + * Checks if the value is greater than the specified value. + */ + gt?: DataType; - /** - * Checks if the value is between the specified values (inclusive). - */ - between?: [start: DataType, end: DataType]; + /** + * Checks if the value is greater than or equal to the specified value. + */ + gte?: DataType; - /** - * Builds a negated filter. - */ - not?: PrimitiveFilter; -}; + /** + * Checks if the value is between the specified values (inclusive). + */ + between?: [start: DataType, end: DataType]; + } + : {}) & { + /** + * Builds a negated filter. + */ + not?: PrimitiveFilter; + }; -export type StringFilter = - | NullableIf - | (CommonPrimitiveFilter & { - /** - * Checks if the string contains the specified substring. - */ - contains?: string; +export type StringFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = + | ('Equality' extends AllowedKinds ? NullableIf : never) + | (CommonPrimitiveFilter & + ('Like' extends AllowedKinds + ? { + /** + * Checks if the string contains the specified substring. + */ + contains?: string; - /** - * Checks if the string starts with the specified substring. - */ - startsWith?: string; + /** + * Checks if the string starts with the specified substring. + */ + startsWith?: string; - /** - * Checks if the string ends with the specified substring. - */ - endsWith?: string; + /** + * Checks if the string ends with the specified substring. + */ + endsWith?: string; - /** - * Specifies the string comparison mode. Not effective for "sqlite" provider - */ - mode?: 'default' | 'insensitive'; - } & (WithAggregations extends true + /** + * Specifies the string comparison mode. Not effective for "sqlite" provider + */ + mode?: 'default' | 'insensitive'; + } + : {}) & + (WithAggregations extends true ? { /** * Filters against the count of records. */ - _count?: NumberFilter<'Int', false, false>; + _count?: NumberFilter<'Int', false, false, AllowedKinds>; /** * Filters against the minimum value. */ - _min?: StringFilter; + _min?: StringFilter; /** * Filters against the maximum value. */ - _max?: StringFilter; + _max?: StringFilter; } : {})); @@ -507,214 +555,254 @@ export type NumberFilter< T extends 'Int' | 'Float' | 'Decimal' | 'BigInt', Nullable extends boolean, WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, > = - | NullableIf - | (CommonPrimitiveFilter & + | ('Equality' extends AllowedKinds ? NullableIf : never) + | (CommonPrimitiveFilter & (WithAggregations extends true ? { /** * Filters against the count of records. */ - _count?: NumberFilter<'Int', false, false>; + _count?: NumberFilter<'Int', false, false, AllowedKinds>; /** * Filters against the average value. */ - _avg?: NumberFilter; + _avg?: NumberFilter; /** * Filters against the sum value. */ - _sum?: NumberFilter; + _sum?: NumberFilter; /** * Filters against the minimum value. */ - _min?: NumberFilter; + _min?: NumberFilter; /** * Filters against the maximum value. */ - _max?: NumberFilter; + _max?: NumberFilter; } : {})); -export type DateTimeFilter = - | NullableIf - | (CommonPrimitiveFilter & +export type DateTimeFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = + | ('Equality' extends AllowedKinds ? NullableIf : never) + | (CommonPrimitiveFilter & (WithAggregations extends true ? { /** * Filters against the count of records. */ - _count?: NumberFilter<'Int', false, false>; + _count?: NumberFilter<'Int', false, false, AllowedKinds>; /** * Filters against the minimum value. */ - _min?: DateTimeFilter; + _min?: DateTimeFilter; /** * Filters against the maximum value. */ - _max?: DateTimeFilter; + _max?: DateTimeFilter; } : {})); -export type BytesFilter = - | NullableIf - | ({ - /** - * Checks for equality with the specified value. - */ - equals?: NullableIf; - - /** - * Checks if the value is in the specified list of values. - */ - in?: Uint8Array[]; - - /** - * Checks if the value is not in the specified list of values. - */ - notIn?: Uint8Array[]; - - /** - * Builds a negated filter. - */ - not?: BytesFilter; - } & (WithAggregations extends true +export type BytesFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = + | ('Equality' extends AllowedKinds ? NullableIf : never) + | (('Equality' extends AllowedKinds ? { /** - * Filters against the count of records. + * Checks for equality with the specified value. */ - _count?: NumberFilter<'Int', false, false>; + equals?: NullableIf; /** - * Filters against the minimum value. + * Checks if the value is in the specified list of values. */ - _min?: BytesFilter; + in?: Uint8Array[]; /** - * Filters against the maximum value. + * Checks if the value is not in the specified list of values. */ - _max?: BytesFilter; + notIn?: Uint8Array[]; } - : {})); - -export type BooleanFilter = - | NullableIf - | ({ - /** - * Checks for equality with the specified value. - */ - equals?: NullableIf; - + : {}) & { /** * Builds a negated filter. */ - not?: BooleanFilter; + not?: BytesFilter; } & (WithAggregations extends true - ? { - /** - * Filters against the count of records. - */ - _count?: NumberFilter<'Int', false, false>; + ? { + /** + * Filters against the count of records. + */ + _count?: NumberFilter<'Int', false, false, AllowedKinds>; - /** - * Filters against the minimum value. - */ - _min?: BooleanFilter; + /** + * Filters against the minimum value. + */ + _min?: BytesFilter; + /** + * Filters against the maximum value. + */ + _max?: BytesFilter; + } + : {})); + +export type BooleanFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = + | ('Equality' extends AllowedKinds ? NullableIf : never) + | (('Equality' extends AllowedKinds + ? { /** - * Filters against the maximum value. + * Checks for equality with the specified value. */ - _max?: BooleanFilter; + equals?: NullableIf; } - : {})); + : {}) & { + /** + * Builds a negated filter. + */ + not?: BooleanFilter; + } & (WithAggregations extends true + ? { + /** + * Filters against the count of records. + */ + _count?: NumberFilter<'Int', false, false, AllowedKinds>; -export type JsonFilter = { - /** - * JSON path to select the value to filter on. If omitted, the whole JSON value is used. - */ - path?: string; + /** + * Filters against the minimum value. + */ + _min?: BooleanFilter; - /** - * Checks for equality with the specified value. - */ - equals?: JsonValue | JsonNullValues; + /** + * Filters against the maximum value. + */ + _max?: BooleanFilter; + } + : {})); - /** - * Builds a negated filter. - */ - not?: JsonValue | JsonNullValues; +export type JsonFilter = ('Equality' extends AllowedKinds + ? { + /** + * Checks for equality with the specified value. + */ + equals?: JsonValue | JsonNullValues; - /** - * Checks if the value is a string and contains the specified substring. - */ - string_contains?: string; + /** + * Builds a negated filter. + */ + not?: JsonValue | JsonNullValues; + } + : {}) & + ('Json' extends AllowedKinds + ? { + /** + * JSON path to select the value to filter on. If omitted, the whole JSON value is used. + */ + path?: string; - /** - * Checks if the value is a string and starts with the specified substring. - */ - string_starts_with?: string; + /** + * Checks if the value is a string and contains the specified substring. + */ + string_contains?: string; - /** - * Checks if the value is a string and ends with the specified substring. - */ - string_ends_with?: string; + /** + * Checks if the value is a string and starts with the specified substring. + */ + string_starts_with?: string; - /** - * String comparison mode. Not effective for "sqlite" provider - */ - mode?: 'default' | 'insensitive'; + /** + * Checks if the value is a string and ends with the specified substring. + */ + string_ends_with?: string; - /** - * Checks if the value is an array and contains the specified value. - */ - array_contains?: JsonValue; + /** + * String comparison mode. Not effective for "sqlite" provider + */ + mode?: 'default' | 'insensitive'; - /** - * Checks if the value is an array and starts with the specified value. - */ - array_starts_with?: JsonValue; + /** + * Checks if the value is an array and contains the specified value. + */ + array_contains?: JsonValue; - /** - * Checks if the value is an array and ends with the specified value. - */ - array_ends_with?: JsonValue; -}; + /** + * Checks if the value is an array and starts with the specified value. + */ + array_starts_with?: JsonValue; + + /** + * Checks if the value is an array and ends with the specified value. + */ + array_ends_with?: JsonValue; + } + : {}); export type TypedJsonFilter< Schema extends SchemaDef, TypeDefName extends GetTypeDefs, Array extends boolean, Optional extends boolean, -> = XOR>; + AllowedKinds extends FilterKind, +> = XOR, TypedJsonTypedFilter>; type TypedJsonTypedFilter< Schema extends SchemaDef, TypeDefName extends GetTypeDefs, Array extends boolean, Optional extends boolean, -> = - | (Array extends true ? ArrayTypedJsonFilter : NonArrayTypedJsonFilter) - | (Optional extends true ? null : never); - -type ArrayTypedJsonFilter> = { - some?: TypedJsonFieldsFilter; - every?: TypedJsonFieldsFilter; - none?: TypedJsonFieldsFilter; + AllowedKinds extends FilterKind, +> = 'Json' extends AllowedKinds + ? + | (Array extends true + ? ArrayTypedJsonFilter + : NonArrayTypedJsonFilter) + | (Optional extends true ? null : never) + : {}; + +type ArrayTypedJsonFilter< + Schema extends SchemaDef, + TypeDefName extends GetTypeDefs, + AllowedKinds extends FilterKind, +> = { + some?: TypedJsonFieldsFilter; + every?: TypedJsonFieldsFilter; + none?: TypedJsonFieldsFilter; }; -type NonArrayTypedJsonFilter> = +type NonArrayTypedJsonFilter< + Schema extends SchemaDef, + TypeDefName extends GetTypeDefs, + AllowedKinds extends FilterKind, +> = | { - is?: TypedJsonFieldsFilter; - isNot?: TypedJsonFieldsFilter; + is?: TypedJsonFieldsFilter; + isNot?: TypedJsonFieldsFilter; } - | TypedJsonFieldsFilter; + | TypedJsonFieldsFilter; -type TypedJsonFieldsFilter> = { +type TypedJsonFieldsFilter< + Schema extends SchemaDef, + TypeDefName extends GetTypeDefs, + AllowedKinds extends FilterKind, +> = { [Key in GetTypeDefFields]?: GetTypeDefFieldType< Schema, TypeDefName, @@ -725,24 +813,27 @@ type TypedJsonFieldsFilter, TypeDefFieldIsArray, - TypeDefFieldIsOptional + TypeDefFieldIsOptional, + AllowedKinds > : // array TypeDefFieldIsArray extends true - ? ArrayFilter> + ? ArrayFilter, AllowedKinds> : // enum GetTypeDefFieldType extends GetEnums ? EnumFilter< Schema, GetTypeDefFieldType, TypeDefFieldIsOptional, - false + false, + AllowedKinds > : // primitive PrimitiveFilter< GetTypeDefFieldType, TypeDefFieldIsOptional, - false + false, + AllowedKinds >; }; @@ -813,7 +904,11 @@ export type OrderBy< }) : {}); -export type WhereUniqueInput> = AtLeast< +export type WhereUniqueInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = AtLeast< { [Key in keyof GetModel['uniqueFields']]?: GetModel< Schema, @@ -829,7 +924,7 @@ export type WhereUniqueInput['uniqueFields'][Key][Key1]> : never; }; - } & WhereInput, + } & WhereInput, Extract['uniqueFields'], string> >; @@ -841,34 +936,38 @@ export type SelectIncludeOmit< Schema extends SchemaDef, Model extends GetModels, AllowCount extends boolean, + Options extends QueryOptions = QueryOptions, AllowRelation extends boolean = true, > = { /** * Explicitly select fields and relations to be returned by the query. */ - select?: SelectInput | null; - - /** - * Specifies relations to be included in the query result. All scalar fields are included. - */ - include?: IncludeInput | null; + select?: SelectInput | null; /** * Explicitly omit fields from the query result. */ omit?: OmitInput | null; -}; +} & (AllowRelation extends true + ? { + /** + * Specifies relations to be included in the query result. All scalar fields are included. + */ + include?: IncludeInput | null; + } + : {}); export type SelectInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, AllowRelation extends boolean = true, > = { [Key in NonRelationFields]?: boolean; -} & (AllowRelation extends true ? IncludeInput : {}); +} & (AllowRelation extends true ? IncludeInput : {}); -type SelectCount> = +type SelectCount, Options extends QueryOptions> = | boolean | { /** @@ -878,7 +977,7 @@ type SelectCount> = [Key in RelationFields as FieldIsArray extends true ? Key : never]?: | boolean | { - where: WhereInput, false>; + where: WhereInput, Options, false>; }; }; }; @@ -886,13 +985,20 @@ type SelectCount> = export type IncludeInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, > = { - [Key in RelationFields]?: + [Key in RelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]?: | boolean | FindArgs< Schema, RelationFieldType, + Options, FieldIsArray, // where clause is allowed only if the relation is array or optional FieldIsArray extends true @@ -904,7 +1010,7 @@ export type IncludeInput< } & (AllowCount extends true ? // _count is only allowed if the model has to-many relations HasToManyRelations extends true - ? { _count?: SelectCount } + ? { _count?: SelectCount } : {} : {}); @@ -924,23 +1030,25 @@ type ToManyRelationFilter< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = { - every?: WhereInput>; - some?: WhereInput>; - none?: WhereInput>; + every?: WhereInput, Options>; + some?: WhereInput, Options>; + none?: WhereInput, Options>; }; type ToOneRelationFilter< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = NullableIf< - WhereInput> & { + WhereInput, Options> & { /** * Checks if the related record matches the specified filter. */ is?: NullableIf< - WhereInput>, + WhereInput, Options>, ModelFieldIsOptional >; @@ -948,7 +1056,7 @@ type ToOneRelationFilter< * Checks if the related record does not match the specified filter. */ isNot?: NullableIf< - WhereInput>, + WhereInput, Options>, ModelFieldIsOptional >; }, @@ -959,10 +1067,13 @@ type RelationFilter< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = - FieldIsArray extends true - ? ToManyRelationFilter - : ToOneRelationFilter; + Options extends QueryOptions, + AllowedKinds extends FilterKind, +> = 'Relation' extends AllowedKinds + ? FieldIsArray extends true + ? ToManyRelationFilter + : ToOneRelationFilter + : never; //#endregion @@ -995,7 +1106,7 @@ type OptionalFieldsForCreate extends true ? Key - : GetModelField['updatedAt'] extends (true | UpdatedAtInfo) + : GetModelField['updatedAt'] extends true | UpdatedAtInfo ? Key : never]: GetModelField; }; @@ -1045,14 +1156,18 @@ type OppositeRelationAndFK< //#region Find args -type FilterArgs> = { +type FilterArgs, Options extends QueryOptions> = { /** * Filter conditions */ - where?: WhereInput; + where?: WhereInput; }; -type SortAndTakeArgs> = { +type SortAndTakeArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = { /** * Number of records to skip */ @@ -1071,16 +1186,17 @@ type SortAndTakeArgs> /** * Cursor for pagination */ - cursor?: WhereUniqueInput; + cursor?: WhereUniqueInput; }; export type FindArgs< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions, Collection extends boolean, AllowFilter extends boolean = true, > = (Collection extends true - ? SortAndTakeArgs & + ? SortAndTakeArgs & (ProviderSupportsDistinct extends true ? { /** @@ -1090,34 +1206,54 @@ export type FindArgs< } : {}) : {}) & - (AllowFilter extends true ? FilterArgs : {}) & - SelectIncludeOmit; + (AllowFilter extends true ? FilterArgs : {}) & + SelectIncludeOmit; -export type FindManyArgs> = FindArgs; +export type FindManyArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = FindArgs; -export type FindFirstArgs> = FindArgs; +export type FindFirstArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = FindArgs; -export type ExistsArgs> = FilterArgs; +export type ExistsArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = FilterArgs; -export type FindUniqueArgs> = { - where: WhereUniqueInput; -} & SelectIncludeOmit; +export type FindUniqueArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { + where: WhereUniqueInput; +} & SelectIncludeOmit; //#endregion //#region Create args -export type CreateArgs> = { - data: CreateInput; -} & SelectIncludeOmit; +export type CreateArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { + data: CreateInput; +} & SelectIncludeOmit; export type CreateManyArgs> = CreateManyInput; -export type CreateManyAndReturnArgs> = CreateManyInput< - Schema, - Model -> & - Omit, 'include'>; +export type CreateManyAndReturnArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = CreateManyInput & SelectIncludeOmit; type OptionalWrap, T extends object> = Optional< T, @@ -1176,17 +1312,18 @@ type CreateRelationFieldPayload< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = Omit< { /** * Connects or create a related record. */ - connectOrCreate?: ConnectOrCreateInput; + connectOrCreate?: ConnectOrCreateInput; /** * Creates a related record. */ - create?: NestedCreateInput; + create?: NestedCreateInput; /** * Creates a batch of related records. @@ -1196,7 +1333,7 @@ type CreateRelationFieldPayload< /** * Connects an existing record. */ - connect?: ConnectInput; + connect?: ConnectInput; }, // no "createMany" for non-array fields | (FieldIsArray extends true ? never : 'createMany') @@ -1204,50 +1341,73 @@ type CreateRelationFieldPayload< | (FieldIsDelegateRelation extends true ? 'create' | 'createMany' | 'connectOrCreate' : never) >; -type CreateRelationPayload> = OptionalWrap< +type CreateRelationPayload< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = OptionalWrap< Schema, Model, { - [Key in RelationFields]: CreateRelationFieldPayload; + [Key in RelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]: CreateRelationFieldPayload; } >; -type CreateWithFKInput> = +type CreateWithFKInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = // scalar fields CreateScalarPayload & // fk fields CreateFKPayload & // non-owned relations - CreateWithNonOwnedRelationPayload; + CreateWithNonOwnedRelationPayload; -type CreateWithRelationInput> = CreateScalarPayload< - Schema, - Model -> & - CreateRelationPayload; +type CreateWithRelationInput< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = CreateScalarPayload & CreateRelationPayload; -type CreateWithNonOwnedRelationPayload> = OptionalWrap< +type CreateWithNonOwnedRelationPayload< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = OptionalWrap< Schema, Model, { - [Key in NonOwnedRelationFields]: CreateRelationFieldPayload; + [Key in NonOwnedRelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]: CreateRelationFieldPayload; } >; type ConnectOrCreatePayload< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions, Without extends string = never, > = { /** * The unique filter to find an existing record to connect. */ - where: WhereUniqueInput; + where: WhereUniqueInput; /** * The data to create a new record if no existing record is found. */ - create: CreateInput; + create: CreateInput; }; export type CreateManyInput< @@ -1269,15 +1429,20 @@ export type CreateManyInput< export type CreateInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions, Without extends string = never, -> = XOR, Without>, Omit, Without>>; +> = XOR< + Omit, Without>, + Omit, Without> +>; type NestedCreateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = OrArray< - CreateInput, OppositeRelationAndFK>, + CreateInput, Options, OppositeRelationAndFK>, FieldIsArray >; @@ -1291,30 +1456,40 @@ type NestedCreateManyInput< // #region Update args -export type UpdateArgs> = { +export type UpdateArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { /** * The data to update the record with. */ - data: UpdateInput; + data: UpdateInput; /** * The unique filter to find the record to update. */ - where: WhereUniqueInput; -} & SelectIncludeOmit; + where: WhereUniqueInput; +} & SelectIncludeOmit; -export type UpdateManyArgs> = UpdateManyPayload< - Schema, - Model ->; +export type UpdateManyArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = UpdateManyPayload; -export type UpdateManyAndReturnArgs> = UpdateManyPayload< - Schema, - Model -> & - Omit, 'include'>; +export type UpdateManyAndReturnArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = UpdateManyPayload & SelectIncludeOmit; -type UpdateManyPayload, Without extends string = never> = { +type UpdateManyPayload< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, + Without extends string = never, +> = { /** * The data to update the records with. */ @@ -1323,7 +1498,7 @@ type UpdateManyPayload /** * The filter to select records to update. */ - where?: WhereInput; + where?: WhereInput; /** * Limit the number of records to update. @@ -1331,22 +1506,26 @@ type UpdateManyPayload limit?: number; }; -export type UpsertArgs> = { +export type UpsertArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { /** * The data to create the record if it doesn't exist. */ - create: CreateInput; + create: CreateInput; /** * The data to update the record with if it exists. */ - update: UpdateInput; + update: UpdateInput; /** * The unique filter to find the record to update. */ - where: WhereUniqueInput; -} & SelectIncludeOmit; + where: WhereUniqueInput; +} & SelectIncludeOmit; type UpdateScalarInput< Schema extends SchemaDef, @@ -1413,10 +1592,16 @@ type ScalarUpdatePayload< type UpdateRelationInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions, Without extends string = never, > = Omit< { - [Key in RelationFields]?: UpdateRelationFieldPayload; + [Key in RelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]?: UpdateRelationFieldPayload; }, Without >; @@ -1424,28 +1609,30 @@ type UpdateRelationInput< export type UpdateInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions, Without extends string = never, -> = UpdateScalarInput & UpdateRelationInput; - +> = UpdateScalarInput & UpdateRelationInput; type UpdateRelationFieldPayload< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true - ? ToManyRelationUpdateInput - : ToOneRelationUpdateInput; + ? ToManyRelationUpdateInput + : ToOneRelationUpdateInput; type ToManyRelationUpdateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = Omit< { /** * Creates related records. */ - create?: NestedCreateInput; + create?: NestedCreateInput; /** * Creates a batch of related records. @@ -1455,47 +1642,47 @@ type ToManyRelationUpdateInput< /** * Connects existing records. */ - connect?: ConnectInput; + connect?: ConnectInput; /** * Connects or create related records. */ - connectOrCreate?: ConnectOrCreateInput; + connectOrCreate?: ConnectOrCreateInput; /** * Disconnects related records. */ - disconnect?: DisconnectInput; + disconnect?: DisconnectInput; /** * Updates related records. */ - update?: NestedUpdateInput; + update?: NestedUpdateInput; /** * Upserts related records. */ - upsert?: NestedUpsertInput; + upsert?: NestedUpsertInput; /** * Updates a batch of related records. */ - updateMany?: NestedUpdateManyInput; + updateMany?: NestedUpdateManyInput; /** * Deletes related records. */ - delete?: NestedDeleteInput; + delete?: NestedDeleteInput; /** * Deletes a batch of related records. */ - deleteMany?: NestedDeleteManyInput; + deleteMany?: NestedDeleteManyInput; /** * Sets the related records to the specified ones. */ - set?: SetRelationInput; + set?: SetRelationInput; }, // exclude FieldIsDelegateRelation extends true @@ -1507,43 +1694,44 @@ type ToOneRelationUpdateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = Omit< { /** * Creates a related record. */ - create?: NestedCreateInput; + create?: NestedCreateInput; /** * Connects an existing record. */ - connect?: ConnectInput; + connect?: ConnectInput; /** * Connects or create a related record. */ - connectOrCreate?: ConnectOrCreateInput; + connectOrCreate?: ConnectOrCreateInput; /** * Updates the related record. */ - update?: NestedUpdateInput; + update?: NestedUpdateInput; /** * Upserts the related record. */ - upsert?: NestedUpsertInput; + upsert?: NestedUpsertInput; } & (ModelFieldIsOptional extends true ? { /** * Disconnects the related record. */ - disconnect?: DisconnectInput; + disconnect?: DisconnectInput; /** * Deletes the related record. */ - delete?: NestedDeleteInput; + delete?: NestedDeleteInput; } : {}), FieldIsDelegateRelation extends true ? 'create' | 'connectOrCreate' | 'upsert' : never @@ -1553,18 +1741,26 @@ type ToOneRelationUpdateInput< // #region Delete args -export type DeleteArgs> = { +export type DeleteArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { /** * The unique filter to find the record to delete. */ - where: WhereUniqueInput; -} & SelectIncludeOmit; + where: WhereUniqueInput; +} & SelectIncludeOmit; -export type DeleteManyArgs> = { +export type DeleteManyArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { /** * Filter to select records to delete. */ - where?: WhereInput; + where?: WhereInput; /** * Limits the number of records to delete. @@ -1576,10 +1772,11 @@ export type DeleteManyArgs> = Omit< - FindArgs, - 'select' | 'include' | 'distinct' | 'omit' -> & { +export type CountArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = Omit, 'select' | 'include' | 'distinct' | 'omit'> & { /** * Selects fields to count */ @@ -1604,12 +1801,15 @@ export type CountResult> = { +export type AggregateArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { /** * Filter conditions */ - where?: WhereInput; - + where?: WhereInput; /** * Number of records to skip for the aggregation */ @@ -1732,16 +1932,21 @@ type AggCommonOutput = Input extends true // #region GroupBy -type GroupByHaving> = Omit< - WhereInput, - '$expr' ->; +type GroupByHaving< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = Omit, '$expr'>; -export type GroupByArgs> = { +export type GroupByArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { /** * Filter conditions */ - where?: WhereInput; + where?: WhereInput; /** * Order by clauses @@ -1756,7 +1961,7 @@ export type GroupByArgs; + having?: GroupByHaving; /** * Number of records to take for grouping @@ -1855,27 +2060,31 @@ type ConnectInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true - ? OrArray>> - : WhereUniqueInput>; + ? OrArray, Options>> + : WhereUniqueInput, Options>; type ConnectOrCreateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true ? OrArray< ConnectOrCreatePayload< Schema, RelationFieldType, + Options, OppositeRelationAndFK > > : ConnectOrCreatePayload< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; @@ -1883,21 +2092,24 @@ type DisconnectInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true - ? OrArray>, true> - : boolean | WhereInput>; + ? OrArray, Options>, true> + : boolean | WhereInput, Options>; type SetRelationInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = OrArray>>; + Options extends QueryOptions, +> = OrArray, Options>>; type NestedUpdateInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true ? // to-many @@ -1906,7 +2118,7 @@ type NestedUpdateInput< /** * Unique filter to select the record to update. */ - where: WhereUniqueInput>; + where: WhereUniqueInput, Options>; /** * The data to update the record with. @@ -1914,6 +2126,7 @@ type NestedUpdateInput< data: UpdateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; }, @@ -1925,7 +2138,7 @@ type NestedUpdateInput< /** * Filter to select the record to update. */ - where?: WhereInput>; + where?: WhereInput, Options>; /** * The data to update the record with. @@ -1933,22 +2146,29 @@ type NestedUpdateInput< data: UpdateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; }, - UpdateInput, OppositeRelationAndFK> + UpdateInput< + Schema, + RelationFieldType, + Options, + OppositeRelationAndFK + > >; type NestedUpsertInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = OrArray< { /** * Unique filter to select the record to update. */ - where: WhereUniqueInput>; + where: WhereUniqueInput, Options>; /** * The data to create the record if it doesn't exist. @@ -1956,6 +2176,7 @@ type NestedUpsertInput< create: CreateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; @@ -1965,6 +2186,7 @@ type NestedUpsertInput< update: UpdateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; }, @@ -1975,24 +2197,32 @@ type NestedUpdateManyInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = OrArray< - UpdateManyPayload, OppositeRelationAndFK> + UpdateManyPayload< + Schema, + RelationFieldType, + Options, + OppositeRelationAndFK + > >; type NestedDeleteInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true - ? OrArray>, true> - : boolean | WhereInput>; + ? OrArray, Options>, true> + : boolean | WhereInput, Options>; type NestedDeleteManyInput< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = OrArray, true>>; + Options extends QueryOptions, +> = OrArray, Options, true>>; // #endregion diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 9b273e2b6..0f204817f 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -5,7 +5,7 @@ import { match, P } from 'ts-pattern'; import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, ModelDef, SchemaDef } from '../../../schema'; import type { OrArray } from '../../../utils/type-utils'; -import { AGGREGATE_OPERATORS, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants'; +import { AggregateOperators, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants'; import type { BooleanFilter, BytesFilter, @@ -117,7 +117,7 @@ export abstract class BaseCrudDialect { buildFilterSortTake( model: string, - args: FindArgs, true>, + args: FindArgs, any, true>, query: SelectQueryBuilder, modelAlias: string, ) { @@ -828,7 +828,7 @@ export abstract class BaseCrudDialect { }) .with('not', () => this.eb.not(recurse(value))) // aggregations - .with(P.union(...AGGREGATE_OPERATORS), (op) => { + .with(P.union(...AggregateOperators), (op) => { const innerResult = this.buildStandardFilter( type, value, @@ -1040,7 +1040,7 @@ export abstract class BaseCrudDialect { for (const [k, v] of Object.entries(value)) { invariant(v === 'asc' || v === 'desc', `invalid orderBy value for field "${field}"`); result = result.orderBy( - (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field as AGGREGATE_OPERATORS), + (eb) => aggregate(eb, buildFieldRef(model, k, modelAlias), field as AggregateOperators), this.negateSort(v, negated), ); } @@ -1164,7 +1164,6 @@ export abstract class BaseCrudDialect { return (omit as any)[field]; } - // options-level if ( this.options.omit?.[model] && typeof this.options.omit[model] === 'object' && @@ -1181,7 +1180,7 @@ export abstract class BaseCrudDialect { protected buildModelSelect( model: GetModels, subQueryAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, selectAllFields: boolean, ) { let subQuery = this.buildSelectModel(model, subQueryAlias); @@ -1371,7 +1370,7 @@ export abstract class BaseCrudDialect { protected canJoinWithoutNestedSelect( modelDef: ModelDef, - payload: boolean | FindArgs, true>, + payload: boolean | FindArgs, any, true>, ) { if (modelDef.computedFields) { // computed fields requires explicit select @@ -1412,7 +1411,7 @@ export abstract class BaseCrudDialect { model: string, relationField: string, parentAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, ): SelectQueryBuilder; /** diff --git a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts index 6bc1f887c..65f2d9cec 100644 --- a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts +++ b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts @@ -29,7 +29,7 @@ export abstract class LateralJoinDialectBase extends B model: string, relationField: string, parentAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, ): SelectQueryBuilder { const relationResultName = `${parentAlias}$${relationField}`; const joinedQuery = this.buildRelationJSON( @@ -48,7 +48,7 @@ export abstract class LateralJoinDialectBase extends B qb: SelectQueryBuilder, relationField: string, parentAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, resultName: string, ) { const relationFieldDef = requireField(this.schema, model, relationField); @@ -158,7 +158,7 @@ export abstract class LateralJoinDialectBase extends B relationModelAlias: string, relationFieldDef: FieldDef, qb: SelectQueryBuilder, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, parentResultName: string, ) { qb = qb.select((eb) => { @@ -184,7 +184,7 @@ export abstract class LateralJoinDialectBase extends B relationModel: string, relationModelAlias: string, eb: ExpressionBuilder, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, parentResultName: string, ) { const relationModelDef = requireModel(this.schema, relationModel); @@ -264,7 +264,7 @@ export abstract class LateralJoinDialectBase extends B query: SelectQueryBuilder, relationModel: string, relationModelAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, parentResultName: string, ) { let result = query; diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index 75dd25dcc..93d4f547d 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -183,7 +183,7 @@ export class SqliteCrudDialect extends BaseCrudDialect model: string, relationField: string, parentAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, ): SelectQueryBuilder { return query.select((eb) => this.buildRelationJSON(model, eb, relationField, parentAlias, payload).as(relationField), @@ -195,7 +195,7 @@ export class SqliteCrudDialect extends BaseCrudDialect eb: ExpressionBuilder, relationField: string, parentAlias: string, - payload: true | FindArgs, true>, + payload: true | FindArgs, any, true>, ) { const relationFieldDef = requireField(this.schema, model, relationField); const relationModel = relationFieldDef.type as GetModels; diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index fc75cac9d..78dffd843 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -272,7 +272,7 @@ export abstract class BaseOperationHandler { protected async read( kysely: AnyKysely, model: string, - args: FindArgs, true> | undefined, + args: FindArgs, any, true> | undefined, ): Promise { // table let query = this.dialect.buildSelectModel(model, model); @@ -310,7 +310,7 @@ export abstract class BaseOperationHandler { return result; } - protected async readUnique(kysely: AnyKysely, model: string, args: FindArgs, true>) { + protected async readUnique(kysely: AnyKysely, model: string, args: FindArgs, any, true>) { const result = await this.read(kysely, model, { ...args, take: 1 }); return result[0] ?? null; } @@ -1137,7 +1137,7 @@ export abstract class BaseOperationHandler { const parentWhere = await this.buildUpdateParentRelationFilter(kysely, fromRelation); - let combinedWhere: WhereInput, false> = where ?? {}; + let combinedWhere: WhereInput, any, false> = where ?? {}; if (Object.keys(parentWhere).length > 0) { combinedWhere = Object.keys(combinedWhere).length > 0 ? { AND: [parentWhere, combinedWhere] } : parentWhere; } @@ -1538,7 +1538,7 @@ export abstract class BaseOperationHandler { } const parentWhere = await this.buildUpdateParentRelationFilter(kysely, fromRelation); - let combinedWhere: WhereInput, false> = where ?? {}; + let combinedWhere: WhereInput, any, false> = where ?? {}; if (Object.keys(parentWhere).length > 0) { combinedWhere = Object.keys(combinedWhere).length > 0 ? { AND: [parentWhere, combinedWhere] } : parentWhere; } diff --git a/packages/orm/src/client/crud/operations/create.ts b/packages/orm/src/client/crud/operations/create.ts index eeb0802d5..af3c85994 100644 --- a/packages/orm/src/client/crud/operations/create.ts +++ b/packages/orm/src/client/crud/operations/create.ts @@ -1,6 +1,5 @@ import { match } from 'ts-pattern'; -import type { GetModels, SchemaDef } from '../../../schema'; -import type { CreateArgs, CreateManyAndReturnArgs, CreateManyArgs, WhereInput } from '../../crud-types'; +import type { SchemaDef } from '../../../schema'; import { createRejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; import { getIdValues } from '../../query-utils'; import { BaseOperationHandler } from './base'; @@ -23,7 +22,7 @@ export class CreateOperationHandler extends BaseOperat .exhaustive(); } - private async runCreate(args: CreateArgs>) { + private async runCreate(args: any) { // analyze if we need to read back the created record, or just return the create result const { needReadBack, selectedFields } = this.mutationNeedsReadBack(this.model, args); @@ -36,11 +35,7 @@ export class CreateOperationHandler extends BaseOperat select: args.select, include: args.include, omit: args.omit, - where: getIdValues(this.schema, this.model, createResult) as WhereInput< - Schema, - GetModels, - false - >, + where: getIdValues(this.schema, this.model, createResult) as any, }); } else { return createResult; @@ -58,7 +53,7 @@ export class CreateOperationHandler extends BaseOperat return result; } - private runCreateMany(args?: CreateManyArgs>) { + private runCreateMany(args?: any) { if (args === undefined) { return { count: 0 }; } @@ -66,7 +61,7 @@ export class CreateOperationHandler extends BaseOperat return this.safeTransaction((tx) => this.createMany(tx, this.model, args, false)); } - private async runCreateManyAndReturn(args?: CreateManyAndReturnArgs>) { + private async runCreateManyAndReturn(args?: any) { if (args === undefined) { return []; } diff --git a/packages/orm/src/client/crud/operations/delete.ts b/packages/orm/src/client/crud/operations/delete.ts index e0c3875b5..295eba799 100644 --- a/packages/orm/src/client/crud/operations/delete.ts +++ b/packages/orm/src/client/crud/operations/delete.ts @@ -1,6 +1,5 @@ import { match } from 'ts-pattern'; import type { SchemaDef } from '../../../schema'; -import type { DeleteArgs, DeleteManyArgs } from '../../crud-types'; import { createNotFoundError, createRejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; import { BaseOperationHandler } from './base'; @@ -17,7 +16,7 @@ export class DeleteOperationHandler extends BaseOperat .exhaustive(); } - async runDelete(args: DeleteArgs>) { + async runDelete(args: any) { // analyze if we need to read back the deleted record, or just return delete result const { needReadBack, selectedFields } = this.mutationNeedsReadBack(this.model, args); @@ -51,7 +50,7 @@ export class DeleteOperationHandler extends BaseOperat return result; } - async runDeleteMany(args: DeleteManyArgs> | undefined) { + async runDeleteMany(args?: any) { return await this.safeTransaction(async (tx) => { const result = await this.delete(tx, this.model, args?.where, args?.limit); return { count: Number(result.numAffectedRows ?? 0) }; diff --git a/packages/orm/src/client/crud/operations/find.ts b/packages/orm/src/client/crud/operations/find.ts index db087a3b5..7bf56b8f5 100644 --- a/packages/orm/src/client/crud/operations/find.ts +++ b/packages/orm/src/client/crud/operations/find.ts @@ -1,5 +1,4 @@ -import type { GetModels, SchemaDef } from '../../../schema'; -import type { FindArgs } from '../../crud-types'; +import type { SchemaDef } from '../../../schema'; import { BaseOperationHandler, type CoreCrudOperations } from './base'; export class FindOperationHandler extends BaseOperationHandler { @@ -12,7 +11,7 @@ export class FindOperationHandler extends BaseOperatio // parse args let parsedArgs = validateArgs ? this.inputValidator.validateFindArgs(this.model, normalizedArgs, operation) - : (normalizedArgs as FindArgs, true> | undefined); + : (normalizedArgs as any); if (findOne) { // ensure "limit 1" diff --git a/packages/orm/src/client/crud/operations/update.ts b/packages/orm/src/client/crud/operations/update.ts index d8bd57b53..424945708 100644 --- a/packages/orm/src/client/crud/operations/update.ts +++ b/packages/orm/src/client/crud/operations/update.ts @@ -1,6 +1,6 @@ import { match } from 'ts-pattern'; import type { GetModels, SchemaDef } from '../../../schema'; -import type { UpdateArgs, UpdateManyAndReturnArgs, UpdateManyArgs, UpsertArgs, WhereInput } from '../../crud-types'; +import type { WhereInput } from '../../crud-types'; import { createRejectedByPolicyError, RejectedByPolicyReason } from '../../errors'; import { getIdValues } from '../../query-utils'; import { BaseOperationHandler } from './base'; @@ -24,7 +24,7 @@ export class UpdateOperationHandler extends BaseOperat .exhaustive(); } - private async runUpdate(args: UpdateArgs>) { + private async runUpdate(args: any) { // analyze if we need to read back the update record, or just return the updated result const { needReadBack, selectedFields } = this.needReadBack(args); @@ -50,7 +50,7 @@ export class UpdateOperationHandler extends BaseOperat select: args.select, include: args.include, omit: args.omit, - where: readFilter as WhereInput, false>, + where: readFilter, }); return readBackResult; } else { @@ -77,14 +77,14 @@ export class UpdateOperationHandler extends BaseOperat } } - private async runUpdateMany(args: UpdateManyArgs>) { + private async runUpdateMany(args: any) { // TODO: avoid using transaction for simple update return this.safeTransaction(async (tx) => { return this.updateMany(tx, this.model, args.where, args.data, args.limit, false); }); } - private async runUpdateManyAndReturn(args: UpdateManyAndReturnArgs> | undefined) { + private async runUpdateManyAndReturn(args: any) { if (!args) { return []; } @@ -136,7 +136,7 @@ export class UpdateOperationHandler extends BaseOperat return readBackResult; } - private async runUpsert(args: UpsertArgs>) { + private async runUpsert(args: any) { // analyze if we need to read back the updated record, or just return the update result const { needReadBack, selectedFields } = this.needReadBack(args); @@ -165,6 +165,7 @@ export class UpdateOperationHandler extends BaseOperat where: getIdValues(this.schema, this.model, mutationResult) as WhereInput< Schema, GetModels, + any, false >, }); diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 8cad792e9..88bf0cfd1 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -1,4 +1,4 @@ -import { enumerate, invariant } from '@zenstackhq/common-helpers'; +import { enumerate, invariant, lowerCaseFirst } from '@zenstackhq/common-helpers'; import Decimal from 'decimal.js'; import { match, P } from 'ts-pattern'; import { z, ZodObject, ZodType } from 'zod'; @@ -13,7 +13,7 @@ import { } from '../../../schema'; import { extractFields } from '../../../utils/object-utils'; import { formatError } from '../../../utils/zod-utils'; -import { AGGREGATE_OPERATORS, LOGICAL_COMBINATORS, NUMERIC_FIELD_TYPES } from '../../constants'; +import { AggregateOperators, FILTER_PROPERTY_TO_KIND, LOGICAL_COMBINATORS, NUMERIC_FIELD_TYPES } from '../../constants'; import type { ClientContract } from '../../contract'; import { type AggregateArgs, @@ -63,8 +63,19 @@ import { type GetSchemaFunc = (model: GetModels) => ZodType; +/** + * Minimal field information needed for filter schema generation. + */ +type FieldInfo = { + name: string; + type: string; + optional?: boolean; + array?: boolean; +}; + export class InputValidator { private readonly schemaCache = new Map(); + private readonly allFilterKinds = [...new Set(Object.values(FILTER_PROPERTY_TO_KIND))]; constructor(private readonly client: ClientContract) {} @@ -86,8 +97,8 @@ export class InputValidator { model: GetModels, args: unknown, operation: CoreCrudOperations, - ): FindArgs, true> | undefined { - return this.validate, true> | undefined>( + ): FindArgs, any, true> | undefined { + return this.validate, any, true> | undefined>( model, operation, (model) => this.makeFindSchema(model, operation), @@ -95,8 +106,11 @@ export class InputValidator { ); } - validateExistsArgs(model: GetModels, args: unknown): ExistsArgs> | undefined { - return this.validate>>( + validateExistsArgs( + model: GetModels, + args: unknown, + ): ExistsArgs, any> | undefined { + return this.validate, any> | undefined>( model, 'exists', (model) => this.makeExistsSchema(model), @@ -104,8 +118,8 @@ export class InputValidator { ); } - validateCreateArgs(model: GetModels, args: unknown): CreateArgs> { - return this.validate>>( + validateCreateArgs(model: GetModels, args: unknown): CreateArgs, any> { + return this.validate, any>>( model, 'create', (model) => this.makeCreateSchema(model), @@ -125,8 +139,8 @@ export class InputValidator { validateCreateManyAndReturnArgs( model: GetModels, args: unknown, - ): CreateManyAndReturnArgs> | undefined { - return this.validate> | undefined>( + ): CreateManyAndReturnArgs, any> | undefined { + return this.validate, any> | undefined>( model, 'createManyAndReturn', (model) => this.makeCreateManyAndReturnSchema(model), @@ -134,8 +148,8 @@ export class InputValidator { ); } - validateUpdateArgs(model: GetModels, args: unknown): UpdateArgs> { - return this.validate>>( + validateUpdateArgs(model: GetModels, args: unknown): UpdateArgs, any> { + return this.validate, any>>( model, 'update', (model) => this.makeUpdateSchema(model), @@ -143,8 +157,8 @@ export class InputValidator { ); } - validateUpdateManyArgs(model: GetModels, args: unknown): UpdateManyArgs> { - return this.validate>>( + validateUpdateManyArgs(model: GetModels, args: unknown): UpdateManyArgs, any> { + return this.validate, any>>( model, 'updateMany', (model) => this.makeUpdateManySchema(model), @@ -155,8 +169,8 @@ export class InputValidator { validateUpdateManyAndReturnArgs( model: GetModels, args: unknown, - ): UpdateManyAndReturnArgs> { - return this.validate>>( + ): UpdateManyAndReturnArgs, any> { + return this.validate, any>>( model, 'updateManyAndReturn', (model) => this.makeUpdateManyAndReturnSchema(model), @@ -164,8 +178,8 @@ export class InputValidator { ); } - validateUpsertArgs(model: GetModels, args: unknown): UpsertArgs> { - return this.validate>>( + validateUpsertArgs(model: GetModels, args: unknown): UpsertArgs, any> { + return this.validate, any>>( model, 'upsert', (model) => this.makeUpsertSchema(model), @@ -173,8 +187,8 @@ export class InputValidator { ); } - validateDeleteArgs(model: GetModels, args: unknown): DeleteArgs> { - return this.validate>>( + validateDeleteArgs(model: GetModels, args: unknown): DeleteArgs, any> { + return this.validate, any>>( model, 'delete', (model) => this.makeDeleteSchema(model), @@ -185,8 +199,8 @@ export class InputValidator { validateDeleteManyArgs( model: GetModels, args: unknown, - ): DeleteManyArgs> | undefined { - return this.validate> | undefined>( + ): DeleteManyArgs, any> | undefined { + return this.validate, any> | undefined>( model, 'deleteMany', (model) => this.makeDeleteManySchema(model), @@ -194,8 +208,8 @@ export class InputValidator { ); } - validateCountArgs(model: GetModels, args: unknown): CountArgs> | undefined { - return this.validate> | undefined>( + validateCountArgs(model: GetModels, args: unknown): CountArgs, any> | undefined { + return this.validate, any> | undefined>( model, 'count', (model) => this.makeCountSchema(model), @@ -203,8 +217,8 @@ export class InputValidator { ); } - validateAggregateArgs(model: GetModels, args: unknown): AggregateArgs> { - return this.validate>>( + validateAggregateArgs(model: GetModels, args: unknown): AggregateArgs, any> { + return this.validate, any>>( model, 'aggregate', (model) => this.makeAggregateSchema(model), @@ -212,8 +226,8 @@ export class InputValidator { ); } - validateGroupByArgs(model: GetModels, args: unknown): GroupByArgs> { - return this.validate>>( + validateGroupByArgs(model: GetModels, args: unknown): GroupByArgs, any> { + return this.validate, any>>( model, 'groupBy', (model) => this.makeGroupBySchema(model), @@ -546,6 +560,17 @@ export class InputValidator { ): ZodType { const modelDef = requireModel(this.schema, model); + // unique field used in unique filters bypass filter slicing + const uniqueFieldNames = unique + ? getUniqueFields(this.schema, model) + .filter( + (uf): uf is { name: string; def: FieldDef } => + // single-field unique + 'def' in uf, + ) + .map((uf) => uf.name) + : undefined; + const fields: Record = {}; for (const field of Object.keys(modelDef.fields)) { const fieldDef = requireField(this.schema, model, field); @@ -555,55 +580,56 @@ export class InputValidator { if (withoutRelationFields) { continue; } - fieldSchema = z.lazy(() => this.makeWhereSchema(fieldDef.type, false).optional()); - - // optional to-one relation allows null - fieldSchema = this.nullableIf(fieldSchema, !fieldDef.array && !!fieldDef.optional); - if (fieldDef.array) { - // to-many relation - fieldSchema = z.union([ - fieldSchema, - z.strictObject({ - some: fieldSchema.optional(), - every: fieldSchema.optional(), - none: fieldSchema.optional(), - }), - ]); + // Check if Relation filter kind is allowed + const allowedFilterKinds = this.getEffectiveFilterKinds(model, field); + if (allowedFilterKinds && !allowedFilterKinds.includes('Relation')) { + // Relation filters are not allowed for this field - use z.never() + fieldSchema = z.never(); } else { - // to-one relation - fieldSchema = z.union([ - fieldSchema, - z.strictObject({ - is: fieldSchema.optional(), - isNot: fieldSchema.optional(), - }), - ]); + fieldSchema = z.lazy(() => this.makeWhereSchema(fieldDef.type, false).optional()); + + // optional to-one relation allows null + fieldSchema = this.nullableIf(fieldSchema, !fieldDef.array && !!fieldDef.optional); + + if (fieldDef.array) { + // to-many relation + fieldSchema = z.union([ + fieldSchema, + z.strictObject({ + some: fieldSchema.optional(), + every: fieldSchema.optional(), + none: fieldSchema.optional(), + }), + ]); + } else { + // to-one relation + fieldSchema = z.union([ + fieldSchema, + z.strictObject({ + is: fieldSchema.optional(), + isNot: fieldSchema.optional(), + }), + ]); + } } } else { + const ignoreSlicing = !!uniqueFieldNames?.includes(field); + const enumDef = getEnum(this.schema, fieldDef.type); if (enumDef) { // enum if (Object.keys(enumDef.values).length > 0) { - fieldSchema = this.makeEnumFilterSchema( - fieldDef.type, - !!fieldDef.optional, - withAggregations, - !!fieldDef.array, - ); + fieldSchema = this.makeEnumFilterSchema(model, fieldDef, withAggregations, ignoreSlicing); } } else if (fieldDef.array) { // array field - fieldSchema = this.makeArrayFilterSchema(fieldDef.type as BuiltinType); + fieldSchema = this.makeArrayFilterSchema(model, fieldDef); } else if (this.isTypeDefType(fieldDef.type)) { - fieldSchema = this.makeTypedJsonFilterSchema(fieldDef.type, !!fieldDef.optional, !!fieldDef.array); + fieldSchema = this.makeTypedJsonFilterSchema(model, fieldDef); } else { // primitive field - fieldSchema = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, - !!fieldDef.optional, - withAggregations, - ); + fieldSchema = this.makePrimitiveFilterSchema(model, fieldDef, withAggregations, ignoreSlicing); } } @@ -614,6 +640,7 @@ export class InputValidator { if (unique) { // add compound unique fields, e.g. `{ id1_id2: { id1: 1, id2: 1 } }` + // compound-field filters are not affected by slicing const uniqueFields = getUniqueFields(this.schema, model); for (const uniqueField of uniqueFields) { if ('defs' in uniqueField) { @@ -627,22 +654,12 @@ export class InputValidator { if (enumDef) { // enum if (Object.keys(enumDef.values).length > 0) { - fieldSchema = this.makeEnumFilterSchema( - def.type, - !!def.optional, - false, - false, - ); + fieldSchema = this.makeEnumFilterSchema(model, def, false, true); } else { fieldSchema = z.never(); } } else { - // regular field - fieldSchema = this.makePrimitiveFilterSchema( - def.type as BuiltinType, - !!def.optional, - false, - ); + fieldSchema = this.makePrimitiveFilterSchema(model, def, false, true); } return [key, fieldSchema]; }), @@ -697,7 +714,12 @@ export class InputValidator { } @cache() - private makeTypedJsonFilterSchema(type: string, optional: boolean, array: boolean) { + private makeTypedJsonFilterSchema(contextModel: string | undefined, fieldInfo: FieldInfo) { + const field = fieldInfo.name; + const type = fieldInfo.type; + const optional = !!fieldInfo.optional; + const array = !!fieldInfo.array; + const typeDef = getTypeDef(this.schema, type); invariant(typeDef, `Type definition "${type}" not found in schema`); @@ -708,28 +730,19 @@ export class InputValidator { const fieldSchemas: Record = {}; for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { if (this.isTypeDefType(fieldDef.type)) { - // recursive typed JSON - fieldSchemas[fieldName] = this.makeTypedJsonFilterSchema( - fieldDef.type, - !!fieldDef.optional, - !!fieldDef.array, - ).optional(); + // recursive typed JSON - use same model/field for nested typed JSON + fieldSchemas[fieldName] = this.makeTypedJsonFilterSchema(contextModel, fieldDef).optional(); } else { // enum, array, primitives const enumDef = getEnum(this.schema, fieldDef.type); if (enumDef) { - fieldSchemas[fieldName] = this.makeEnumFilterSchema( - fieldDef.type, - !!fieldDef.optional, - false, - !!fieldDef.array, - ).optional(); + fieldSchemas[fieldName] = this.makeEnumFilterSchema(contextModel, fieldDef, false).optional(); } else if (fieldDef.array) { - fieldSchemas[fieldName] = this.makeArrayFilterSchema(fieldDef.type as BuiltinType).optional(); + fieldSchemas[fieldName] = this.makeArrayFilterSchema(contextModel, fieldDef).optional(); } else { fieldSchemas[fieldName] = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, - !!fieldDef.optional, + contextModel, + fieldDef, false, ).optional(); } @@ -739,7 +752,9 @@ export class InputValidator { candidates.push(z.strictObject(fieldSchemas)); } - const recursiveSchema = z.lazy(() => this.makeTypedJsonFilterSchema(type, optional, false)).optional(); + const recursiveSchema = z + .lazy(() => this.makeTypedJsonFilterSchema(contextModel, { name: field, type, optional, array: false })) + .optional(); if (array) { // array filter candidates.push( @@ -760,7 +775,7 @@ export class InputValidator { } // plain json filter - candidates.push(this.makeJsonFilterSchema(optional)); + candidates.push(this.makeJsonFilterSchema(contextModel, field, optional)); if (optional) { // allow null as well @@ -776,49 +791,86 @@ export class InputValidator { } @cache() - private makeEnumFilterSchema(enumName: string, optional: boolean, withAggregations: boolean, array: boolean) { + private makeEnumFilterSchema( + model: string | undefined, + fieldInfo: FieldInfo, + withAggregations: boolean, + ignoreSlicing: boolean = false, + ) { + const enumName = fieldInfo.type; + const optional = !!fieldInfo.optional; + const array = !!fieldInfo.array; + const enumDef = getEnum(this.schema, enumName); invariant(enumDef, `Enum "${enumName}" not found in schema`); const baseSchema = z.enum(Object.keys(enumDef.values) as [string, ...string[]]); if (array) { - return this.internalMakeArrayFilterSchema(baseSchema); + return this.internalMakeArrayFilterSchema(model, fieldInfo.name, baseSchema); } + const allowedFilterKinds = ignoreSlicing ? undefined : this.getEffectiveFilterKinds(model, fieldInfo.name); const components = this.makeCommonPrimitiveFilterComponents( baseSchema, optional, - () => z.lazy(() => this.makeEnumFilterSchema(enumName, optional, withAggregations, array)), + () => z.lazy(() => this.makeEnumFilterSchema(model, fieldInfo, withAggregations)), ['equals', 'in', 'notIn', 'not'], withAggregations ? ['_count', '_min', '_max'] : undefined, + allowedFilterKinds, ); - return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); + + return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds); } @cache() - private makeArrayFilterSchema(type: BuiltinType) { - return this.internalMakeArrayFilterSchema(this.makeScalarSchema(type)); + private makeArrayFilterSchema(model: string | undefined, fieldInfo: FieldInfo) { + return this.internalMakeArrayFilterSchema( + model, + fieldInfo.name, + this.makeScalarSchema(fieldInfo.type as BuiltinType), + ); } - private internalMakeArrayFilterSchema(elementSchema: ZodType) { - return z.strictObject({ + private internalMakeArrayFilterSchema(contextModel: string | undefined, field: string, elementSchema: ZodType) { + const allowedFilterKinds = this.getEffectiveFilterKinds(contextModel, field); + const operators = { equals: elementSchema.array().optional(), has: elementSchema.optional(), hasEvery: elementSchema.array().optional(), hasSome: elementSchema.array().optional(), isEmpty: z.boolean().optional(), - }); + }; + + // Filter operators based on allowed filter kinds + const filteredOperators = this.trimFilterOperators(operators, allowedFilterKinds); + + return z.strictObject(filteredOperators); } @cache() - private makePrimitiveFilterSchema(type: BuiltinType, optional: boolean, withAggregations: boolean) { + private makePrimitiveFilterSchema( + contextModel: string | undefined, + fieldInfo: FieldInfo, + withAggregations: boolean, + ignoreSlicing = false, + ) { + const allowedFilterKinds = ignoreSlicing + ? undefined + : this.getEffectiveFilterKinds(contextModel, fieldInfo.name); + const type = fieldInfo.type as BuiltinType; + const optional = !!fieldInfo.optional; return match(type) - .with('String', () => this.makeStringFilterSchema(optional, withAggregations)) + .with('String', () => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds)) .with(P.union('Int', 'Float', 'Decimal', 'BigInt'), (type) => - this.makeNumberFilterSchema(this.makeScalarSchema(type), optional, withAggregations), + this.makeNumberFilterSchema( + this.makeScalarSchema(type), + optional, + withAggregations, + allowedFilterKinds, + ), ) - .with('Boolean', () => this.makeBooleanFilterSchema(optional, withAggregations)) - .with('DateTime', () => this.makeDateTimeFilterSchema(optional, withAggregations)) - .with('Bytes', () => this.makeBytesFilterSchema(optional, withAggregations)) - .with('Json', () => this.makeJsonFilterSchema(optional)) + .with('Boolean', () => this.makeBooleanFilterSchema(optional, withAggregations, allowedFilterKinds)) + .with('DateTime', () => this.makeDateTimeFilterSchema(optional, withAggregations, allowedFilterKinds)) + .with('Bytes', () => this.makeBytesFilterSchema(optional, withAggregations, allowedFilterKinds)) + .with('Json', () => this.makeJsonFilterSchema(contextModel, fieldInfo.name, optional)) .with('Unsupported', () => z.never()) .exhaustive(); } @@ -851,7 +903,15 @@ export class InputValidator { } @cache() - private makeJsonFilterSchema(optional: boolean) { + private makeJsonFilterSchema(contextModel: string | undefined, field: string, optional: boolean) { + const allowedFilterKinds = this.getEffectiveFilterKinds(contextModel, field); + + // Check if Json filter kind is allowed + if (allowedFilterKinds && !allowedFilterKinds.includes('Json')) { + // Return a never schema if Json filters are not allowed + return z.never(); + } + const valueSchema = this.makeJsonValueSchema(optional, true); return z.strictObject({ path: z.string().optional(), @@ -868,29 +928,44 @@ export class InputValidator { } @cache() - private makeDateTimeFilterSchema(optional: boolean, withAggregations: boolean): ZodType { + private makeDateTimeFilterSchema( + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: string[] | undefined, + ): ZodType { return this.makeCommonPrimitiveFilterSchema( z.union([z.iso.datetime(), z.date()]), optional, - () => z.lazy(() => this.makeDateTimeFilterSchema(optional, withAggregations)), + () => z.lazy(() => this.makeDateTimeFilterSchema(optional, withAggregations, allowedFilterKinds)), withAggregations ? ['_count', '_min', '_max'] : undefined, + allowedFilterKinds, ); } @cache() - private makeBooleanFilterSchema(optional: boolean, withAggregations: boolean): ZodType { + private makeBooleanFilterSchema( + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: string[] | undefined, + ): ZodType { const components = this.makeCommonPrimitiveFilterComponents( z.boolean(), optional, - () => z.lazy(() => this.makeBooleanFilterSchema(optional, withAggregations)), + () => z.lazy(() => this.makeBooleanFilterSchema(optional, withAggregations, allowedFilterKinds)), ['equals', 'not'], withAggregations ? ['_count', '_min', '_max'] : undefined, + allowedFilterKinds, ); - return z.union([this.nullableIf(z.boolean(), optional), z.strictObject(components)]); + + return this.createUnionFilterSchema(z.boolean(), optional, components, allowedFilterKinds); } @cache() - private makeBytesFilterSchema(optional: boolean, withAggregations: boolean): ZodType { + private makeBytesFilterSchema( + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: string[] | undefined, + ): ZodType { const baseSchema = z.instanceof(Uint8Array); const components = this.makeCommonPrimitiveFilterComponents( baseSchema, @@ -898,8 +973,10 @@ export class InputValidator { () => z.instanceof(Uint8Array), ['equals', 'in', 'notIn', 'not'], withAggregations ? ['_count', '_min', '_max'] : undefined, + allowedFilterKinds, ); - return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); + + return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds); } private makeCommonPrimitiveFilterComponents( @@ -908,12 +985,12 @@ export class InputValidator { makeThis: () => ZodType, supportedOperators: string[] | undefined = undefined, withAggregations: Array<'_count' | '_avg' | '_sum' | '_min' | '_max'> | undefined = undefined, + allowedFilterKinds: string[] | undefined = undefined, ) { const commonAggSchema = () => - this.makeCommonPrimitiveFilterSchema(baseSchema, false, makeThis, undefined).optional(); + this.makeCommonPrimitiveFilterSchema(baseSchema, false, makeThis, undefined, allowedFilterKinds).optional(); let result = { equals: this.nullableIf(baseSchema.optional(), optional), - notEquals: this.nullableIf(baseSchema.optional(), optional), in: baseSchema.array().optional(), notIn: baseSchema.array().optional(), lt: baseSchema.optional(), @@ -923,7 +1000,7 @@ export class InputValidator { between: baseSchema.array().length(2).optional(), not: makeThis().optional(), ...(withAggregations?.includes('_count') - ? { _count: this.makeNumberFilterSchema(z.number().int(), false, false).optional() } + ? { _count: this.makeNumberFilterSchema(z.number().int(), false, false, undefined).optional() } : {}), ...(withAggregations?.includes('_avg') ? { _avg: commonAggSchema() } : {}), ...(withAggregations?.includes('_sum') ? { _sum: commonAggSchema() } : {}), @@ -934,6 +1011,10 @@ export class InputValidator { const keys = [...supportedOperators, ...(withAggregations ?? [])]; result = extractFields(result, keys) as typeof result; } + + // Filter operators based on allowed filter kinds + result = this.trimFilterOperators(result, allowedFilterKinds) as typeof result; + return result; } @@ -941,46 +1022,70 @@ export class InputValidator { baseSchema: ZodType, optional: boolean, makeThis: () => ZodType, - withAggregations: Array | undefined = undefined, + withAggregations: Array | undefined = undefined, + allowedFilterKinds: string[] | undefined = undefined, ): z.ZodType { - return z.union([ - this.nullableIf(baseSchema, optional), - z.strictObject( - this.makeCommonPrimitiveFilterComponents(baseSchema, optional, makeThis, undefined, withAggregations), - ), - ]); + const components = this.makeCommonPrimitiveFilterComponents( + baseSchema, + optional, + makeThis, + undefined, + withAggregations, + allowedFilterKinds, + ); + + return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds); } - private makeNumberFilterSchema(baseSchema: ZodType, optional: boolean, withAggregations: boolean): ZodType { + private makeNumberFilterSchema( + baseSchema: ZodType, + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: string[] | undefined, + ): ZodType { return this.makeCommonPrimitiveFilterSchema( baseSchema, optional, - () => z.lazy(() => this.makeNumberFilterSchema(baseSchema, optional, withAggregations)), + () => z.lazy(() => this.makeNumberFilterSchema(baseSchema, optional, withAggregations, allowedFilterKinds)), withAggregations ? ['_count', '_avg', '_sum', '_min', '_max'] : undefined, + allowedFilterKinds, ); } - private makeStringFilterSchema(optional: boolean, withAggregations: boolean): ZodType { - return z.union([ - this.nullableIf(z.string(), optional), - z.strictObject({ - ...this.makeCommonPrimitiveFilterComponents( - z.string(), - optional, - () => z.lazy(() => this.makeStringFilterSchema(optional, withAggregations)), - undefined, - withAggregations ? ['_count', '_min', '_max'] : undefined, - ), - startsWith: z.string().optional(), - endsWith: z.string().optional(), - contains: z.string().optional(), - ...(this.providerSupportsCaseSensitivity - ? { - mode: this.makeStringModeSchema().optional(), - } - : {}), - }), - ]); + private makeStringFilterSchema( + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: string[] | undefined, + ): ZodType { + const baseComponents = this.makeCommonPrimitiveFilterComponents( + z.string(), + optional, + () => z.lazy(() => this.makeStringFilterSchema(optional, withAggregations, allowedFilterKinds)), + undefined, + withAggregations ? ['_count', '_min', '_max'] : undefined, + allowedFilterKinds, + ); + + const stringSpecificOperators = { + startsWith: z.string().optional(), + endsWith: z.string().optional(), + contains: z.string().optional(), + ...(this.providerSupportsCaseSensitivity + ? { + mode: this.makeStringModeSchema().optional(), + } + : {}), + }; + + // Filter string-specific operators based on allowed filter kinds + const filteredStringOperators = this.trimFilterOperators(stringSpecificOperators, allowedFilterKinds); + + const allComponents = { + ...baseComponents, + ...filteredStringOperators, + }; + + return this.createUnionFilterSchema(z.string(), optional, allComponents, allowedFilterKinds); } private makeStringModeSchema() { @@ -994,7 +1099,10 @@ export class InputValidator { for (const field of Object.keys(modelDef.fields)) { const fieldDef = requireField(this.schema, model, field); if (fieldDef.relation) { - fields[field] = this.makeRelationSelectIncludeSchema(model, field).optional(); + // Check if the target model is allowed by slicing configuration + if (this.isModelAllowed(fieldDef.type)) { + fields[field] = this.makeRelationSelectIncludeSchema(model, field).optional(); + } } else { fields[field] = z.boolean().optional(); } @@ -1109,7 +1217,10 @@ export class InputValidator { for (const field of Object.keys(modelDef.fields)) { const fieldDef = requireField(this.schema, model, field); if (fieldDef.relation) { - fields[field] = this.makeRelationSelectIncludeSchema(model, field).optional(); + // Check if the target model is allowed by slicing configuration + if (this.isModelAllowed(fieldDef.type)) { + fields[field] = this.makeRelationSelectIncludeSchema(model, field).optional(); + } } } @@ -1249,6 +1360,10 @@ export class InputValidator { if (withoutRelationFields) { return; } + // Check if the target model is allowed by slicing configuration + if (!this.isModelAllowed(fieldDef.type)) { + return; + } const excludeFields: string[] = []; const oppositeField = fieldDef.relation.opposite; if (oppositeField) { @@ -1562,6 +1677,10 @@ export class InputValidator { if (withoutRelationFields) { return; } + // Check if the target model is allowed by slicing configuration + if (!this.isModelAllowed(fieldDef.type)) { + return; + } const excludeFields: string[] = []; const oppositeField = fieldDef.relation.opposite; if (oppositeField) { @@ -1801,7 +1920,7 @@ export class InputValidator { const bys = typeof value.by === 'string' ? [value.by] : value.by; if (value.having && typeof value.having === 'object') { for (const [key, val] of Object.entries(value.having)) { - if (AGGREGATE_OPERATORS.includes(key as any)) { + if (AggregateOperators.includes(key as any)) { continue; } if (bys.includes(key)) { @@ -1829,7 +1948,7 @@ export class InputValidator { if ( value.orderBy && Object.keys(value.orderBy) - .filter((f) => !AGGREGATE_OPERATORS.includes(f as AGGREGATE_OPERATORS)) + .filter((f) => !AggregateOperators.includes(f as AggregateOperators)) .some((key) => !bys.includes(key)) ) { return false; @@ -1843,7 +1962,7 @@ export class InputValidator { private onlyAggregationFields(val: object) { for (const [key, value] of Object.entries(val)) { - if (AGGREGATE_OPERATORS.includes(key as any)) { + if (AggregateOperators.includes(key as any)) { // aggregation field continue; } @@ -1965,5 +2084,171 @@ export class InputValidator { return this.schema.provider.type === 'postgresql'; } + /** + * Gets the effective set of allowed FilterKind values for a specific model and field. + * Respects the precedence: model[field] > model.$all > $all[field] > $all.$all. + */ + private getEffectiveFilterKinds(model: string | undefined, field: string): string[] | undefined { + if (!model) { + // no restrictions + return undefined; + } + + const slicing = this.options.slicing; + if (!slicing?.models) { + // no slicing or no model-specific slicing, no restrictions + return undefined; + } + + // A string-indexed view of slicing.models that avoids unsafe 'as any' while still + // allowing runtime access by model name. The value shape matches FieldSlicingOptions. + type FieldConfig = { includedFilterKinds?: readonly string[]; excludedFilterKinds?: readonly string[] }; + type FieldsRecord = { $all?: FieldConfig } & Record; + type ModelConfig = { fields?: FieldsRecord }; + const modelsRecord = slicing.models as Record; + + // Check field-level settings for the specific model + const modelConfig = modelsRecord[lowerCaseFirst(model)]; + if (modelConfig?.fields) { + const fieldConfig = modelConfig.fields[field]; + if (fieldConfig) { + return this.computeFilterKinds(fieldConfig.includedFilterKinds, fieldConfig.excludedFilterKinds); + } + + // Fallback to field-level $all for the specific model + const allFieldsConfig = modelConfig.fields['$all']; + if (allFieldsConfig) { + return this.computeFilterKinds( + allFieldsConfig.includedFilterKinds, + allFieldsConfig.excludedFilterKinds, + ); + } + } + + // Fallback to model-level $all + const allModelsConfig = modelsRecord['$all']; + if (allModelsConfig?.fields) { + // Check specific field in $all model config before falling back to $all.$all + const allModelsFieldConfig = allModelsConfig.fields[field]; + if (allModelsFieldConfig) { + return this.computeFilterKinds( + allModelsFieldConfig.includedFilterKinds, + allModelsFieldConfig.excludedFilterKinds, + ); + } + + // Fallback to $all.$all + const allModelsAllFieldsConfig = allModelsConfig.fields['$all']; + if (allModelsAllFieldsConfig) { + return this.computeFilterKinds( + allModelsAllFieldsConfig.includedFilterKinds, + allModelsAllFieldsConfig.excludedFilterKinds, + ); + } + } + + return undefined; // No restrictions + } + + /** + * Computes the effective set of filter kinds based on inclusion and exclusion lists. + */ + private computeFilterKinds(included: readonly string[] | undefined, excluded: readonly string[] | undefined) { + let result: string[] | undefined; + + if (included !== undefined) { + // Start with the included set + result = [...included]; + } + + if (excluded !== undefined) { + if (!result) { + // If no inclusion list, start with all filter kinds + result = [...this.allFilterKinds]; + } + // Remove excluded kinds + for (const kind of excluded) { + result = result.filter((k) => k !== kind); + } + } + + return result; + } + + /** + * Filters operators based on allowed filter kinds. + */ + private trimFilterOperators>( + operators: T, + allowedKinds: string[] | undefined, + ): Partial { + if (!allowedKinds) { + return operators; // No restrictions + } + + return Object.fromEntries( + Object.entries(operators).filter(([key, _]) => { + return ( + !(key in FILTER_PROPERTY_TO_KIND) || + allowedKinds.includes(FILTER_PROPERTY_TO_KIND[key as keyof typeof FILTER_PROPERTY_TO_KIND]) + ); + }), + ) as Partial; + } + + private createUnionFilterSchema( + valueSchema: ZodType, + optional: boolean, + components: Record, + allowedFilterKinds: string[] | undefined, + ) { + // If all filter operators are excluded + if (Object.keys(components).length === 0) { + // if equality filters are allowed, allow direct value + if (!allowedFilterKinds || allowedFilterKinds.includes('Equality')) { + return this.nullableIf(valueSchema, optional); + } + // otherwise nothing is allowed + return z.never(); + } + + if (!allowedFilterKinds || allowedFilterKinds.includes('Equality')) { + // direct value or filter operators + return z.union([this.nullableIf(valueSchema, optional), z.strictObject(components)]); + } else { + // filter operators + return z.strictObject(components); + } + } + + /** + * Checks if a model is included in the slicing configuration. + * Returns true if the model is allowed, false if it's excluded. + */ + private isModelAllowed(targetModel: string): boolean { + const slicing = this.options.slicing; + if (!slicing) { + return true; // No slicing, all models allowed + } + + const { includedModels, excludedModels } = slicing; + + // If includedModels is specified, only those models are allowed + if (includedModels !== undefined) { + if (!includedModels.includes(targetModel as any)) { + return false; + } + } + + // If excludedModels is specified, those models are not allowed + if (excludedModels !== undefined) { + if (excludedModels.includes(targetModel as any)) { + return false; + } + } + + return true; + } + // #endregion } diff --git a/packages/orm/src/client/index.ts b/packages/orm/src/client/index.ts index 00bbf1b6d..25015c353 100644 --- a/packages/orm/src/client/index.ts +++ b/packages/orm/src/client/index.ts @@ -20,3 +20,4 @@ export * from './plugin'; export type { ZenStackPromise } from './promise'; export type { ToKysely } from './query-builder'; export * as QueryUtils from './query-utils'; +export type * from './type-utils'; diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 6439e3996..80aa82261 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,9 +1,11 @@ import type { Dialect, Expression, ExpressionBuilder, KyselyConfig } from 'kysely'; import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, SchemaDef } from '../schema'; import type { PrependParameter } from '../utils/type-utils'; +import type { FilterPropertyToKind } from './constants'; import type { ClientContract, CRUD_EXT } from './contract'; import type { GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; +import type { AllCrudOperations } from './crud/operations/base'; import type { AnyPlugin } from './plugin'; import type { ToKyselySchema } from './query-builder'; @@ -40,10 +42,125 @@ export type ZModelFunction = ( context: ZModelFunctionContext, ) => Expression; +/** + * Options for slicing ORM client's capabilities by including/excluding certain models, operations, + * filters, etc. + */ +export type SlicingOptions = { + /** + * Models to include in the client. If not specified, all models are included by default. + */ + includedModels?: readonly GetModels[]; + + /** + * Models to exclude from the client. Exclusion takes precedence over inclusion. + */ + excludedModels?: readonly GetModels[]; + + /** + * Model slicing options. + */ + models?: { + /** + * Model-specific slicing options. + */ + [Model in GetModels as Uncapitalize]?: ModelSlicingOptions; + } & { + /** + * Slicing options that apply to all models. Model-specific options will override these general + * options if both are specified. + */ + $all?: ModelSlicingOptions>; + }; + + /** + * Procedures to include in the client. If not specified, all procedures are included by default. + */ + includedProcedures?: readonly GetProcedureNames[]; + + /** + * Procedures to exclude from the client. Exclusion takes precedence over inclusion. + */ + excludedProcedures?: readonly GetProcedureNames[]; +}; + +/** + * Kinds of filter operations. + */ +export type FilterKind = FilterPropertyToKind[keyof FilterPropertyToKind]; + +/** + * Model slicing options. + */ +export type ModelSlicingOptions> = { + /** + * ORM query operations to include for the model. If not specified, all operations are included + * by default. + */ + includedOperations?: readonly AllCrudOperations[]; + + /** + * ORM query operations to exclude for the model. Exclusion takes precedence over inclusion. + */ + excludedOperations?: readonly AllCrudOperations[]; + + /** + * Field-level slicing options. + */ + fields?: { + /** + * Field-specific slicing options. + */ + [Field in GetModelFields]?: FieldSlicingOptions; + } & { + /** + * Field slicing options that apply to all fields. Field-specific options will override these + * general options if both are specified. + */ + $all?: FieldSlicingOptions; + }; +}; + +/** + * Field slicing options. + */ +type FieldSlicingOptions = { + /** + * Filter kinds to include for the field. If not specified, all filter kinds are included by default. + */ + includedFilterKinds?: readonly FilterKind[]; + + /** + * Filter kinds to exclude for the field. Exclusion takes precedence over inclusion. + */ + excludedFilterKinds?: readonly FilterKind[]; +}; + +/** + * Partial ORM client options that defines customizable behaviors. + */ +export type QueryOptions = { + /** + * Options for omitting fields in ORM query results. + */ + omit?: OmitConfig; + + /** + * Whether to allow overriding omit settings at query time. Defaults to `true`. When set to `false`, a + * query-time `omit` clause that sets the field to `false` (not omitting) will trigger a validation error. + */ + allowQueryTimeOmitOverride?: boolean; + + /** + * Options for slicing ORM client's capabilities by including/excluding certain models, operations, filters, etc. + */ + slicing?: SlicingOptions; +}; + /** * ZenStack client options. */ -export type ClientOptions = { +export type ClientOptions = QueryOptions & { /** * Kysely dialect. */ @@ -81,26 +198,14 @@ export type ClientOptions = { * `@@validate`, etc. Defaults to `true`. */ validateInput?: boolean; - - /** - * Options for omitting fields in ORM query results. - */ - omit?: OmitConfig; - - /** - * Whether to allow overriding omit settings at query time. Defaults to `true`. When set to - * `false`, an `omit` clause that sets field to `false` (not omitting) will trigger a validation - * error. - */ - allowQueryTimeOmitOverride?: boolean; } & (HasComputedFields extends true - ? { - /** - * Computed field definitions. - */ - computedFields: ComputedFieldsOptions; - } - : {}) & + ? { + /** + * Computed field definitions. + */ + computedFields: ComputedFieldsOptions; + } + : {}) & (HasProcedures extends true ? { /** @@ -146,11 +251,6 @@ export type HasProcedures = Schema extends { : false; /** - * Subset of client options relevant to query operations. - */ -export type QueryOptions = Pick, 'omit'>; - -/** - * Extract QueryOptions from ClientOptions + * Extracts QueryOptions from an object with '$options' property. */ -export type ToQueryOptions> = Pick; +export type GetQueryOptions = T['$options']; diff --git a/packages/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index 66e41c404..fb9c39bea 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -11,7 +11,7 @@ import { import { match } from 'ts-pattern'; import { ExpressionUtils, type FieldDef, type GetModels, type ModelDef, type SchemaDef } from '../schema'; import { extractFields } from '../utils/object-utils'; -import type { AGGREGATE_OPERATORS } from './constants'; +import type { AggregateOperators } from './constants'; import { createInternalError } from './errors'; export function hasModel(schema: SchemaDef, model: string) { @@ -386,7 +386,7 @@ export function getDelegateDescendantModels( return [...collected]; } -export function aggregate(eb: ExpressionBuilder, expr: Expression, op: AGGREGATE_OPERATORS) { +export function aggregate(eb: ExpressionBuilder, expr: Expression, op: AggregateOperators) { return match(op) .with('_count', () => eb.fn.count(expr)) .with('_sum', () => eb.fn.sum(expr)) diff --git a/packages/orm/src/client/type-utils.ts b/packages/orm/src/client/type-utils.ts new file mode 100644 index 000000000..3a4589f71 --- /dev/null +++ b/packages/orm/src/client/type-utils.ts @@ -0,0 +1,262 @@ +import type { GetModels, SchemaDef } from '@zenstackhq/schema'; +import type { GetProcedureNames } from './crud-types'; +import type { AllCrudOperations } from './crud/operations/base'; +import type { FilterKind, QueryOptions, SlicingOptions } from './options'; + +type IsNever = [T] extends [never] ? true : false; + +// #region Model slicing + +/** + * Filters models based on slicing configuration. + */ +export type GetSlicedModels< + Schema extends SchemaDef, + Options extends QueryOptions, +> = Options['slicing'] extends infer S + ? S extends SlicingOptions + ? S['includedModels'] extends readonly GetModels[] + ? // includedModels is specified, start with only those + Exclude< + Extract>, + S['excludedModels'] extends readonly GetModels[] ? S['excludedModels'][number] : never + > + : // includedModels not specified, start with all models + Exclude< + GetModels, + S['excludedModels'] extends readonly GetModels[] ? S['excludedModels'][number] : never + > + : // No slicing config, include all models + GetModels + : GetModels; + +// #endregion + +// #region Operation slicing + +/** + * Filters query operations based on slicing configuration for a specific model. + */ +export type GetSlicedOperations< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = Options['slicing'] extends infer Slicing + ? Slicing extends SlicingOptions + ? GetIncludedOperations extends infer IO + ? GetExcludedOperations extends infer EO + ? IO extends '_none_' + ? // special case for empty includeOperations array - exclude all operations + never + : IsNever extends false + ? // includedOperations is specified, use those minus any excludedOperations + Exclude + : // includedOperations not specified, use all operations minus any excludedOperations + Exclude + : AllCrudOperations + : AllCrudOperations + : AllCrudOperations + : AllCrudOperations; + +export type GetIncludedOperations< + Slicing extends SlicingOptions, + Model extends string, +> = 'models' extends keyof Slicing + ? Slicing extends { models: infer Config } + ? Uncapitalize extends keyof Config + ? 'includedOperations' extends keyof Config[Uncapitalize] + ? // 'includedOperations' is specified for the model + Config[Uncapitalize] extends { includedOperations: readonly [] } + ? // special marker for empty array (mute all) + '_none_' + : // use the specified includedOperations + Config[Uncapitalize] extends { includedOperations: readonly (infer IO)[] } + ? IO + : never + : // fallback to $all if 'includedOperations' not specified for the model + GetAllIncludedOperations + : // fallback to $all if model-specific config not found + GetAllIncludedOperations + : AllCrudOperations + : AllCrudOperations; + +export type GetAllIncludedOperations> = 'models' extends keyof Slicing + ? Slicing extends { models: infer Config } + ? '$all' extends keyof Config + ? Config['$all'] extends { includedOperations: readonly [] } + ? '_none_' + : Config['$all'] extends { includedOperations: readonly (infer IO)[] } + ? IO + : AllCrudOperations + : AllCrudOperations + : AllCrudOperations + : AllCrudOperations; + +type GetExcludedOperations, Model extends string> = 'models' extends keyof Slicing + ? Slicing extends { models: infer Config } + ? Uncapitalize extends keyof Config + ? Config[Uncapitalize] extends { excludedOperations: readonly (infer EO)[] } + ? EO + : // fallback to $all if 'excludedOperations' not specified for the model + GetAllExcludedOperations + : // fallback to $all if model-specific config not found + GetAllExcludedOperations + : never + : never; + +type GetAllExcludedOperations> = 'models' extends keyof Slicing + ? Slicing extends { models: infer M } + ? '$all' extends keyof M + ? M['$all'] extends { excludedOperations: readonly (infer EO)[] } + ? EO + : never + : never + : never + : never; + +// #endregion + +// #region Procedure slicing + +/** + * Filters procedures based on slicing configuration. + */ +export type GetSlicedProcedures< + Schema extends SchemaDef, + Options extends QueryOptions, +> = Options['slicing'] extends infer S + ? S extends SlicingOptions + ? S['includedProcedures'] extends readonly (infer IncludedProc)[] + ? // includedProcedures is specified, start with only those + Exclude< + Extract>, + S['excludedProcedures'] extends readonly (infer ExcludedProc)[] + ? Extract> + : never + > + : // includedProcedures not specified, start with all procedures + Exclude< + GetProcedureNames, + S['excludedProcedures'] extends readonly (infer ExcludedProc)[] + ? Extract> + : never + > + : // No slicing config, include all procedures + GetProcedureNames + : GetProcedureNames; + +// #endregion + +// #region Filter slicing + +/** + * Filters filter kinds for a specific field, considering field-level slicing configuration with $all fallback. + */ +export type GetSlicedFilterKindsForField< + Schema extends SchemaDef, + Model extends GetModels, + Field extends string, + Options extends QueryOptions, +> = Options extends { slicing: infer S } + ? S extends SlicingOptions + ? GetFieldIncludedFilterKinds extends infer IFK + ? GetFieldExcludedFilterKinds extends infer EFK + ? '_none_' extends IFK + ? // Empty includedFilterKinds array - exclude all + never + : IsNever extends true + ? // No field-level includedFilterKinds specified + IsNever extends true + ? // No field-level exclusions either - allow all + FilterKind + : // Field-level exclusions exist - exclude them from all filter kinds + Exclude + : // Field-level includedFilterKinds specified - use those and apply exclusions + Exclude + : FilterKind + : FilterKind + : FilterKind + : FilterKind; + +// Helper type to extract includedFilterKinds from a model config (handles both specific model and $all) +type GetIncludedFilterKindsFromModelConfig = ModelConfig extends { + includedFilterKinds: readonly []; +} + ? '_none_' + : 'fields' extends keyof ModelConfig + ? ModelConfig['fields'] extends infer FieldsConfig + ? // Check if specific field config exists + Field extends keyof FieldsConfig + ? 'includedFilterKinds' extends keyof FieldsConfig[Field] + ? // Field-specific includedFilterKinds + FieldsConfig[Field] extends { includedFilterKinds: readonly [] } + ? '_none_' + : FieldsConfig[Field] extends { includedFilterKinds: readonly (infer IFK)[] } + ? IFK + : never + : // No field-specific includedFilterKinds, try $all + GetAllFieldsIncludedFilterKinds + : // No field-specific config, try $all + GetAllFieldsIncludedFilterKinds + : never + : never; + +type GetFieldIncludedFilterKinds< + S extends SlicingOptions, + Model extends string, + Field extends string, +> = S extends { + models?: infer Config; +} + ? Uncapitalize extends keyof Config + ? GetIncludedFilterKindsFromModelConfig], Field> + : // Model not in config, fallback to $all + '$all' extends keyof Config + ? GetIncludedFilterKindsFromModelConfig + : never + : never; + +type GetAllFieldsIncludedFilterKinds = '$all' extends keyof FieldsConfig + ? FieldsConfig['$all'] extends { includedFilterKinds: readonly [] } + ? '_none_' + : FieldsConfig['$all'] extends { includedFilterKinds: readonly (infer IFK)[] } + ? IFK + : never + : never; + +// Helper type to extract excludedFilterKinds from a model config (handles both specific model and $all) +type GetExcludedFilterKindsFromModelConfig = 'fields' extends keyof ModelConfig + ? ModelConfig['fields'] extends infer FieldsConfig + ? // Check if specific field config exists + Field extends keyof FieldsConfig + ? FieldsConfig[Field] extends { excludedFilterKinds: readonly (infer EFK)[] } + ? EFK + : // No field-specific excludedFilterKinds, try $all + GetAllFieldsExcludedFilterKinds + : // No field-specific config, try $all + GetAllFieldsExcludedFilterKinds + : never + : never; + +type GetFieldExcludedFilterKinds< + S extends SlicingOptions, + Model extends string, + Field extends string, +> = S extends { + models?: infer Config; +} + ? Uncapitalize extends keyof Config + ? GetExcludedFilterKindsFromModelConfig], Field> + : // Model not in config, fallback to $all + '$all' extends keyof Config + ? GetExcludedFilterKindsFromModelConfig + : never + : never; + +type GetAllFieldsExcludedFilterKinds = '$all' extends keyof FieldsConfig + ? FieldsConfig['$all'] extends { excludedFilterKinds: readonly (infer EFK)[] } + ? EFK + : never + : never; + +// #endregion diff --git a/packages/zod/package.json b/packages/zod/package.json index 4853c6295..a146e20e9 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,10 +1,8 @@ { "name": "@zenstackhq/zod", "version": "3.3.3", - "description": "", + "description": "ZenStack Zod integration", "type": "module", - "main": "index.js", - "private": true, "scripts": { "build": "tsc --noEmit && tsup-node", "lint": "eslint src --ext ts" diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts new file mode 100644 index 000000000..5ba5fcb35 --- /dev/null +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -0,0 +1,2001 @@ +import { AllReadOperations } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic/schema'; +import { schema as proceduresSchema } from '../schemas/procedures/schema'; + +describe('Query slicing tests', () => { + describe('Model inclusion/exclusion', () => { + it('includes all models when no slicing config', async () => { + const db = await createTestClient(schema); + + // All models should be accessible + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + expect(db.comment).toBeDefined(); + expect(db.profile).toBeDefined(); + expect(db.plain).toBeDefined(); + }); + + it('includes only specified models with includedModels', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // included models should be accessible + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + + // @ts-expect-error - Profile model should not be accessible + expect(db.profile).toBeUndefined(); + // @ts-expect-error - Plain model should not be accessible + expect(db.plain).toBeUndefined(); + }); + + it('excludes specified models with excludedModels', async () => { + const options = { + slicing: { + excludedModels: ['Comment', 'Profile'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // non-excluded models should be accessible + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + expect(db.plain).toBeDefined(); + + // excluded models should not be accessible + // @ts-expect-error - Comment model should be excluded + expect(db.comment).toBeUndefined(); + // @ts-expect-error - Profile model should be excluded + expect(db.profile).toBeUndefined(); + }); + + it('applies both includedModels and excludedModels (exclusion after inclusion)', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post', 'Comment'] as const, + excludedModels: ['Comment'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // only User and Post should be accessible (Comment excluded after being included) + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + + // Comment should be excluded despite being in includedModels + // @ts-expect-error - Comment model should be excluded + expect(db.comment).toBeUndefined(); + + // Profile and Plain were never included + // @ts-expect-error - Profile model was not included + expect(db.profile).toBeUndefined(); + // @ts-expect-error - Plain model was not included + expect(db.plain).toBeUndefined(); + }); + + it('excludes all models when includedModels is empty array', async () => { + const options = { + slicing: { + includedModels: [] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // no models should be accessible with empty includedModels + // @ts-expect-error - User model should not be accessible + expect(db.user).toBeUndefined(); + // @ts-expect-error - Post model should not be accessible + expect(db.post).toBeUndefined(); + // @ts-expect-error - Comment model should not be accessible + expect(db.comment).toBeUndefined(); + // @ts-expect-error - Profile model should not be accessible + expect(db.profile).toBeUndefined(); + // @ts-expect-error - Plain model should not be accessible + expect(db.plain).toBeUndefined(); + }); + + it('has no effect when excludedModels is empty array', async () => { + const options = { + slicing: { + excludedModels: [] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // All models should be accessible (empty excludedModels has no effect) + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + expect(db.comment).toBeDefined(); + expect(db.profile).toBeDefined(); + expect(db.plain).toBeDefined(); + }); + + it('works with setOptions to change slicing at runtime', async () => { + const initialOptions = { + slicing: { + includedModels: ['User', 'Post'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, initialOptions); + + // Initially only User and Post are accessible + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + + // Change slicing options + const newOptions = { + ...initialOptions, + slicing: { + includedModels: ['Comment', 'Profile'] as const, + }, + } as const; + + const db2 = db.$setOptions(newOptions); + + // After setOptions, different models should be accessible + expect(db2.comment).toBeDefined(); + expect(db2.profile).toBeDefined(); + + // Original client should remain unchanged + expect(db.user).toBeDefined(); + expect(db.post).toBeDefined(); + }); + + it('prevents excluded models from being used in include clause', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post'] as const, + // excludedModels: ['Profile', 'Comment'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Profile is excluded, so including it should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - Profile model is excluded + include: { profile: true }, + }), + ).toBeRejectedByValidation(['"profile"', '"include"']); + + // Comment is excluded, so including it should cause type error + await expect( + db.post.findMany({ + // @ts-expect-error - Comment model is excluded + include: { comments: true }, + }), + ).toBeRejectedByValidation(['"comments"', '"include"']); + + // Non-excluded relations should work + const userWithPosts = await db.user.findMany({ + include: { posts: true }, + }); + expect(userWithPosts[0]!.posts).toBeDefined(); + }); + + it('prevents excluded models from being used in select clause', async () => { + const options = { + slicing: { + excludedModels: ['Profile', 'Comment'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Profile is excluded, so selecting it should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - Profile model is excluded + select: { id: true, profile: true }, + }), + ).toBeRejectedByValidation(['"profile"', '"select"']); + + // Comment is excluded, so selecting it should cause type error + await expect( + db.post.findMany({ + // @ts-expect-error - Comment model is excluded + select: { id: true, comments: true }, + }), + ).toBeRejectedByValidation(['"comments"', '"select"']); + + // Non-excluded relations should work in select + const userWithPosts = await db.user.findMany({ + select: { id: true, posts: true }, + }); + expect(userWithPosts[0]!.posts).toBeDefined(); + }); + + it('prevents models not in includedModels from being used in include clause', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Profile is not included, so including it should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - Profile model is not included + include: { profile: true }, + }), + ).toBeRejectedByValidation(['"profile"', '"include"']); + + // Comment is not included, so including it should cause type error + await expect( + db.post.findMany({ + // @ts-expect-error - Comment model is not included + include: { comments: true }, + }), + ).toBeRejectedByValidation(['"comments"', '"include"']); + + // User and Post are included, so relations between them should work + const userWithPosts = await db.user.findMany({ + include: { posts: true }, + }); + expect(userWithPosts[0]!.posts).toBeDefined(); + + const postWithAuthor = await db.post.findMany({ + include: { author: true }, + }); + expect(postWithAuthor[0]!.author).toBeDefined(); + }); + + it('prevents models not in includedModels from being used in select clause', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Profile is not included, so selecting it should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - Profile model is not included + select: { id: true, profile: true }, + }), + ).toBeRejectedByValidation(['"profile"', '"select"']); + + // Comment is not included, so selecting it should cause type error + await expect( + db.post.findMany({ + // @ts-expect-error - Comment model is not included + select: { id: true, comments: true }, + }), + ).toBeRejectedByValidation(['"comments"', '"select"']); + + // User and Post are included, so relations between them should work in select + const userWithPosts = await db.user.findMany({ + select: { id: true, posts: true }, + }); + expect(userWithPosts[0]!.posts).toBeDefined(); + + const postWithAuthor = await db.post.findMany({ + select: { id: true, author: true }, + }); + expect(postWithAuthor[0]!.author).toBeDefined(); + }); + + it('prevents excluded models from nested include clauses', async () => { + const options = { + slicing: { + excludedModels: ['Comment'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Comment is excluded, so including it in nested include should cause type error + await expect( + db.user.findMany({ + include: { + posts: { + // @ts-expect-error - Comment model is excluded + include: { comments: true }, + }, + }, + }), + ).toBeRejectedByValidation(['"comments"']); + + // User -> Post relation should work (Comment is excluded) + const userWithPosts = await db.user.findMany({ + include: { + posts: true, + }, + }); + expect(userWithPosts[0]!.posts).toBeDefined(); + }); + + it('prevents excluded models from nested select clauses', async () => { + const options = { + slicing: { + excludedModels: ['Comment'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Comment is excluded, so selecting it in nested select should cause type error + await expect( + db.user.findMany({ + select: { + id: true, + posts: { + // @ts-expect-error - Comment model is excluded + select: { id: true, comments: true }, + }, + }, + }), + ).toBeRejectedByValidation(['"comments"']); + + // User -> Post relation should work in nested select (Comment is excluded) + const userWithPosts = await db.user.findMany({ + select: { + id: true, + posts: { + select: { id: true, title: true }, + }, + }, + }); + expect(userWithPosts[0]!.posts).toBeDefined(); + }); + + it('prevents nested create on excluded models', async () => { + const options = { + slicing: { + excludedModels: ['Profile'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Cannot create user with nested profile (Profile is excluded) + await expect( + db.user.create({ + data: { + email: 'test@example.com', + // @ts-expect-error - Profile model is excluded + profile: { + create: { + bio: 'Test bio', + }, + }, + }, + }), + ).toBeRejectedByValidation(['"profile"']); + }); + + it('prevents nested update on excluded models', async () => { + const options = { + slicing: { + excludedModels: ['Profile'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + const user = await db.user.create({ data: { email: 'test@example.com' } }); + + // Cannot update user with nested profile operations (Profile is excluded) + await expect( + db.user.update({ + where: { id: user.id }, + data: { + // @ts-expect-error - Profile model is excluded + profile: { + create: { + bio: 'Test bio', + }, + }, + }, + }), + ).toBeRejectedByValidation(['"profile"']); + }); + + it('prevents nested upsert on excluded models', async () => { + const options = { + slicing: { + excludedModels: ['Comment'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Cannot update post with nested comment operations (Comment is excluded) + await expect( + db.post.update({ + where: { id: 'post-id' }, + data: { + // @ts-expect-error - Comment model is excluded + comments: { + upsert: { + where: { id: 'comment-id' }, + create: { content: 'New comment' }, + update: { content: 'Updated comment' }, + }, + }, + }, + }), + ).toBeRejectedByValidation(['"comments"']); + }); + + it('allows nested create on included models', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Can create user with nested posts (Post is included) + const user = await db.user.create({ + data: { + email: 'test@example.com', + posts: { + create: [ + { title: 'Post 1', content: 'Content 1' }, + { title: 'Post 2', content: 'Content 2' }, + ], + }, + }, + include: { posts: true }, + }); + + expect(user.posts).toHaveLength(2); + expect(user.posts[0]!.title).toBe('Post 1'); + }); + + it('allows nested update on included models', async () => { + const options = { + slicing: { + includedModels: ['User', 'Post'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Create user with post + const user = await db.user.create({ + data: { + email: 'test@example.com', + posts: { + create: { title: 'Post 1', content: 'Content 1' }, + }, + }, + include: { posts: true }, + }); + + const postId = user.posts[0]!.id; + + // Can update user with nested post updates (Post is included) + const updated = await db.user.update({ + where: { id: user.id }, + data: { + posts: { + update: { + where: { id: postId }, + data: { title: 'Updated Post' }, + }, + }, + }, + include: { posts: true }, + }); + + expect(updated.posts[0]!.title).toBe('Updated Post'); + }); + }); + + describe('Operation inclusion/exclusion', () => { + it('includes only specified operations with includedOperations', async () => { + const options = { + slicing: { + models: { + user: { + includedOperations: ['findMany', 'create'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Included operations should be accessible + expect(db.user.findMany).toBeDefined(); + expect(db.user.create).toBeDefined(); + + // Excluded operations should not be accessible + // @ts-expect-error - findUnique should not be accessible + expect(db.user.findUnique).toBeUndefined(); + // @ts-expect-error - update should not be accessible + expect(db.user.update).toBeUndefined(); + // @ts-expect-error - delete should not be accessible + expect(db.user.delete).toBeUndefined(); + }); + + it('excludes specified operations with excludedOperations', async () => { + const options = { + slicing: { + models: { + user: { + excludedOperations: ['delete', 'deleteMany', 'update', 'updateMany'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Non-excluded operations should be accessible + expect(db.user.findMany).toBeDefined(); + expect(db.user.create).toBeDefined(); + expect(db.user.count).toBeDefined(); + + // Excluded operations should not be accessible + // @ts-expect-error - delete should be excluded + expect(db.user.delete).toBeUndefined(); + // @ts-expect-error - deleteMany should be excluded + expect(db.user.deleteMany).toBeUndefined(); + // @ts-expect-error - update should be excluded + expect(db.user.update).toBeUndefined(); + // @ts-expect-error - updateMany should be excluded + expect(db.user.updateMany).toBeUndefined(); + }); + + it('applies both includedOperations and excludedOperations', async () => { + const options = { + slicing: { + models: { + user: { + includedOperations: ['findMany', 'findUnique', 'create', 'update'] as const, + excludedOperations: ['update'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Only findMany, findUnique, and create should be accessible + expect(db.user.findMany).toBeDefined(); + expect(db.user.findUnique).toBeDefined(); + expect(db.user.create).toBeDefined(); + + // update should be excluded despite being in includedOperations + // @ts-expect-error - update should be excluded + expect(db.user.update).toBeUndefined(); + + // delete was never included + // @ts-expect-error - delete was not included + expect(db.user.delete).toBeUndefined(); + }); + + it('restricts operations for a single model without affecting others', async () => { + const options = { + slicing: { + models: { + user: { + includedOperations: ['findMany', 'create'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // User should have restricted operations + expect(db.user.findMany).toBeDefined(); + expect(db.user.create).toBeDefined(); + // @ts-expect-error - update not included for User + expect(db.user.update).toBeUndefined(); + + // Post should have all operations (no restrictions) + expect(db.post.findMany).toBeDefined(); + expect(db.post.create).toBeDefined(); + expect(db.post.update).toBeDefined(); + expect(db.post.delete).toBeDefined(); + }); + + it('creates read-only model with only read operations', async () => { + const options = { + slicing: { + models: { + user: { + includedOperations: AllReadOperations, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Read operations should be accessible + expect(db.user.findMany).toBeDefined(); + expect(db.user.findUnique).toBeDefined(); + expect(db.user.findFirst).toBeDefined(); + expect(db.user.count).toBeDefined(); + expect(db.user.exists).toBeDefined(); + + // Write operations should not be accessible + // @ts-expect-error - create should not be accessible + expect(db.user.create).toBeUndefined(); + // @ts-expect-error - update should not be accessible + expect(db.user.update).toBeUndefined(); + // @ts-expect-error - delete should not be accessible + expect(db.user.delete).toBeUndefined(); + }); + + it('excludes all operations when includedOperations is empty', async () => { + const options = { + slicing: { + models: { + user: { + includedOperations: [] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // No operations should be accessible + // @ts-expect-error - findMany should not be accessible + expect(db.user.findMany).toBeUndefined(); + // @ts-expect-error - create should not be accessible + expect(db.user.create).toBeUndefined(); + // @ts-expect-error - update should not be accessible + expect(db.user.update).toBeUndefined(); + }); + + it('applies $all slicing to all models when no model-specific config', async () => { + const options = { + slicing: { + models: { + $all: { + includedOperations: ['findMany', 'count'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // All models should have only findMany and count + expect(db.user.findMany).toBeDefined(); + expect(db.user.count).toBeDefined(); + // @ts-expect-error - create should not be accessible + expect(db.user.create).toBeUndefined(); + + expect(db.post.findMany).toBeDefined(); + expect(db.post.count).toBeDefined(); + // @ts-expect-error - update should not be accessible + expect(db.post.update).toBeUndefined(); + }); + + it('model-specific slicing overrides $all slicing', async () => { + const options = { + slicing: { + models: { + $all: { + includedOperations: ['findMany', 'count'] as const, + }, + user: { + includedOperations: ['findMany', 'create', 'update'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // User should have model-specific operations + expect(db.user.findMany).toBeDefined(); + expect(db.user.create).toBeDefined(); + expect(db.user.update).toBeDefined(); + // @ts-expect-error - count is in $all but User overrides + expect(db.user.count).toBeUndefined(); + // @ts-expect-error - delete not in User's includedOperations + expect(db.user.delete).toBeUndefined(); + + // Post should have $all operations + expect(db.post.findMany).toBeDefined(); + expect(db.post.count).toBeDefined(); + // @ts-expect-error - create not in $all + expect(db.post.create).toBeUndefined(); + }); + + it('uses $all excludedOperations as fallback', async () => { + const options = { + slicing: { + models: { + $all: { + excludedOperations: ['delete', 'deleteMany'] as const, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // All models should exclude delete and deleteMany + expect(db.user.findMany).toBeDefined(); + expect(db.user.create).toBeDefined(); + // @ts-expect-error - delete should be excluded + expect(db.user.delete).toBeUndefined(); + // @ts-expect-error - deleteMany should be excluded + expect(db.user.deleteMany).toBeUndefined(); + + expect(db.post.update).toBeDefined(); + // @ts-expect-error - delete should be excluded + expect(db.post.delete).toBeUndefined(); + }); + }); + + describe('Procedure inclusion/exclusion', () => { + // Mock procedure handlers for testing (simplified versions) + const mockProcedures = { + getUser: () => ({ id: 1, name: 'test', role: 'USER' as const }), + listUsers: () => [], + signUp: () => ({ id: 1, name: 'test', role: 'USER' as const }), + setAdmin: () => undefined, + getOverview: () => ({ userIds: [], total: 0, roles: ['USER' as const], meta: null }), + createMultiple: () => [], + }; + + it('includes all procedures when no slicing config', async () => { + const db = await createTestClient(proceduresSchema, { + procedures: mockProcedures as any, + }); + + // All procedures should be accessible + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + expect(db.$procs.signUp).toBeDefined(); + expect(db.$procs.setAdmin).toBeDefined(); + expect(db.$procs.getOverview).toBeDefined(); + expect(db.$procs.createMultiple).toBeDefined(); + }); + + it('includes only specified procedures with includedProcedures', async () => { + const options = { + procedures: mockProcedures as any, + slicing: { + includedProcedures: ['getUser', 'listUsers'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(proceduresSchema, options); + + // Included procedures should be accessible + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + + // Non-included procedures should not be accessible + // @ts-expect-error - signUp should not be accessible + expect(db.$procs.signUp).toBeUndefined(); + // @ts-expect-error - setAdmin should not be accessible + expect(db.$procs.setAdmin).toBeUndefined(); + // @ts-expect-error - getOverview should not be accessible + expect(db.$procs.getOverview).toBeUndefined(); + // @ts-expect-error - createMultiple should not be accessible + expect(db.$procs.createMultiple).toBeUndefined(); + }); + + it('excludes specified procedures with excludedProcedures', async () => { + const options = { + procedures: mockProcedures as any, + slicing: { + excludedProcedures: ['signUp', 'setAdmin', 'createMultiple'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(proceduresSchema, options); + + // Non-excluded procedures should be accessible + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + expect(db.$procs.getOverview).toBeDefined(); + + // Excluded procedures should not be accessible + // @ts-expect-error - signUp should be excluded + expect(db.$procs.signUp).toBeUndefined(); + // @ts-expect-error - setAdmin should be excluded + expect(db.$procs.setAdmin).toBeUndefined(); + // @ts-expect-error - createMultiple should be excluded + expect(db.$procs.createMultiple).toBeUndefined(); + }); + + it('applies both includedProcedures and excludedProcedures (exclusion takes precedence)', async () => { + const options = { + procedures: mockProcedures as any, + slicing: { + includedProcedures: ['getUser', 'listUsers', 'signUp'] as const, + excludedProcedures: ['signUp'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(proceduresSchema, options); + + // Only getUser and listUsers should be accessible + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + + // signUp should be excluded despite being in includedProcedures + // @ts-expect-error - signUp should be excluded + expect(db.$procs.signUp).toBeUndefined(); + + // Others were never included + // @ts-expect-error - setAdmin was not included + expect(db.$procs.setAdmin).toBeUndefined(); + // @ts-expect-error - getOverview was not included + expect(db.$procs.getOverview).toBeUndefined(); + // @ts-expect-error - createMultiple was not included + expect(db.$procs.createMultiple).toBeUndefined(); + }); + + it('excludes all procedures when includedProcedures is empty array', async () => { + const options = { + procedures: mockProcedures as any, + slicing: { + includedProcedures: [] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(proceduresSchema, options); + + // No procedures should be accessible with empty includedProcedures + // @ts-expect-error - getUser should not be accessible + expect(db.$procs.getUser).toBeUndefined(); + // @ts-expect-error - listUsers should not be accessible + expect(db.$procs.listUsers).toBeUndefined(); + // @ts-expect-error - signUp should not be accessible + expect(db.$procs.signUp).toBeUndefined(); + // @ts-expect-error - setAdmin should not be accessible + expect(db.$procs.setAdmin).toBeUndefined(); + // @ts-expect-error - getOverview should not be accessible + expect(db.$procs.getOverview).toBeUndefined(); + // @ts-expect-error - createMultiple should not be accessible + expect(db.$procs.createMultiple).toBeUndefined(); + }); + + it('has no effect when excludedProcedures is empty array', async () => { + const db = await createTestClient(proceduresSchema, { + procedures: mockProcedures as any, + slicing: { + excludedProcedures: [] as const, + }, + }); + + // All procedures should be accessible (empty excludedProcedures has no effect) + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + expect(db.$procs.signUp).toBeDefined(); + expect(db.$procs.setAdmin).toBeDefined(); + expect(db.$procs.getOverview).toBeDefined(); + expect(db.$procs.createMultiple).toBeDefined(); + }); + }); + + describe('Filter kind inclusion/exclusion', () => { + describe('Model-level filter kind slicing', () => { + it('allows all filter kinds when no slicing config', async () => { + const db = await createTestClient(schema); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // All filter kinds should work + + // Equality filters + const equalityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(equalityResult).toHaveLength(1); + + // empty filters + const rangeResult = await db.user.findMany({ + where: {}, + }); + expect(rangeResult).toHaveLength(1); + + // Like filters + const likeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(likeResult).toHaveLength(1); + }); + + it('includes only specified filter kinds with includedFilterKinds', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work + const equalityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(equalityResult).toHaveLength(1); + + // Range filters should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - gte is not allowed (Range filters excluded) + where: { age: { gte: 20 } }, + }), + ).toBeRejectedByValidation(['"gte"']); + + // Like filters should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - contains is not allowed (Like filters excluded) + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + + it('excludes specified filter kinds with excludedFilterKinds', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + excludedFilterKinds: ['Like', 'Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work + const equalityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(equalityResult).toHaveLength(1); + + // Range filters should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - gte is excluded + where: { age: { gte: 20 } }, + }), + ).toBeRejectedByValidation(['"gte"']); + + // Like filters should cause type error + await expect( + db.user.findMany({ + // @ts-expect-error - contains is excluded + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + + it('applies both includedFilterKinds and excludedFilterKinds (exclusion takes precedence)', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality', 'Range', 'Like'] as const, + excludedFilterKinds: ['Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work + const equalityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(equalityResult).toHaveLength(1); + + // Like filters should work + const likeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(likeResult).toHaveLength(1); + + // Range filters should cause type error (excluded despite being included) + await expect( + db.user.findMany({ + // @ts-expect-error - gte is excluded + where: { age: { gte: 20 } }, + }), + ).toBeRejectedByValidation(['"gte"']); + }); + + it('excludes all filter operations when includedFilterKinds is empty', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: [] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // All filter operations should cause type errors + await expect( + db.user.findMany({ + // @ts-expect-error - no filter operators are allowed + where: { email: { equals: 'test@example.com' } }, + }), + ).toBeRejectedByValidation(['"where.email"']); + + await expect( + db.user.findMany({ + // @ts-expect-error - no filter operators are allowed + where: { age: { gte: 20 } }, + }), + ).toBeRejectedByValidation(['"where.age"']); + + await expect( + db.user.findMany({ + // @ts-expect-error - no filter operators are allowed + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"where.name"']); + }); + + it('allows only Equality and Range filters for numeric fields', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality', 'Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work + const inResult = await db.user.findMany({ + where: { age: { in: [25, 30] } }, + }); + expect(inResult).toHaveLength(1); + + // Range filters should work + const betweenResult = await db.user.findMany({ + where: { age: { between: [20, 30] } }, + }); + expect(betweenResult).toHaveLength(1); + + const gteResult = await db.user.findMany({ + where: { age: { gte: 25, lte: 30 } }, + }); + expect(gteResult).toHaveLength(1); + }); + + it('applies $all filter kind slicing to all models', async () => { + const options = { + slicing: { + models: { + $all: { + fields: { + $all: { + includedFilterKinds: ['Equality'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'user@example.com', name: 'User' } }); + const user = await db.user.findFirst({ where: { email: 'user@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Equality filters should work for all models + const userResult = await db.user.findMany({ + where: { email: { equals: 'user@example.com' } }, + }); + expect(userResult).toHaveLength(1); + + const postResult = await db.post.findMany({ + where: { title: { equals: 'Test Post' } }, + }); + expect(postResult).toHaveLength(1); + + // Like filters should cause type errors for all models + await expect( + db.user.findMany({ + // @ts-expect-error - contains is not allowed for User + where: { name: { contains: 'User' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + await expect( + db.post.findMany({ + // @ts-expect-error - contains is not allowed for Post + where: { title: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + + it('model-specific filter kind slicing overrides $all slicing', async () => { + const options = { + slicing: { + models: { + $all: { + fields: { + $all: { + includedFilterKinds: ['Equality'] as const, + }, + }, + }, + user: { + fields: { + $all: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'user@example.com', name: 'Test User' } }); + const user = await db.user.findFirst({ where: { email: 'user@example.com' } }); + + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // User should have Equality and Like filters + const userLikeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(userLikeResult).toHaveLength(1); + + // Post should only have Equality filters (from $all) + const postEqualityResult = await db.post.findMany({ + where: { title: { equals: 'Test Post' } }, + }); + expect(postEqualityResult).toHaveLength(1); + + // Post should not have Like filters + await expect( + db.post.findMany({ + // @ts-expect-error - contains is not allowed for Post + where: { title: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + + it('excludes Relation filters to prevent relation queries', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + excludedFilterKinds: ['Relation'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'user@example.com', name: 'Test' } }); + + // Scalar filters should work + const scalarResult = await db.user.findMany({ + where: { email: { equals: 'user@example.com' } }, + }); + expect(scalarResult).toHaveLength(1); + + // Relation filters should cause type errors + await expect( + db.user.findMany({ + // @ts-expect-error - posts relation filter should be excluded + where: { posts: { some: { title: 'test' } } }, + }), + ).toBeRejectedByValidation(['"where.posts"']); + + await expect( + db.user.findMany({ + // @ts-expect-error - profile relation filter should be excluded + where: { profile: { is: { bio: 'test' } } }, + }), + ).toBeRejectedByValidation(['"where.profile"']); + }); + + it('uses $all excludedFilterKinds as fallback', async () => { + const options = { + slicing: { + models: { + $all: { + fields: { + $all: { + excludedFilterKinds: ['Relation', 'Json'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'user@example.com', name: 'User' } }); + const user = await db.user.findFirst({ where: { email: 'user@example.com' } }); + await db.post.create({ data: { title: 'Post', content: 'Content', authorId: user!.id } }); + + // Scalar filters should work for all models + const userResult = await db.user.findMany({ + where: { email: { equals: 'user@example.com' } }, + }); + expect(userResult).toHaveLength(1); + + // Relation filters should be excluded for all models + await expect( + db.user.findMany({ + // @ts-expect-error - posts relation filter excluded + where: { posts: { some: { title: 'test' } } }, + }), + ).toBeRejectedByValidation(['"where.posts"']); + + await expect( + db.post.findMany({ + // @ts-expect-error - author relation filter excluded + where: { author: { is: { email: 'test' } } }, + }), + ).toBeRejectedByValidation(['"where.author"']); + }); + }); + + describe('Field-level filter kind slicing', () => { + it('allows field-specific filter kind restrictions', async () => { + const options = { + slicing: { + models: { + user: { + // Field-level: restrict 'name' to only Equality filters + fields: { + name: { + includedFilterKinds: ['Equality'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work on 'name' field + const equalityResult = await db.user.findMany({ + where: { name: { equals: 'Test User' } }, + }); + expect(equalityResult).toHaveLength(1); + + // Like filters should cause type error on 'name' field + await expect( + db.user.findMany({ + // @ts-expect-error - contains is not allowed for 'name' field + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + // Other fields should still support all filter kinds + const ageRangeResult = await db.user.findMany({ + where: { age: { gte: 20 } }, + }); + expect(ageRangeResult).toHaveLength(1); + + const emailLikeResult = await db.user.findMany({ + where: { email: { contains: 'example' } }, + }); + expect(emailLikeResult).toHaveLength(1); + }); + + it('excludes specific filter kinds for a field', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + email: { + excludedFilterKinds: ['Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work on 'email' field + const equalityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(equalityResult).toHaveLength(1); + + // Like filters should cause type error on 'email' field + await expect( + db.user.findMany({ + // @ts-expect-error - contains is excluded for 'email' field + where: { email: { contains: 'test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + // Other fields should still support Like filters + const nameLikeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(nameLikeResult).toHaveLength(1); + }); + + it('applies both field-level includedFilterKinds and excludedFilterKinds', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + name: { + includedFilterKinds: ['Equality', 'Like', 'Range'] as const, + excludedFilterKinds: ['Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // Equality filters should work + const equalityResult = await db.user.findMany({ + where: { name: { equals: 'Test User' } }, + }); + expect(equalityResult).toHaveLength(1); + + // Like filters should work + const likeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(likeResult).toHaveLength(1); + + // Range filters should cause type error (excluded despite being included) + await expect( + db.user.findMany({ + // @ts-expect-error - Range filters are excluded for 'name' + where: { name: { gte: 'A' } }, + }), + ).toBeRejectedByValidation(['"gte"']); + }); + + it('field-level slicing with $all fallback', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + // $all: only Equality filters for all fields by default + $all: { + includedFilterKinds: ['Equality'] as const, + }, + // Field-level: 'name' gets Equality AND Like filters + name: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // 'name' field should have Like filters (field-level override) + const nameLikeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(nameLikeResult).toHaveLength(1); + + // 'email' field should only have Equality filters ($all fallback) + const emailEqualityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(emailEqualityResult).toHaveLength(1); + + // 'email' field should not have Like filters + await expect( + db.user.findMany({ + // @ts-expect-error - Like filters not allowed for 'email' (from $all) + where: { email: { contains: 'test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + + it('excludes all filter operations for a field when includedFilterKinds is empty', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + name: { + includedFilterKinds: [] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // All filter operations should cause type errors for 'name' field + await expect( + db.user.findMany({ + // @ts-expect-error - equals is not allowed for 'name' + where: { name: { equals: 'Test User' } }, + }), + ).toBeRejectedByValidation(['"where.name"']); + + await expect( + db.user.findMany({ + // @ts-expect-error - contains is not allowed for 'name' + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"where.name"']); + + // Other fields should still work normally + const emailResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(emailResult).toHaveLength(1); + }); + + it('allows different field-level slicing for multiple fields', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + name: { + includedFilterKinds: ['Equality'] as const, + }, + email: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + age: { + includedFilterKinds: ['Equality', 'Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // 'name' should only support Equality + const nameResult = await db.user.findMany({ + where: { name: { equals: 'Test User' } }, + }); + expect(nameResult).toHaveLength(1); + + await expect( + db.user.findMany({ + // @ts-expect-error - Like filters not allowed for 'name' + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + // 'email' should support Equality and Like + const emailEqualityResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(emailEqualityResult).toHaveLength(1); + + const emailLikeResult = await db.user.findMany({ + where: { email: { contains: 'example' } }, + }); + expect(emailLikeResult).toHaveLength(1); + + await expect( + db.user.findMany({ + // @ts-expect-error - Range filters not allowed for 'email' + where: { email: { gte: 'a' } }, + }), + ).toBeRejectedByValidation(['"gte"']); + + // 'age' should support Equality and Range + const ageEqualityResult = await db.user.findMany({ + where: { age: { equals: 25 } }, + }); + expect(ageEqualityResult).toHaveLength(1); + + const ageRangeResult = await db.user.findMany({ + where: { age: { gte: 20, lte: 30 } }, + }); + expect(ageRangeResult).toHaveLength(1); + }); + + it('field-level excludedFilterKinds with $all fallback', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + // $all: exclude Range filters for all fields + $all: { + excludedFilterKinds: ['Range'] as const, + }, + // Field-level override + name: { + excludedFilterKinds: ['Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // 'name' should support Equality but not Like or Range + const nameEqualityResult = await db.user.findMany({ + where: { name: { equals: 'Test User' } }, + }); + expect(nameEqualityResult).toHaveLength(1); + + await expect( + db.user.findMany({ + // @ts-expect-error - Like filters excluded for 'name' field + where: { name: { contains: 'Test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + await db.user.findMany({ + where: { name: { gte: 'A' } }, + }); + + // 'email' should support Equality and Like but not Range ($all excludes Range) + const emailLikeResult = await db.user.findMany({ + where: { email: { contains: 'example' } }, + }); + expect(emailLikeResult).toHaveLength(1); + + await expect( + db.user.findMany({ + // @ts-expect-error - Range filters excluded by $all + where: { email: { gte: 'a' } }, + }), + ).toBeRejectedByValidation(['"gte"']); + }); + + it('works with numeric fields', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + age: { + includedFilterKinds: ['Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test', age: 25 } }); + + // Range filters should work for 'age' + const gteResult = await db.user.findMany({ + where: { age: { gte: 20 } }, + }); + expect(gteResult).toHaveLength(1); + + const betweenResult = await db.user.findMany({ + where: { age: { between: [20, 30] } }, + }); + expect(betweenResult).toHaveLength(1); + + // Equality filters should cause type error for 'age' + await expect( + db.user.findMany({ + // @ts-expect-error - Equality filters not allowed for 'age' + where: { age: { equals: 25 } }, + }), + ).toBeRejectedByValidation(['"equals"']); + }); + + it('$all.fields specific-field config overrides $all.fields.$all', async () => { + // Verifies the precedence level: $all.fields[field] > $all.fields.$all + const options = { + slicing: { + models: { + $all: { + fields: { + // Default for every field on every model: Equality only + $all: { + includedFilterKinds: ['Equality'] as const, + }, + // Field-specific override within $all: 'name' gets Like too + name: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + // 'name' should allow Like ($all.fields.name wins over $all.fields.$all) + const nameLikeResult = await db.user.findMany({ + where: { name: { contains: 'Test' } }, + }); + expect(nameLikeResult).toHaveLength(1); + + // 'name' still allows Equality + const nameEqResult = await db.user.findMany({ + where: { name: { equals: 'Test User' } }, + }); + expect(nameEqResult).toHaveLength(1); + + // 'email' should only allow Equality (falls back to $all.fields.$all) + const emailEqResult = await db.user.findMany({ + where: { email: { equals: 'test@example.com' } }, + }); + expect(emailEqResult).toHaveLength(1); + + await expect( + db.user.findMany({ + // @ts-expect-error - Like not allowed for 'email' ($all.fields.$all) + where: { email: { contains: 'test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + + it('filter kind precedence: model[field] > model.$all > $all[field] > $all.$all', async () => { + // Exercises all four levels of the precedence chain end-to-end. + const options = { + slicing: { + models: { + $all: { + fields: { + // Level 4 (lowest): default for all fields on all models + $all: { + includedFilterKinds: ['Equality'] as const, + }, + // Level 3: 'title' field on any model gets Like too + title: { + includedFilterKinds: ['Equality', 'Like'] as const, + }, + }, + }, + user: { + fields: { + // Level 2: all User fields default to Equality + Range + $all: { + includedFilterKinds: ['Equality', 'Range'] as const, + }, + // Level 1 (highest): User.name also gets Like + name: { + includedFilterKinds: ['Equality', 'Like', 'Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + const user = await db.user.findFirst({ where: { email: 'test@example.com' } }); + await db.post.create({ data: { title: 'Test Post', content: 'Content', authorId: user!.id } }); + + // Level 1 – User.name: Equality + Like + Range + const nameLike = await db.user.findMany({ where: { name: { contains: 'Test' } } }); + expect(nameLike).toHaveLength(1); + const nameRange = await db.user.findMany({ where: { name: { gte: 'A' } } }); + expect(nameRange).toHaveLength(1); + + // Level 2 – User.email: Equality + Range (User.$all; Like is NOT included) + const emailRange = await db.user.findMany({ where: { age: { gte: 20 } } }); + expect(emailRange).toHaveLength(1); + await expect( + db.user.findMany({ + // @ts-expect-error - Like not allowed for User.email (User.$all wins over $all.fields.*) + where: { email: { contains: 'test' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + + // Level 3 – Post.title: Equality + Like ($all.fields.title; Range is NOT included) + const titleLike = await db.post.findMany({ where: { title: { contains: 'Test' } } }); + expect(titleLike).toHaveLength(1); + await expect( + db.post.findMany({ + // @ts-expect-error - Range not allowed for Post.title ($all.fields.title) + where: { title: { gte: 'A' } }, + }), + ).toBeRejectedByValidation(['"gte"']); + + // Level 4 – Post.content: Equality only ($all.fields.$all fallback) + const contentEq = await db.post.findMany({ where: { content: { equals: 'Content' } } }); + expect(contentEq).toHaveLength(1); + await expect( + db.post.findMany({ + // @ts-expect-error - Like not allowed for Post.content ($all.fields.$all) + where: { content: { contains: 'Content' } }, + }), + ).toBeRejectedByValidation(['"contains"']); + }); + }); + + describe('Direct value filter slicing', () => { + it('allows direct value filters when Equality kind is included', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Equality'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User' } }); + + const user = await db.user.findFirst({ + where: { email: 'test@example.com' }, + }); + expect(user?.email).toBe('test@example.com'); + }); + + it('rejects direct value filters when Equality kind is excluded', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'test@example.com', name: 'Test User', age: 25 } }); + + await expect( + db.user.findFirst({ + // @ts-expect-error - direct value shorthand maps to Equality filters + where: { email: 'test@example.com' }, + }), + ).toBeRejectedByValidation(['"where.email"']); + }); + + it('still allows unique operations to use direct value filters', async () => { + const options = { + slicing: { + models: { + user: { + fields: { + $all: { + includedFilterKinds: ['Range'] as const, + }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + await db.user.create({ data: { email: 'unique@example.com', name: 'Original Name' } }); + + await expect( + db.user.findMany({ + // @ts-expect-error - findMany cannot use direct value filters without Equality kind + where: { email: 'unique@example.com' }, + }), + ).toBeRejectedByValidation(['"where.email"']); + + const uniqueUser = await db.user.findUnique({ + where: { email: 'unique@example.com' }, + }); + expect(uniqueUser?.name).toBe('Original Name'); + + await expect( + db.user.findUnique({ + // @ts-expect-error non-unique fields are still sliced + where: { email: 'unique@example.com', age: 10 }, + }), + ).toBeRejectedByValidation(['"where.age"']); + + const updated = await db.user.update({ + where: { email: 'unique@example.com' }, + data: { name: 'Updated Name' }, + }); + expect(updated.name).toBe('Updated Name'); + + const deleted = await db.user.delete({ + where: { email: 'unique@example.com' }, + }); + expect(deleted.email).toBe('unique@example.com'); + }); + }); + }); +}); diff --git a/tests/e2e/orm/schemas/basic/schema.ts b/tests/e2e/orm/schemas/basic/schema.ts index ea345b9ed..d42e0d89a 100644 --- a/tests/e2e/orm/schemas/basic/schema.ts +++ b/tests/e2e/orm/schemas/basic/schema.ts @@ -44,6 +44,11 @@ export class SchemaType implements SchemaDef { type: "String", optional: true }, + age: { + name: "age", + type: "Int", + optional: true + }, role: { name: "role", type: "Role", diff --git a/tests/e2e/orm/schemas/basic/schema.zmodel b/tests/e2e/orm/schemas/basic/schema.zmodel index 8fd48872c..9b2898bb1 100644 --- a/tests/e2e/orm/schemas/basic/schema.zmodel +++ b/tests/e2e/orm/schemas/basic/schema.zmodel @@ -21,6 +21,7 @@ type CommonFields { model User with CommonFields { email String @unique name String? + age Int? password String @ignore role Role @default(USER) posts Post[]