From 61dbc2a09710a68ade7bce9dacb3b6f7954cf5df Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:30:08 +0800 Subject: [PATCH 01/16] initial commit --- packages/orm/src/client/client-impl.ts | 68 ++- packages/orm/src/client/contract.ts | 116 +++- packages/orm/src/client/crud-types.ts | 61 +- .../src/client/crud/dialects/base-dialect.ts | 8 +- packages/orm/src/client/options.ts | 94 ++- tests/e2e/orm/client-api/slicing.test.ts | 568 ++++++++++++++++++ 6 files changed, 900 insertions(+), 15 deletions(-) create mode 100644 tests/e2e/orm/client-api/slicing.test.ts diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index acf888f8a..d70ddf09f 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -462,6 +462,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 +475,35 @@ 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; +} + function createModelCrudHandler( client: ClientContract, model: string, @@ -527,7 +560,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 +753,36 @@ function createModelCrudHandler( false, ); }, - } as ModelOperations; + }; + + // Filter operations based on slicing configuration + const slicing = client.$options.slicing; + if (slicing?.models) { + const modelSlicing = slicing.models[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/contract.ts b/packages/orm/src/client/contract.ts index 7492c02bf..9949ff735 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -47,7 +47,7 @@ import type { CoreReadOperations, CoreUpdateOperations, } from './crud/operations/base'; -import type { ClientOptions, QueryOptions, ToQueryOptions } from './options'; +import type { ClientOptions, QueryOptions, SlicingOptions, ToQueryOptions } from './options'; import type { ExtClientMembersBase, ExtQueryArgsBase, RuntimePlugin } from './plugin'; import type { ZenStackPromise } from './promise'; import type { ToKysely } from './query-builder'; @@ -89,6 +89,34 @@ export enum TransactionIsolationLevel { Snapshot = 'snapshot', } +/** + * Filters models based on slicing configuration. + * + * Logic: + * 1. If includedModels is specified, only include those models + * 2. Otherwise, include all models + * 3. Then exclude any models in excludedModels + */ +type GetSlicedModels< + Schema extends SchemaDef, + Options extends ClientOptions, +> = 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; + /** * ZenStack client interface. */ @@ -238,7 +266,7 @@ export type ClientContract< */ $pushSchema(): Promise; } & { - [Key in GetModels as Uncapitalize]: ModelOperations< + [Key in GetSlicedModels as Uncapitalize]: ModelOperations< Schema, Key, ToQueryOptions, @@ -907,6 +935,86 @@ type CommonModelOperations< export type OperationsIneligibleForDelegateModels = 'create' | 'createMany' | 'createManyAndReturn' | 'upsert'; +/** + * Helper to extract includedOperations with $all fallback + */ +type ExtractIncludedOperations = S extends { + models: infer M; +} + ? M extends Record + ? Model extends keyof M + ? M[Model] extends { includedOperations: infer IO } + ? IO + : '$all' extends keyof M + ? M['$all'] extends { includedOperations: infer IO } + ? IO + : never + : never + : '$all' extends keyof M + ? M['$all'] extends { includedOperations: infer IO } + ? IO + : never + : never + : never + : never; + +/** + * Helper to extract excludedOperations with $all fallback + */ +type ExtractExcludedOperations = S extends { + models: infer M; +} + ? M extends Record + ? Model extends keyof M + ? M[Model] extends { excludedOperations: infer EO } + ? EO + : '$all' extends keyof M + ? M['$all'] extends { excludedOperations: infer EO } + ? EO + : never + : never + : '$all' extends keyof M + ? M['$all'] extends { excludedOperations: infer EO } + ? EO + : never + : never + : never + : never; + +/** + * Computes which operations to exclude for a model based on slicing configuration. + */ +type GetExcludedOperations< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, +> = + Options['slicing'] extends SlicingOptions + ? ExtractIncludedOperations extends infer IO + ? [IO] extends [never] + ? // No includedOperations, only check excludedOperations + ExtractExcludedOperations extends infer EO + ? [EO] extends [never] + ? never + : EO extends readonly any[] + ? EO[number] + : never + : never + : IO extends readonly any[] + ? // includedOperations specified, exclude all others except those in the list + | Exclude, IO[number]> + // Also apply excludedOperations + | (ExtractExcludedOperations extends infer EO + ? [EO] extends [never] + ? never + : EO extends readonly any[] + ? EO[number] + : never + : never) + : never + : never + : never; + export type ModelOperations< Schema extends SchemaDef, Model extends GetModels, @@ -915,7 +1023,9 @@ export type ModelOperations< > = Omit< AllModelOperations, // exclude operations not applicable to delegate models - IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never + | (IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never) + // exclude operations based on slicing configuration + | GetExcludedOperations >; //#endregion diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 62be0c199..fe178f505 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -108,15 +108,68 @@ type QueryLevelOmit< Omit, > = Field extends keyof Omit ? (Omit[Field] extends boolean ? Omit[Field] : undefined) : undefined; +/** + * Merges field-level slicing omit settings with top-level omit config at the type level. + * Field-level settings take precedence over top-level settings. + */ +type MergeOmitConfigs< + Schema extends SchemaDef, + Options extends QueryOptions, +> = Options['slicing'] extends infer S + ? S extends { models: infer M } + ? { + [Model in GetModels]: Model extends keyof M + ? M[Model] extends { fields: infer F } + ? F extends Record + ? { + [Field in GetModelFields]: Field extends keyof F + ? F[Field] extends { omit: infer O } + ? O extends boolean + ? O + : Model extends keyof Options['omit'] + ? Field extends keyof Options['omit'][Model] + ? Options['omit'][Model][Field] + : undefined + : undefined + : Model extends keyof Options['omit'] + ? Field extends keyof Options['omit'][Model] + ? Options['omit'][Model][Field] + : undefined + : undefined + : Model extends keyof Options['omit'] + ? Field extends keyof Options['omit'][Model] + ? Options['omit'][Model][Field] + : undefined + : undefined; + } + : Model extends keyof Options['omit'] + ? Options['omit'][Model] + : {} + : Model extends keyof Options['omit'] + ? Options['omit'][Model] + : {} + : Model extends keyof Options['omit'] + ? Options['omit'][Model] + : {}; + } + : Options['omit'] extends infer O + ? O + : {} + : Options['omit'] extends infer O + ? O + : {}; + type OptionsLevelOmit< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, Options extends QueryOptions, -> = Model extends keyof Options['omit'] - ? Field extends keyof Options['omit'][Model] - ? Options['omit'][Model][Field] extends boolean - ? Options['omit'][Model][Field] +> = MergeOmitConfigs extends infer Merged + ? Model extends keyof Merged + ? Field extends keyof Merged[Model] + ? Merged[Model][Field] extends boolean + ? Merged[Model][Field] + : undefined : undefined : undefined : undefined; diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 9b273e2b6..d670583c2 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1164,7 +1164,13 @@ export abstract class BaseCrudDialect { return (omit as any)[field]; } - // options-level + // field-level slicing (takes precedence over top-level omit) + const fieldSlicing = this.options.slicing?.models?.[model as any]?.fields?.[field as any]; + if (fieldSlicing && typeof fieldSlicing.omit === 'boolean') { + return fieldSlicing.omit; + } + + // top-level omit if ( this.options.omit?.[model] && typeof this.options.omit[model] === 'object' && diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 6439e3996..72d3580fb 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -4,6 +4,7 @@ import type { PrependParameter } from '../utils/type-utils'; 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,6 +41,81 @@ export type ZModelFunction = ( context: ZModelFunctionContext, ) => Expression; +/** + * Options for slicing ORM client's capabilities by including/excluding certain models, operations, + * fields, or filter kinds. + */ +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]?: ModelSlicingOptions; + } & { + /** + * Slicing options that apply to all models. Model-specific options will override these general + * options if both are specified. + */ + $all?: ModelSlicingOptions>; + }; +}; + +type FilterKinds = 'Equality' | 'Range' | 'List' | 'Like' | 'Relation'; + +/** + * Model slicing options. + */ +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[]; + + includedFilterKinds?: readonly FilterKinds[]; + excludedFilterKinds?: readonly FilterKinds[]; + + fields?: { + [Field in GetModelFields]?: ModelFieldSlicingOptions; + }; +}; + +type ModelFieldSlicingOptions< + Schema extends SchemaDef, + Model extends GetModels, + _Field extends GetModelFields, +> = { + /** + * Set to `true` to exclude the field from query results by default. Omitted fields can be re-included at + * query time with an `omit` clause. + */ + omit?: boolean; + + /** + * Marks the field as ignored. Contrary to omitted fields, ignored fields are completely unaccessible + * by the ORM client. + */ + ignore?: boolean; +}; + /** * ZenStack client options. */ @@ -82,15 +158,23 @@ export type ClientOptions = { */ validateInput?: boolean; + /** + * Options for slicing ORM client's capabilities by including/excluding certain models, operations, filters, etc. + */ + slicing?: SlicingOptions; + /** * Options for omitting fields in ORM query results. + * + * @deprecated Use {@link slicing} options instead. */ 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. + * 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. + * + * @deprecated Use {@link slicing} options instead. */ allowQueryTimeOmitOverride?: boolean; } & (HasComputedFields extends true @@ -148,9 +232,9 @@ export type HasProcedures = Schema extends { /** * Subset of client options relevant to query operations. */ -export type QueryOptions = Pick, 'omit'>; +export type QueryOptions = Pick, 'omit' | 'slicing'>; /** * Extract QueryOptions from ClientOptions */ -export type ToQueryOptions> = Pick; +export type ToQueryOptions> = Pick; 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..e4afe3cf7 --- /dev/null +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -0,0 +1,568 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; +import { schema } from '../schemas/basic/schema'; +import { AllReadOperations } from '@zenstackhq/orm'; + +describe('Model 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(); + }); + }); + + 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('Field slicing (omit)', () => { + it('omits fields specified in field-level slicing', async () => { + const options = { + slicing: { + models: { + User: { + fields: { + email: { omit: true }, + meta: { omit: true }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Create a user + const user = await db.user.create({ + data: { + email: 'test@example.com', + name: 'Test User', + meta: { foo: 'bar' }, + }, + }); + + // Omitted fields should not be in the result + expect(user.id).toBeDefined(); + expect(user.name).toBe('Test User'); + expect('email' in user).toBe(false); + expect('meta' in user).toBe(false); + }); + + it('field-level slicing omit overrides top-level omit', async () => { + const options = { + slicing: { + models: { + User: { + fields: { + name: { omit: true }, + }, + }, + }, + }, + omit: { + User: { + email: true, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + const user = await db.user.create({ + data: { + email: 'test@example.com', + name: 'Test User', + }, + }); + + expect(user.id).toBeDefined(); + + // @ts-expect-error - name should be omitted by field-level slicing + expect(user.name).toBeUndefined(); + // @ts-expect-error - email should be omitted by top-level omit + expect(user.email).toBeUndefined(); + }); + + it('omit false explicitly includes field even if top-level omits it', async () => { + const options = { + slicing: { + models: { + User: { + fields: { + email: { omit: false }, + }, + }, + }, + }, + omit: { + User: { + email: true, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + const user = await db.user.create({ + data: { + email: 'test@example.com', + name: 'Test User', + }, + }); + + // Field-level omit: false should override top-level omit: true + expect(user.email).toBe('test@example.com'); + expect(user.name).toBe('Test User'); + }); + + it('works with findMany and includes', async () => { + const options = { + slicing: { + models: { + User: { + fields: { + email: { omit: true }, + }, + }, + Post: { + fields: { + content: { omit: true }, + }, + }, + }, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(schema, options); + + // Create test data + const user = await db.user.create({ + data: { + email: 'test@example.com', + name: 'Test User', + posts: { + create: [ + { title: 'Post 1', content: 'Content 1' }, + { title: 'Post 2', content: 'Content 2' }, + ], + }, + }, + }); + + // Query with include + const users = await db.user.findMany({ + where: { id: user.id }, + include: { posts: true }, + }); + + expect(users).toHaveLength(1); + expect('email' in users[0]!).toBe(false); // User.email omitted + expect(users[0]?.name).toBe('Test User'); + expect(users[0]?.posts).toHaveLength(2); + expect('content' in users[0]!.posts[0]!).toBe(false); // Post.content omitted + expect(users[0]?.posts[0]?.title).toBe('Post 1'); + }); + }); +}); From 550c3de437caf222b8f83135c56024ac82bbb27d Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:16:56 +0800 Subject: [PATCH 02/16] refactor: typing for sliced models and operations --- .../tanstack-query/src/common/types.ts | 24 +- packages/clients/tanstack-query/src/react.ts | 252 +++++++++--------- .../test/react-sliced-client.test-d.ts | 48 ++++ .../tanstack-query/test/react-typing-test.ts | 143 ---------- .../test/react-typing.test-d.ts | 153 +++++++++++ .../clients/tanstack-query/vitest.config.ts | 4 + packages/orm/src/client/contract.ts | 132 +-------- packages/orm/src/client/crud-types.ts | 63 +---- .../src/client/crud/dialects/base-dialect.ts | 7 - packages/orm/src/client/index.ts | 1 + packages/orm/src/client/options.ts | 118 ++++---- packages/orm/src/client/type-utils.ts | 105 ++++++++ tests/e2e/orm/client-api/slicing.test.ts | 155 +---------- 13 files changed, 545 insertions(+), 660 deletions(-) create mode 100644 packages/clients/tanstack-query/test/react-sliced-client.test-d.ts delete mode 100644 packages/clients/tanstack-query/test/react-typing-test.ts create mode 100644 packages/clients/tanstack-query/test/react-typing.test-d.ts create mode 100644 packages/orm/src/client/type-utils.ts diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index 2e61d286d..02aa4248e 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'; /** @@ -66,6 +72,22 @@ export type TrimDelegateModelOperations< T extends Record, > = IsDelegateModel extends true ? Omit : T; +type Modifiers = '' | 'Suspense' | 'Infinite' | `SuspenseInfinite`; + +/** + * Trim hooks based on slicing configuration. + */ +export type TrimSlicedHooks< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions, + T extends Record, +> = { + [Key in keyof T as Key extends `use${Modifiers}${Capitalize>}` + ? 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..b0686bb81 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -39,6 +39,7 @@ import type { FindUniqueArgs, GetProcedure, GetProcedureNames, + GetSlicedModels, GroupByArgs, GroupByResult, ProcedureEnvelope, @@ -63,6 +64,7 @@ import type { ProcedureReturn, QueryContext, TrimDelegateModelOperations, + TrimSlicedHooks, WithOptimistic, } from './common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -148,7 +150,7 @@ export type ModelMutationModelResult< }; export type ClientHooks = QueryOptions> = { - [Model in GetModels as `${Uncapitalize}`]: ModelQueryHooks; + [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; } & ProcedureHooks; type ProcedureHookGroup = { @@ -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,121 +213,126 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimDelegateModelOperations< +> = TrimSlicedHooks< Schema, Model, - { - useFindUnique>( - args: SelectSubset>, - options?: ModelQueryOptions | null>, - ): ModelQueryResult | null>; - - useSuspenseFindUnique>( - args: SelectSubset>, - options?: ModelSuspenseQueryOptions | null>, - ): ModelSuspenseQueryResult | null>; - - useFindFirst>( - args?: SelectSubset>, - options?: ModelQueryOptions | null>, - ): ModelQueryResult | null>; - - useSuspenseFindFirst>( - args?: SelectSubset>, - options?: ModelSuspenseQueryOptions | null>, - ): ModelSuspenseQueryResult | null>; - - useExists>( - args?: Subset>, - options?: ModelQueryOptions, - ): ModelQueryResult; - - useFindMany>( - args?: SelectSubset>, - options?: ModelQueryOptions[]>, - ): ModelQueryResult[]>; - - useSuspenseFindMany>( - args?: SelectSubset>, - options?: ModelSuspenseQueryOptions[]>, - ): ModelSuspenseQueryResult[]>; - - useInfiniteFindMany>( - args?: SelectSubset>, - options?: ModelInfiniteQueryOptions[]>, - ): ModelInfiniteQueryResult[]>>; - - useSuspenseInfiniteFindMany>( - args?: SelectSubset>, - options?: ModelSuspenseInfiniteQueryOptions[]>, - ): ModelSuspenseInfiniteQueryResult[]>>; - - useCreate>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useCreateMany>( - options?: ModelMutationOptions, - ): ModelMutationResult; - - useCreateManyAndReturn>( - options?: ModelMutationOptions[], T>, - ): ModelMutationModelResult; - - useUpdate>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useUpdateMany>( - options?: ModelMutationOptions, - ): ModelMutationResult; - - useUpdateManyAndReturn>( - options?: ModelMutationOptions[], T>, - ): ModelMutationModelResult; - - useUpsert>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useDelete>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useDeleteMany>( - options?: ModelMutationOptions, - ): ModelMutationResult; - - useCount>( - args?: Subset>, - options?: ModelQueryOptions>, - ): ModelQueryResult>; - - useSuspenseCount>( - args?: Subset>, - options?: ModelSuspenseQueryOptions>, - ): ModelSuspenseQueryResult>; - - useAggregate>( - args: Subset>, - options?: ModelQueryOptions>, - ): ModelQueryResult>; - - useSuspenseAggregate>( - args: Subset>, - options?: ModelSuspenseQueryOptions>, - ): ModelSuspenseQueryResult>; - - useGroupBy>( - args: Subset>, - options?: ModelQueryOptions>, - ): ModelQueryResult>; - - useSuspenseGroupBy>( - args: Subset>, - options?: ModelSuspenseQueryOptions>, - ): ModelSuspenseQueryResult>; - } + Options, + TrimDelegateModelOperations< + Schema, + Model, + { + useFindUnique>( + args: SelectSubset>, + options?: ModelQueryOptions | null>, + ): ModelQueryResult | null>; + + useSuspenseFindUnique>( + args: SelectSubset>, + options?: ModelSuspenseQueryOptions | null>, + ): ModelSuspenseQueryResult | null>; + + useFindFirst>( + args?: SelectSubset>, + options?: ModelQueryOptions | null>, + ): ModelQueryResult | null>; + + useSuspenseFindFirst>( + args?: SelectSubset>, + options?: ModelSuspenseQueryOptions | null>, + ): ModelSuspenseQueryResult | null>; + + useExists>( + args?: Subset>, + options?: ModelQueryOptions, + ): ModelQueryResult; + + useFindMany>( + args?: SelectSubset>, + options?: ModelQueryOptions[]>, + ): ModelQueryResult[]>; + + useSuspenseFindMany>( + args?: SelectSubset>, + options?: ModelSuspenseQueryOptions[]>, + ): ModelSuspenseQueryResult[]>; + + useInfiniteFindMany>( + args?: SelectSubset>, + options?: ModelInfiniteQueryOptions[]>, + ): ModelInfiniteQueryResult[]>>; + + useSuspenseInfiniteFindMany>( + args?: SelectSubset>, + options?: ModelSuspenseInfiniteQueryOptions[]>, + ): ModelSuspenseInfiniteQueryResult[]>>; + + useCreate>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useCreateMany>( + options?: ModelMutationOptions, + ): ModelMutationResult; + + useCreateManyAndReturn>( + options?: ModelMutationOptions[], T>, + ): ModelMutationModelResult; + + useUpdate>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useUpdateMany>( + options?: ModelMutationOptions, + ): ModelMutationResult; + + useUpdateManyAndReturn>( + options?: ModelMutationOptions[], T>, + ): ModelMutationModelResult; + + useUpsert>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useDelete>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useDeleteMany>( + options?: ModelMutationOptions, + ): ModelMutationResult; + + useCount>( + args?: Subset>, + options?: ModelQueryOptions>, + ): ModelQueryResult>; + + useSuspenseCount>( + args?: Subset>, + options?: ModelSuspenseQueryOptions>, + ): ModelSuspenseQueryResult>; + + useAggregate>( + args: Subset>, + options?: ModelQueryOptions>, + ): ModelQueryResult>; + + useSuspenseAggregate>( + args: Subset>, + options?: ModelSuspenseQueryOptions>, + ): ModelSuspenseQueryResult>; + + useGroupBy>( + args: Subset>, + options?: ModelQueryOptions>, + ): ModelQueryResult>; + + useSuspenseGroupBy>( + args: Subset>, + options?: ModelSuspenseQueryOptions>, + ): ModelSuspenseQueryResult>; + } + > >; /** 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..edda1afbb --- /dev/null +++ b/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts @@ -0,0 +1,48 @@ +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'; + +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'); + }); +}); 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/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/contract.ts b/packages/orm/src/client/contract.ts index 9949ff735..b6999d13c 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -47,10 +47,11 @@ import type { CoreReadOperations, CoreUpdateOperations, } from './crud/operations/base'; -import type { ClientOptions, QueryOptions, SlicingOptions, 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 } from './type-utils'; type TransactionUnsupportedMethods = (typeof TRANSACTION_UNSUPPORTED_METHODS)[number]; @@ -89,34 +90,6 @@ export enum TransactionIsolationLevel { Snapshot = 'snapshot', } -/** - * Filters models based on slicing configuration. - * - * Logic: - * 1. If includedModels is specified, only include those models - * 2. Otherwise, include all models - * 3. Then exclude any models in excludedModels - */ -type GetSlicedModels< - Schema extends SchemaDef, - Options extends ClientOptions, -> = 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; - /** * ZenStack client interface. */ @@ -266,12 +239,7 @@ export type ClientContract< */ $pushSchema(): Promise; } & { - [Key in GetSlicedModels as Uncapitalize]: ModelOperations< - Schema, - Key, - ToQueryOptions, - ExtQueryArgs - >; + [Key in GetSlicedModels as Uncapitalize]: ModelOperations; } & ProcedureOperations & ExtClientMembers; @@ -935,97 +903,19 @@ type CommonModelOperations< export type OperationsIneligibleForDelegateModels = 'create' | 'createMany' | 'createManyAndReturn' | 'upsert'; -/** - * Helper to extract includedOperations with $all fallback - */ -type ExtractIncludedOperations = S extends { - models: infer M; -} - ? M extends Record - ? Model extends keyof M - ? M[Model] extends { includedOperations: infer IO } - ? IO - : '$all' extends keyof M - ? M['$all'] extends { includedOperations: infer IO } - ? IO - : never - : never - : '$all' extends keyof M - ? M['$all'] extends { includedOperations: infer IO } - ? IO - : never - : never - : never - : never; - -/** - * Helper to extract excludedOperations with $all fallback - */ -type ExtractExcludedOperations = S extends { - models: infer M; -} - ? M extends Record - ? Model extends keyof M - ? M[Model] extends { excludedOperations: infer EO } - ? EO - : '$all' extends keyof M - ? M['$all'] extends { excludedOperations: infer EO } - ? EO - : never - : never - : '$all' extends keyof M - ? M['$all'] extends { excludedOperations: infer EO } - ? EO - : never - : never - : never - : never; - -/** - * Computes which operations to exclude for a model based on slicing configuration. - */ -type GetExcludedOperations< - Schema extends SchemaDef, - Model extends GetModels, - Options extends QueryOptions, -> = - Options['slicing'] extends SlicingOptions - ? ExtractIncludedOperations extends infer IO - ? [IO] extends [never] - ? // No includedOperations, only check excludedOperations - ExtractExcludedOperations extends infer EO - ? [EO] extends [never] - ? never - : EO extends readonly any[] - ? EO[number] - : never - : never - : IO extends readonly any[] - ? // includedOperations specified, exclude all others except those in the list - | Exclude, IO[number]> - // Also apply excludedOperations - | (ExtractExcludedOperations extends infer EO - ? [EO] extends [never] - ? never - : EO extends readonly any[] - ? EO[number] - : never - : never) - : never - : never - : never; - export type ModelOperations< Schema extends SchemaDef, Model extends GetModels, - Options extends QueryOptions = QueryOptions, + Options extends ClientOptions = ClientOptions, ExtQueryArgs = {}, -> = Omit< +> = Pick< AllModelOperations, - // exclude operations not applicable to delegate models - | (IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never) - // exclude operations based on slicing configuration - | GetExcludedOperations + Exclude< + GetSlicedOperations, + // exclude operations not applicable to delegate models + IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never + > & + keyof AllModelOperations >; //#endregion diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index fe178f505..c7e70ea32 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -108,68 +108,15 @@ type QueryLevelOmit< Omit, > = Field extends keyof Omit ? (Omit[Field] extends boolean ? Omit[Field] : undefined) : undefined; -/** - * Merges field-level slicing omit settings with top-level omit config at the type level. - * Field-level settings take precedence over top-level settings. - */ -type MergeOmitConfigs< - Schema extends SchemaDef, - Options extends QueryOptions, -> = Options['slicing'] extends infer S - ? S extends { models: infer M } - ? { - [Model in GetModels]: Model extends keyof M - ? M[Model] extends { fields: infer F } - ? F extends Record - ? { - [Field in GetModelFields]: Field extends keyof F - ? F[Field] extends { omit: infer O } - ? O extends boolean - ? O - : Model extends keyof Options['omit'] - ? Field extends keyof Options['omit'][Model] - ? Options['omit'][Model][Field] - : undefined - : undefined - : Model extends keyof Options['omit'] - ? Field extends keyof Options['omit'][Model] - ? Options['omit'][Model][Field] - : undefined - : undefined - : Model extends keyof Options['omit'] - ? Field extends keyof Options['omit'][Model] - ? Options['omit'][Model][Field] - : undefined - : undefined; - } - : Model extends keyof Options['omit'] - ? Options['omit'][Model] - : {} - : Model extends keyof Options['omit'] - ? Options['omit'][Model] - : {} - : Model extends keyof Options['omit'] - ? Options['omit'][Model] - : {}; - } - : Options['omit'] extends infer O - ? O - : {} - : Options['omit'] extends infer O - ? O - : {}; - type OptionsLevelOmit< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, Options extends QueryOptions, -> = MergeOmitConfigs extends infer Merged - ? Model extends keyof Merged - ? Field extends keyof Merged[Model] - ? Merged[Model][Field] extends boolean - ? Merged[Model][Field] - : undefined +> = Model extends keyof Options['omit'] + ? Field extends keyof Options['omit'][Model] + ? Options['omit'][Model][Field] extends boolean + ? Options['omit'][Model][Field] : undefined : undefined : undefined; @@ -1048,7 +995,7 @@ type OptionalFieldsForCreate extends true ? Key - : GetModelField['updatedAt'] extends (true | UpdatedAtInfo) + : GetModelField['updatedAt'] extends true | UpdatedAtInfo ? Key : never]: GetModelField; }; diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index d670583c2..ed61d24ef 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1164,13 +1164,6 @@ export abstract class BaseCrudDialect { return (omit as any)[field]; } - // field-level slicing (takes precedence over top-level omit) - const fieldSlicing = this.options.slicing?.models?.[model as any]?.fields?.[field as any]; - if (fieldSlicing && typeof fieldSlicing.omit === 'boolean') { - return fieldSlicing.omit; - } - - // top-level omit if ( this.options.omit?.[model] && typeof this.options.omit[model] === 'object' && 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 72d3580fb..fb24bc902 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -71,14 +71,50 @@ export type SlicingOptions = { */ $all?: ModelSlicingOptions>; }; + + // includedProcedures?: readonly GetProcedureNames[]; + // excludedProcedures?: readonly GetProcedureNames[]; }; -type FilterKinds = 'Equality' | 'Range' | 'List' | 'Like' | 'Relation'; +/** + * Kinds of filter operations. + */ +export enum FilterKind { + /** + * Covers "equals", "not", "in", "notIn". + */ + Equality, + + /** + * Covers "gt", "gte", "lt", "lte". + */ + Range, + + /** + * Covers "contains", "startsWith", "endsWith". + */ + Like, + + /** + * Covers all Json filter operations. + */ + Json, + + /** + * Covers "has", "hasEvery", "hasSome", "isEmpty". + */ + List, + + /** + * Covers "is", "isNot", "some", "none", "every" for relations. + */ + Relation, +} /** * Model slicing options. */ -type ModelSlicingOptions> = { +type ModelSlicingOptions> = { /** * ORM query operations to include for the model. If not specified, all operations are included * by default. @@ -90,36 +126,35 @@ type ModelSlicingOptions]?: ModelFieldSlicingOptions; - }; + // includedFilterKinds?: readonly FilterKind[]; + // excludedFilterKinds?: readonly FilterKind[]; }; -type ModelFieldSlicingOptions< - Schema extends SchemaDef, - Model extends GetModels, - _Field extends GetModelFields, -> = { +/** + * Default query options without any customization. + */ +export type QueryOptions = { /** - * Set to `true` to exclude the field from query results by default. Omitted fields can be re-included at - * query time with an `omit` clause. + * Options for omitting fields in ORM query results. + */ + omit?: OmitConfig; + + /** + * Options for slicing ORM client's capabilities by including/excluding certain models, operations, filters, etc. */ - omit?: boolean; + slicing?: SlicingOptions; /** - * Marks the field as ignored. Contrary to omitted fields, ignored fields are completely unaccessible - * by the ORM client. + * 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. */ - ignore?: boolean; + allowQueryTimeOmitOverride?: boolean; }; /** * ZenStack client options. */ -export type ClientOptions = { +export type ClientOptions = QueryOptions & { /** * Kysely dialect. */ @@ -157,34 +192,14 @@ export type ClientOptions = { * `@@validate`, etc. Defaults to `true`. */ validateInput?: boolean; - - /** - * Options for slicing ORM client's capabilities by including/excluding certain models, operations, filters, etc. - */ - slicing?: SlicingOptions; - - /** - * Options for omitting fields in ORM query results. - * - * @deprecated Use {@link slicing} options instead. - */ - 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. - * - * @deprecated Use {@link slicing} options instead. - */ - allowQueryTimeOmitOverride?: boolean; } & (HasComputedFields extends true - ? { - /** - * Computed field definitions. - */ - computedFields: ComputedFieldsOptions; - } - : {}) & + ? { + /** + * Computed field definitions. + */ + computedFields: ComputedFieldsOptions; + } + : {}) & (HasProcedures extends true ? { /** @@ -230,11 +245,6 @@ export type HasProcedures = Schema extends { : false; /** - * Subset of client options relevant to query operations. - */ -export type QueryOptions = Pick, 'omit' | 'slicing'>; - -/** - * Extract QueryOptions from ClientOptions + * Extract QueryOptions from an object with $options property */ -export type ToQueryOptions> = Pick; +export type GetQueryOptions = T['$options']; diff --git a/packages/orm/src/client/type-utils.ts b/packages/orm/src/client/type-utils.ts new file mode 100644 index 000000000..145838801 --- /dev/null +++ b/packages/orm/src/client/type-utils.ts @@ -0,0 +1,105 @@ +import type { GetModels, SchemaDef } from '@zenstackhq/schema'; +import type { AllCrudOperations } from './crud/operations/base'; +import type { QueryOptions, SlicingOptions } from './options'; + +type IsNever = [T] extends [never] ? true : false; + +/** + * 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; + +/** + * 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 S + ? S 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, Model extends string> = S extends { + models: infer Config; +} + ? Model extends keyof Config + ? 'includedOperations' extends keyof Config[Model] + ? // 'includedOperations' is specified for the model + Config[Model] extends { includedOperations: readonly [] } + ? // special marker for empty array (mute all) + '_none_' + : // use the specified includedOperations + Config[Model] 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 + : never; + +export type GetAllIncludedOperations> = S 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; + +type GetExcludedOperations, Model extends string> = S extends { + models: infer Config; +} + ? Model extends keyof Config + ? Config[Model] 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; + +type GetAllExcludedOperations> = S extends { + models: infer M; +} + ? '$all' extends keyof M + ? M['$all'] extends { excludedOperations: readonly (infer EO)[] } + ? EO + : never + : never + : never; diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index e4afe3cf7..a795db566 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -1,7 +1,7 @@ +import { AllReadOperations } from '@zenstackhq/orm'; import { createTestClient } from '@zenstackhq/testtools'; import { describe, expect, it } from 'vitest'; import { schema } from '../schemas/basic/schema'; -import { AllReadOperations } from '@zenstackhq/orm'; describe('Model slicing tests', () => { describe('Model inclusion/exclusion', () => { @@ -412,157 +412,4 @@ describe('Model slicing tests', () => { expect(db.post.delete).toBeUndefined(); }); }); - - describe('Field slicing (omit)', () => { - it('omits fields specified in field-level slicing', async () => { - const options = { - slicing: { - models: { - User: { - fields: { - email: { omit: true }, - meta: { omit: true }, - }, - }, - }, - }, - dialect: {} as any, - } as const; - - const db = await createTestClient(schema, options); - - // Create a user - const user = await db.user.create({ - data: { - email: 'test@example.com', - name: 'Test User', - meta: { foo: 'bar' }, - }, - }); - - // Omitted fields should not be in the result - expect(user.id).toBeDefined(); - expect(user.name).toBe('Test User'); - expect('email' in user).toBe(false); - expect('meta' in user).toBe(false); - }); - - it('field-level slicing omit overrides top-level omit', async () => { - const options = { - slicing: { - models: { - User: { - fields: { - name: { omit: true }, - }, - }, - }, - }, - omit: { - User: { - email: true, - }, - }, - dialect: {} as any, - } as const; - - const db = await createTestClient(schema, options); - - const user = await db.user.create({ - data: { - email: 'test@example.com', - name: 'Test User', - }, - }); - - expect(user.id).toBeDefined(); - - // @ts-expect-error - name should be omitted by field-level slicing - expect(user.name).toBeUndefined(); - // @ts-expect-error - email should be omitted by top-level omit - expect(user.email).toBeUndefined(); - }); - - it('omit false explicitly includes field even if top-level omits it', async () => { - const options = { - slicing: { - models: { - User: { - fields: { - email: { omit: false }, - }, - }, - }, - }, - omit: { - User: { - email: true, - }, - }, - dialect: {} as any, - } as const; - - const db = await createTestClient(schema, options); - - const user = await db.user.create({ - data: { - email: 'test@example.com', - name: 'Test User', - }, - }); - - // Field-level omit: false should override top-level omit: true - expect(user.email).toBe('test@example.com'); - expect(user.name).toBe('Test User'); - }); - - it('works with findMany and includes', async () => { - const options = { - slicing: { - models: { - User: { - fields: { - email: { omit: true }, - }, - }, - Post: { - fields: { - content: { omit: true }, - }, - }, - }, - }, - dialect: {} as any, - } as const; - - const db = await createTestClient(schema, options); - - // Create test data - const user = await db.user.create({ - data: { - email: 'test@example.com', - name: 'Test User', - posts: { - create: [ - { title: 'Post 1', content: 'Content 1' }, - { title: 'Post 2', content: 'Content 2' }, - ], - }, - }, - }); - - // Query with include - const users = await db.user.findMany({ - where: { id: user.id }, - include: { posts: true }, - }); - - expect(users).toHaveLength(1); - expect('email' in users[0]!).toBe(false); // User.email omitted - expect(users[0]?.name).toBe('Test User'); - expect(users[0]?.posts).toHaveLength(2); - expect('content' in users[0]!.posts[0]!).toBe(false); // Post.content omitted - expect(users[0]?.posts[0]?.title).toBe('Post 1'); - }); - }); }); From 561a116a152780e955f6344ea00b5bd65cbf8cf1 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:08:34 +0800 Subject: [PATCH 03/16] feat: implement procedures slicing --- packages/orm/src/client/client-impl.ts | 33 ++++ packages/orm/src/client/contract.ts | 12 +- packages/orm/src/client/options.ts | 11 +- packages/orm/src/client/type-utils.ts | 28 +++ tests/e2e/orm/client-api/slicing-debug.ts | 20 ++ tests/e2e/orm/client-api/slicing.test.ts | 225 ++++++++++++++++++++++ 6 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/orm/client-api/slicing-debug.ts diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index d70ddf09f..7b19a1d04 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -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); @@ -504,6 +508,35 @@ function isModelIncluded(options: ClientOptions, model: string): bool 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, diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index b6999d13c..321041324 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, @@ -51,7 +50,7 @@ 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 } from './type-utils'; +import type { GetSlicedModels, GetSlicedOperations, GetSlicedProcedures } from './type-utils'; type TransactionUnsupportedMethods = (typeof TRANSACTION_UNSUPPORTED_METHODS)[number]; @@ -240,7 +239,7 @@ export type ClientContract< $pushSchema(): Promise; } & { [Key in GetSlicedModels as Uncapitalize]: ModelOperations; -} & ProcedureOperations & +} & ProcedureOperations & ExtClientMembers; /** @@ -253,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; }; } : {}; diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index fb24bc902..491e531b5 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -72,8 +72,15 @@ export type SlicingOptions = { $all?: ModelSlicingOptions>; }; - // includedProcedures?: readonly GetProcedureNames[]; - // excludedProcedures?: readonly GetProcedureNames[]; + /** + * 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[]; }; /** diff --git a/packages/orm/src/client/type-utils.ts b/packages/orm/src/client/type-utils.ts index 145838801..e003dad14 100644 --- a/packages/orm/src/client/type-utils.ts +++ b/packages/orm/src/client/type-utils.ts @@ -1,4 +1,5 @@ import type { GetModels, SchemaDef } from '@zenstackhq/schema'; +import type { GetProcedureNames } from './crud-types'; import type { AllCrudOperations } from './crud/operations/base'; import type { QueryOptions, SlicingOptions } from './options'; @@ -103,3 +104,30 @@ type GetAllExcludedOperations> = S extends { : never : never : never; + +/** + * 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; diff --git a/tests/e2e/orm/client-api/slicing-debug.ts b/tests/e2e/orm/client-api/slicing-debug.ts new file mode 100644 index 000000000..eca6e7b7a --- /dev/null +++ b/tests/e2e/orm/client-api/slicing-debug.ts @@ -0,0 +1,20 @@ +import type { GetProcedureNames, GetSlicedProcedures, ClientOptions } from '@zenstackhq/orm'; +import type { schema as proceduresSchema } from '../schemas/procedures/schema'; + +// Check what GetProcedureNames returns +type AllProcedures = GetProcedureNames; +// Hover over this to see: should be 'getUser' | 'listUsers' | 'signUp' | 'setAdmin' | 'getOverview' | 'createMultiple' +const _test1: AllProcedures = 'getUser'; + +// Define options with excluded procedures +type Options = ClientOptions & { + readonly slicing: { + readonly excludedProcedures: readonly ['signUp', 'setAdmin', 'createMultiple']; + }; +}; + +// Check what GetSlicedProcedures returns +type SlicedProcedures = GetSlicedProcedures; +// Hover over this to see what it actually is +const _test2: SlicedProcedures = 'getUser'; +const _test3: SlicedProcedures = 'signUp'; // This should be an error but probably isn't diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index a795db566..c47562a77 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -2,6 +2,7 @@ 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('Model slicing tests', () => { describe('Model inclusion/exclusion', () => { @@ -412,4 +413,228 @@ describe('Model slicing tests', () => { 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(); + + await db.$disconnect(); + }); + + 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(); + + await db.$disconnect(); + }); + + 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(); + + await db.$disconnect(); + }); + + 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(); + + await db.$disconnect(); + }); + + 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(); + + await db.$disconnect(); + }); + + 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(); + + await db.$disconnect(); + }); + + it('works with setOptions to change procedure slicing at runtime', async () => { + const options = { + procedures: mockProcedures as any, + slicing: { + includedProcedures: ['getUser', 'listUsers'] as const, + }, + dialect: {} as any, + } as const; + + const db = await createTestClient(proceduresSchema, options); + + // Initially only getUser and listUsers are accessible + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + + // Change slicing options + const db2 = db.$setOptions({ + ...db.$options, + slicing: { + includedProcedures: ['signUp', 'setAdmin'] as const, + }, + } as any); + + // After setOptions, different procedures should be accessible + expect(db2['$procs'].signUp).toBeDefined(); + expect(db2['$procs'].setAdmin).toBeDefined(); + expect(db2['$procs'].getUser).toBeUndefined(); + expect(db2['$procs'].listUsers).toBeUndefined(); + + // Original client should remain unchanged + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + + await db.$disconnect(); + }); + + it('creates query-only procedures by excluding mutations', 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); + + // Only query procedures should be accessible + expect(db.$procs.getUser).toBeDefined(); + expect(db.$procs.listUsers).toBeDefined(); + expect(db.$procs.getOverview).toBeDefined(); + + // Mutation procedures should be excluded + // @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(); + + await db.$disconnect(); + }); + }); }); From c6c686a36735717ff474e12276b7e0967747ab42 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:31:55 +0800 Subject: [PATCH 04/16] fix: TQ hooks implementation --- packages/clients/tanstack-query/src/react.ts | 11 ++-- .../tanstack-query/src/svelte/index.svelte.ts | 37 ++++++----- packages/clients/tanstack-query/src/vue.ts | 37 ++++++----- .../test/svelte-sliced-client.test-d.ts | 48 ++++++++++++++ .../test/vue-sliced-client.test-d.ts | 48 ++++++++++++++ tests/e2e/orm/client-api/slicing-debug.ts | 20 ------ tests/e2e/orm/client-api/slicing.test.ts | 63 ------------------- 7 files changed, 148 insertions(+), 116 deletions(-) create mode 100644 packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts create mode 100644 packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts delete mode 100644 tests/e2e/orm/client-api/slicing-debug.ts diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index b0686bb81..ce4bb9c26 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -40,6 +40,7 @@ import type { GetProcedure, GetProcedureNames, GetSlicedModels, + GetSlicedProcedures, GroupByArgs, GroupByResult, ProcedureEnvelope, @@ -151,10 +152,10 @@ export type ModelMutationModelResult< export type ClientHooks = QueryOptions> = { [Model in GetSlicedModels as `${Uncapitalize}`]: ModelQueryHooks; -} & ProcedureHooks; +} & ProcedureHooks; -type ProcedureHookGroup = { - [Name in GetProcedureNames]: GetProcedure extends { mutation: true } +type ProcedureHookGroup> = { + [Name in GetSlicedProcedures]: GetProcedure extends { mutation: true } ? { useMutation( options?: Omit< @@ -197,13 +198,13 @@ type ProcedureHookGroup = { }; }; -export type ProcedureHooks = +export type ProcedureHooks = QueryOptions> = Schema['procedures'] extends Record ? { /** * Custom procedures. */ - $procs: ProcedureHookGroup; + $procs: ProcedureHookGroup; } : Record; diff --git a/packages/clients/tanstack-query/src/svelte/index.svelte.ts b/packages/clients/tanstack-query/src/svelte/index.svelte.ts index ed743baf2..c5d1e57dc 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, @@ -64,6 +66,7 @@ import type { ProcedureReturn, QueryContext, TrimDelegateModelOperations, + TrimSlicedHooks, WithOptimistic, } from '../common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -145,11 +148,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 +185,15 @@ type ProcedureHookGroup = { }; }; -export type ProcedureHooks = Schema['procedures'] extends Record - ? { - /** - * Custom procedures. - */ - $procs: ProcedureHookGroup; - } - : Record; +export type ProcedureHooks = QueryOptions> = + 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,10 +201,14 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimDelegateModelOperations< +> = TrimSlicedHooks< Schema, Model, - { + Options, + TrimDelegateModelOperations< + Schema, + Model, + { useFindUnique>( args: Accessor>>, options?: Accessor | null>>, @@ -275,6 +283,7 @@ export type ModelQueryHooks< options?: Accessor>>, ): ModelQueryResult>; } + > >; /** diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index ab8821a0f..d6b36bb92 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, @@ -62,6 +64,7 @@ import type { ProcedureReturn, QueryContext, TrimDelegateModelOperations, + TrimSlicedHooks, WithOptimistic, } from './common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -138,11 +141,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 +187,15 @@ type ProcedureHookGroup = { }; }; -export type ProcedureHooks = Schema['procedures'] extends Record - ? { - /** - * Custom procedures. - */ - $procs: ProcedureHookGroup; - } - : Record; +export type ProcedureHooks = QueryOptions> = + 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,10 +203,14 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimDelegateModelOperations< +> = TrimSlicedHooks< Schema, Model, - { + Options, + TrimDelegateModelOperations< + Schema, + Model, + { useFindUnique>( args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter | null>>, @@ -279,6 +287,7 @@ export type ModelQueryHooks< options?: MaybeRefOrGetter>>, ): ModelQueryResult>; } + > >; /** 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..95069d1af --- /dev/null +++ b/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts @@ -0,0 +1,48 @@ +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'; + +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'); + }); +}); 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..4f84f5f83 --- /dev/null +++ b/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts @@ -0,0 +1,48 @@ +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'; + +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'); + }); +}); diff --git a/tests/e2e/orm/client-api/slicing-debug.ts b/tests/e2e/orm/client-api/slicing-debug.ts deleted file mode 100644 index eca6e7b7a..000000000 --- a/tests/e2e/orm/client-api/slicing-debug.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { GetProcedureNames, GetSlicedProcedures, ClientOptions } from '@zenstackhq/orm'; -import type { schema as proceduresSchema } from '../schemas/procedures/schema'; - -// Check what GetProcedureNames returns -type AllProcedures = GetProcedureNames; -// Hover over this to see: should be 'getUser' | 'listUsers' | 'signUp' | 'setAdmin' | 'getOverview' | 'createMultiple' -const _test1: AllProcedures = 'getUser'; - -// Define options with excluded procedures -type Options = ClientOptions & { - readonly slicing: { - readonly excludedProcedures: readonly ['signUp', 'setAdmin', 'createMultiple']; - }; -}; - -// Check what GetSlicedProcedures returns -type SlicedProcedures = GetSlicedProcedures; -// Hover over this to see what it actually is -const _test2: SlicedProcedures = 'getUser'; -const _test3: SlicedProcedures = 'signUp'; // This should be an error but probably isn't diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index c47562a77..2f74ab1c5 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -573,68 +573,5 @@ describe('Model slicing tests', () => { await db.$disconnect(); }); - - it('works with setOptions to change procedure slicing at runtime', async () => { - const options = { - procedures: mockProcedures as any, - slicing: { - includedProcedures: ['getUser', 'listUsers'] as const, - }, - dialect: {} as any, - } as const; - - const db = await createTestClient(proceduresSchema, options); - - // Initially only getUser and listUsers are accessible - expect(db.$procs.getUser).toBeDefined(); - expect(db.$procs.listUsers).toBeDefined(); - - // Change slicing options - const db2 = db.$setOptions({ - ...db.$options, - slicing: { - includedProcedures: ['signUp', 'setAdmin'] as const, - }, - } as any); - - // After setOptions, different procedures should be accessible - expect(db2['$procs'].signUp).toBeDefined(); - expect(db2['$procs'].setAdmin).toBeDefined(); - expect(db2['$procs'].getUser).toBeUndefined(); - expect(db2['$procs'].listUsers).toBeUndefined(); - - // Original client should remain unchanged - expect(db.$procs.getUser).toBeDefined(); - expect(db.$procs.listUsers).toBeDefined(); - - await db.$disconnect(); - }); - - it('creates query-only procedures by excluding mutations', 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); - - // Only query procedures should be accessible - expect(db.$procs.getUser).toBeDefined(); - expect(db.$procs.listUsers).toBeDefined(); - expect(db.$procs.getOverview).toBeDefined(); - - // Mutation procedures should be excluded - // @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(); - - await db.$disconnect(); - }); }); }); From 0ba721936a71f8ac476a12d7c9cc68c5fd157a58 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:51:23 +0800 Subject: [PATCH 05/16] feat: filter slicing --- packages/orm/src/client/constants.ts | 55 +- packages/orm/src/client/contract.ts | 67 +- packages/orm/src/client/crud-types.ts | 969 +++++++++++------- .../src/client/crud/dialects/base-dialect.ts | 14 +- .../dialects/lateral-join-dialect-base.ts | 10 +- .../orm/src/client/crud/dialects/sqlite.ts | 4 +- .../orm/src/client/crud/operations/base.ts | 8 +- .../orm/src/client/crud/operations/create.ts | 15 +- .../orm/src/client/crud/operations/delete.ts | 5 +- .../orm/src/client/crud/operations/find.ts | 5 +- .../orm/src/client/crud/operations/update.ts | 13 +- .../orm/src/client/crud/validator/index.ts | 492 ++++++--- packages/orm/src/client/options.ts | 44 +- packages/orm/src/client/query-utils.ts | 4 +- packages/orm/src/client/type-utils.ts | 128 ++- packages/zod/package.json | 4 +- tests/e2e/orm/client-api/slicing.test.ts | 823 ++++++++++++++- tests/e2e/orm/schemas/basic/schema.ts | 5 + tests/e2e/orm/schemas/basic/schema.zmodel | 1 + 19 files changed, 2029 insertions(+), 637 deletions(-) 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 321041324..2cc293ea3 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -360,12 +360,13 @@ export type AllModelOperations< * ``` */ updateManyAndReturn< - T extends UpdateManyAndReturnArgs & + T extends UpdateManyAndReturnArgs & ExtractExtQueryArgs, >( args: Subset< T, - UpdateManyAndReturnArgs & ExtractExtQueryArgs + UpdateManyAndReturnArgs & + ExtractExtQueryArgs >, ): ZenStackPromise[]>; }); @@ -457,8 +458,8 @@ type CommonModelOperations< * }); // result: `{ _count: { posts: number } }` * ``` */ - findMany & ExtractExtQueryArgs>( - args?: SelectSubset & ExtractExtQueryArgs>, + findMany & ExtractExtQueryArgs>( + args?: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise[]>; /** @@ -467,8 +468,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>; /** @@ -477,8 +478,10 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findUniqueOrThrow & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + findUniqueOrThrow< + T extends FindUniqueArgs & ExtractExtQueryArgs, + >( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -487,8 +490,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>; /** @@ -497,8 +500,8 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findFirstOrThrow & ExtractExtQueryArgs>( - args?: SelectSubset & ExtractExtQueryArgs>, + findFirstOrThrow & ExtractExtQueryArgs>( + args?: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -553,8 +556,8 @@ type CommonModelOperations< * }); * ``` */ - create & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + create & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -703,8 +706,8 @@ type CommonModelOperations< * }); * ``` */ - update & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + update & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -727,8 +730,8 @@ type CommonModelOperations< * limit: 10 * }); */ - updateMany & ExtractExtQueryArgs>( - args: Subset & ExtractExtQueryArgs>, + updateMany & ExtractExtQueryArgs>( + args: Subset & ExtractExtQueryArgs>, ): ZenStackPromise; /** @@ -751,8 +754,8 @@ type CommonModelOperations< * }); * ``` */ - upsert & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + upsert & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -774,8 +777,8 @@ type CommonModelOperations< * }); // result: `{ id: string; email: string }` * ``` */ - delete & ExtractExtQueryArgs>( - args: SelectSubset & ExtractExtQueryArgs>, + delete & ExtractExtQueryArgs>( + args: SelectSubset & ExtractExtQueryArgs>, ): ZenStackPromise>; /** @@ -797,8 +800,8 @@ type CommonModelOperations< * }); * ``` */ - deleteMany & ExtractExtQueryArgs>( - args?: Subset & ExtractExtQueryArgs>, + deleteMany & ExtractExtQueryArgs>( + args?: Subset & ExtractExtQueryArgs>, ): ZenStackPromise; /** @@ -819,8 +822,8 @@ type CommonModelOperations< * select: { _all: true, email: true } * }); // result: `{ _all: number, email: number }` */ - count & ExtractExtQueryArgs>( - args?: Subset & ExtractExtQueryArgs>, + count & ExtractExtQueryArgs>( + args?: Subset & ExtractExtQueryArgs>, ): ZenStackPromise>>; /** @@ -840,8 +843,8 @@ type CommonModelOperations< * _max: { age: true } * }); // result: `{ _count: number, _avg: { age: number }, ... }` */ - aggregate & ExtractExtQueryArgs>( - args: Subset & ExtractExtQueryArgs>, + aggregate & ExtractExtQueryArgs>( + args: Subset & ExtractExtQueryArgs>, ): ZenStackPromise>>; /** @@ -877,8 +880,8 @@ type CommonModelOperations< * having: { country: 'US', age: { _avg: { gte: 18 } } } * }); */ - groupBy & ExtractExtQueryArgs>( - args: Subset & ExtractExtQueryArgs>, + groupBy & ExtractExtQueryArgs>( + args: Subset & ExtractExtQueryArgs>, ): ZenStackPromise>>; /** @@ -898,8 +901,8 @@ type CommonModelOperations< * where: { posts: { some: { published: true } } }, * }); // result: `boolean` */ - exists & ExtractExtQueryArgs>( - args?: Subset & ExtractExtQueryArgs>, + exists & ExtractExtQueryArgs>( + args?: Subset & ExtractExtQueryArgs>, ): ZenStackPromise; }; diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index c7e70ea32..7ef86238f 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 } 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,164 @@ export type WhereInput< ? Key extends RelationFields ? never : Key - : Key]?: Key extends RelationFields + : Key]?: Filter; +} & { + $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; +} & { + AND?: OrArray>; + OR?: WhereInput[]; + NOT?: OrArray>; +}; + +type Filter< + 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< + AllowedKinds extends 'Relation' + ? RelationFilter + : never + : 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 + | ((AllowedKinds extends 'Equality' ? { /** - * Filters against the count of records. + * Checks for equality with the specified enum value. + */ + equals?: NullableIf, Nullable>; + + /** + * Checks if the enum value is in the specified list of values. */ - _count?: NumberFilter<'Int', false, false>; + in?: (keyof GetEnum)[]; /** - * Filters against the minimum value. + * Checks if the enum value is not in the specified list of values. */ - _min?: EnumFilter; + notIn?: (keyof GetEnum)[]; /** - * Filters against the maximum value. + * Builds a negated filter. */ - _max?: EnumFilter; + 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, +> = (AllowedKinds extends 'Equality' + ? { + /** + * Checks if the array equals the specified array. + */ + equals?: MapScalarType[] | null; + } + : {}) & + (AllowedKinds extends 'List' + ? { + /** + * 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 +452,105 @@ type CommonPrimitiveFilter< T extends BuiltinType, Nullable extends boolean, WithAggregations extends boolean, + AllowedKinds extends FilterKind, > = { /** - * 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. + * Builds a negated filter. */ - notIn?: DataType[]; + not?: PrimitiveFilter; +} & (AllowedKinds extends 'Equality' + ? { + /** + * 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[]; + } + : {}) & + (AllowedKinds extends 'Range' + ? { + /** + * 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]; + } + : {}); -export type StringFilter = +export type StringFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = | NullableIf - | (CommonPrimitiveFilter & { - /** - * Checks if the string contains the specified substring. - */ - contains?: string; + | (CommonPrimitiveFilter & + (AllowedKinds extends 'Like' + ? { + /** + * 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 +558,256 @@ export type NumberFilter< T extends 'Int' | 'Float' | 'Decimal' | 'BigInt', Nullable extends boolean, WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, > = | NullableIf - | (CommonPrimitiveFilter & + | (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 = +export type DateTimeFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = | NullableIf - | (CommonPrimitiveFilter & + | (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 = +export type BytesFilter< + Nullable extends boolean, + WithAggregations extends boolean, + AllowedKinds extends FilterKind = FilterKind, +> = | 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 + | ((AllowedKinds extends 'Equality' ? { /** - * 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; - } - : {})); - -export type BooleanFilter = - | NullableIf - | ({ - /** - * Checks for equality with the specified value. - */ - equals?: NullableIf; + notIn?: Uint8Array[]; - /** - * Builds a negated filter. - */ - not?: BooleanFilter; - } & (WithAggregations extends true - ? { /** - * Filters against the count of records. + * Builds a negated filter. */ - _count?: NumberFilter<'Int', false, false>; + not?: BytesFilter; + } + : {}) & + (WithAggregations extends true + ? { + /** + * Filters against the count of records. + */ + _count?: NumberFilter<'Int', false, false, AllowedKinds>; + + /** + * 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, +> = + | NullableIf + | ((AllowedKinds extends 'Equality' + ? { /** - * Filters against the minimum value. + * Checks for equality with the specified value. */ - _min?: BooleanFilter; + equals?: NullableIf; /** - * Filters against the maximum value. + * Builds a negated filter. */ - _max?: BooleanFilter; + 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 = (AllowedKinds extends 'Equality' + ? { + /** + * 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; + } + : {}) & + (AllowedKinds extends 'Json' + ? { + /** + * 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, +> = AllowedKinds extends 'Json' + ? + | (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 +818,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 +909,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 +929,7 @@ export type WhereUniqueInput['uniqueFields'][Key][Key1]> : never; }; - } & WhereInput, + } & WhereInput, Extract['uniqueFields'], string> >; @@ -868,7 +968,7 @@ export type SelectInput< [Key in NonRelationFields]?: boolean; } & (AllowRelation extends true ? IncludeInput : {}); -type SelectCount> = +type SelectCount, Options extends QueryOptions> = | boolean | { /** @@ -878,7 +978,7 @@ type SelectCount> = [Key in RelationFields as FieldIsArray extends true ? Key : never]?: | boolean | { - where: WhereInput, false>; + where: WhereInput, Options, false>; }; }; }; @@ -887,12 +987,14 @@ export type IncludeInput< Schema extends SchemaDef, Model extends GetModels, AllowCount extends boolean = true, + Options extends QueryOptions = {}, > = { [Key in RelationFields]?: | boolean | FindArgs< Schema, RelationFieldType, + Options, FieldIsArray, // where clause is allowed only if the relation is array or optional FieldIsArray extends true @@ -904,7 +1006,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 +1026,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 +1052,7 @@ type ToOneRelationFilter< * Checks if the related record does not match the specified filter. */ isNot?: NullableIf< - WhereInput>, + WhereInput, Options>, ModelFieldIsOptional >; }, @@ -959,10 +1063,11 @@ type RelationFilter< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, + Options extends QueryOptions, > = FieldIsArray extends true - ? ToManyRelationFilter - : ToOneRelationFilter; + ? ToManyRelationFilter + : ToOneRelationFilter; //#endregion @@ -1045,14 +1150,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 +1180,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,25 +1200,45 @@ export type FindArgs< } : {}) : {}) & - (AllowFilter extends true ? FilterArgs : {}) & + (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; +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; +export type CreateArgs< + Schema extends SchemaDef, + Model extends GetModels, + Options extends QueryOptions = QueryOptions, +> = { + data: CreateInput; } & SelectIncludeOmit; export type CreateManyArgs> = CreateManyInput; @@ -1176,17 +1306,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 +1327,7 @@ type CreateRelationFieldPayload< /** * Connects an existing record. */ - connect?: ConnectInput; + connect?: ConnectInput; }, // no "createMany" for non-array fields | (FieldIsArray extends true ? never : 'createMany') @@ -1204,50 +1335,63 @@ 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]: 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]: 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 +1413,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 +1440,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; + 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 & Omit, 'include'>; -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 +1482,7 @@ type UpdateManyPayload /** * The filter to select records to update. */ - where?: WhereInput; + where?: WhereInput; /** * Limit the number of records to update. @@ -1331,21 +1490,25 @@ 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; + where: WhereUniqueInput; } & SelectIncludeOmit; type UpdateScalarInput< @@ -1413,10 +1576,11 @@ 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]?: UpdateRelationFieldPayload; }, Without >; @@ -1424,28 +1588,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 +1621,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 +1673,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 +1720,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; + 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 +1751,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 +1780,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 +1911,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 +1940,7 @@ export type GroupByArgs; + having?: GroupByHaving; /** * Number of records to take for grouping @@ -1855,27 +2039,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 +2071,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 +2097,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 +2105,7 @@ type NestedUpdateInput< data: UpdateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; }, @@ -1925,7 +2117,7 @@ type NestedUpdateInput< /** * Filter to select the record to update. */ - where?: WhereInput>; + where?: WhereInput, Options>; /** * The data to update the record with. @@ -1933,22 +2125,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 +2155,7 @@ type NestedUpsertInput< create: CreateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; @@ -1965,6 +2165,7 @@ type NestedUpsertInput< update: UpdateInput< Schema, RelationFieldType, + Options, OppositeRelationAndFK >; }, @@ -1975,24 +2176,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 ed61d24ef..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), ); } @@ -1180,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); @@ -1370,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 @@ -1411,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..a83fdb2a6 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -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,6 +63,16 @@ 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(); @@ -86,8 +96,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 +105,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 +117,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), @@ -134,8 +147,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 +156,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 +168,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 +177,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 +186,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 +198,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 +207,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 +216,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 +225,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), @@ -555,55 +568,54 @@ 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.has('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 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); } } 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); } } @@ -627,22 +639,13 @@ 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); } else { fieldSchema = z.never(); } } else { // regular field - fieldSchema = this.makePrimitiveFilterSchema( - def.type as BuiltinType, - !!def.optional, - false, - ); + fieldSchema = this.makePrimitiveFilterSchema(model, def, false); } return [key, fieldSchema]; }), @@ -697,7 +700,12 @@ export class InputValidator { } @cache() - private makeTypedJsonFilterSchema(type: string, optional: boolean, array: boolean) { + private makeTypedJsonFilterSchema(model: string, 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,30 +716,20 @@ 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(model, { + ...fieldDef, + name: field, + }).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(model, fieldDef, false).optional(); } else if (fieldDef.array) { - fieldSchemas[fieldName] = this.makeArrayFilterSchema(fieldDef.type as BuiltinType).optional(); + fieldSchemas[fieldName] = this.makeArrayFilterSchema(model, fieldDef).optional(); } else { - fieldSchemas[fieldName] = this.makePrimitiveFilterSchema( - fieldDef.type as BuiltinType, - !!fieldDef.optional, - false, - ).optional(); + fieldSchemas[fieldName] = this.makePrimitiveFilterSchema(model, fieldDef, false).optional(); } } } @@ -739,7 +737,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(model, { name: field, type, optional, array: false })) + .optional(); if (array) { // array filter candidates.push( @@ -760,7 +760,7 @@ export class InputValidator { } // plain json filter - candidates.push(this.makeJsonFilterSchema(optional)); + candidates.push(this.makeJsonFilterSchema(model, field, optional)); if (optional) { // allow null as well @@ -776,49 +776,79 @@ export class InputValidator { } @cache() - private makeEnumFilterSchema(enumName: string, optional: boolean, withAggregations: boolean, array: boolean) { + private makeEnumFilterSchema(model: string, fieldInfo: FieldInfo, withAggregations: boolean) { + 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 = 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, ); + + // If all filter operators are excluded, return z.never() + if (Object.keys(components).length === 0) { + return z.never(); + } + return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); } @cache() - private makeArrayFilterSchema(type: BuiltinType) { - return this.internalMakeArrayFilterSchema(this.makeScalarSchema(type)); + private makeArrayFilterSchema(model: string, fieldInfo: FieldInfo) { + return this.internalMakeArrayFilterSchema( + model, + fieldInfo.name, + this.makeScalarSchema(fieldInfo.type as BuiltinType), + ); } - private internalMakeArrayFilterSchema(elementSchema: ZodType) { - return z.strictObject({ + private internalMakeArrayFilterSchema(model: string, field: string, elementSchema: ZodType) { + const allowedFilterKinds = this.getEffectiveFilterKinds(model, 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(model: string, fieldInfo: FieldInfo, withAggregations: boolean) { + const allowedFilterKinds = this.getEffectiveFilterKinds(model, 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(model, fieldInfo.name, optional)) .with('Unsupported', () => z.never()) .exhaustive(); } @@ -851,7 +881,15 @@ export class InputValidator { } @cache() - private makeJsonFilterSchema(optional: boolean) { + private makeJsonFilterSchema(model: string, field: string, optional: boolean) { + const allowedFilterKinds = this.getEffectiveFilterKinds(model, field); + + // Check if Json filter kind is allowed + if (allowedFilterKinds && !allowedFilterKinds.has('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 +906,49 @@ export class InputValidator { } @cache() - private makeDateTimeFilterSchema(optional: boolean, withAggregations: boolean): ZodType { + private makeDateTimeFilterSchema( + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: Set | 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: Set | 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, ); + + // If all filter operators are excluded, return z.never() + if (Object.keys(components).length === 0) { + return z.never(); + } + return z.union([this.nullableIf(z.boolean(), optional), z.strictObject(components)]); } @cache() - private makeBytesFilterSchema(optional: boolean, withAggregations: boolean): ZodType { + private makeBytesFilterSchema( + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: Set | undefined, + ): ZodType { const baseSchema = z.instanceof(Uint8Array); const components = this.makeCommonPrimitiveFilterComponents( baseSchema, @@ -898,7 +956,14 @@ export class InputValidator { () => z.instanceof(Uint8Array), ['equals', 'in', 'notIn', 'not'], withAggregations ? ['_count', '_min', '_max'] : undefined, + allowedFilterKinds, ); + + // If all filter operators are excluded, return z.never() + if (Object.keys(components).length === 0) { + return z.never(); + } + return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); } @@ -908,12 +973,12 @@ export class InputValidator { makeThis: () => ZodType, supportedOperators: string[] | undefined = undefined, withAggregations: Array<'_count' | '_avg' | '_sum' | '_min' | '_max'> | undefined = undefined, + allowedFilterKinds: Set | 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 +988,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 +999,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 +1010,80 @@ export class InputValidator { baseSchema: ZodType, optional: boolean, makeThis: () => ZodType, - withAggregations: Array | undefined = undefined, + withAggregations: Array | undefined = undefined, + allowedFilterKinds: Set | 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, + ); + + // If all filter operators are excluded, return z.never() + if (Object.keys(components).length === 0) { + return z.never(); + } + + return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); } - private makeNumberFilterSchema(baseSchema: ZodType, optional: boolean, withAggregations: boolean): ZodType { + private makeNumberFilterSchema( + baseSchema: ZodType, + optional: boolean, + withAggregations: boolean, + allowedFilterKinds: Set | 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: Set | 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, + }; + + // If all filter operators are excluded, return z.never() + if (Object.keys(allComponents).length === 0) { + return z.never(); + } + + return z.union([this.nullableIf(z.string(), optional), z.strictObject(allComponents)]); } private makeStringModeSchema() { @@ -1801,7 +1904,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 +1932,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 +1946,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 +2068,96 @@ 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: field-level > model-level $all > global $all. + */ + private getEffectiveFilterKinds(model: string, field: string): Set | undefined { + const slicing = this.options.slicing; + if (!slicing?.models) { + return undefined; // No restrictions + } + + // Check field-level settings for the specific model + const modelConfig = (slicing.models as any)[model]; + if (modelConfig?.fields) { + const fieldConfig = modelConfig.fields[field]; + if (fieldConfig) { + return this.computeFilterKinds(fieldConfig.includedFilterKinds, fieldConfig.excludedFilterKinds); + } + + // Fallback to field-level $all + const allFieldsConfig = modelConfig.fields.$all; + if (allFieldsConfig) { + return this.computeFilterKinds( + allFieldsConfig.includedFilterKinds, + allFieldsConfig.excludedFilterKinds, + ); + } + } + + // Fallback to model-level $all + const allModelsConfig = (slicing.models as any).$all; + if (allModelsConfig?.fields) { + if (allModelsConfig.fields.$all) { + return this.computeFilterKinds( + allModelsConfig.fields.$all.includedFilterKinds, + allModelsConfig.fields.$all.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, + ): Set | undefined { + let result: Set | undefined; + + if (included !== undefined) { + // Start with the included set + result = new Set(included); + } + + if (excluded !== undefined) { + if (!result) { + // If no inclusion list, start with all filter kinds + result = new Set(['Equality', 'Range', 'Like', 'Json', 'List', 'Relation']); + } + // Remove excluded kinds + for (const kind of excluded) { + result.delete(kind); + } + } + + return result; + } + + /** + * Filters operators based on allowed filter kinds. + */ + private trimFilterOperators>( + operators: T, + allowedKinds: Set | undefined, + ): Partial { + if (!allowedKinds) { + return operators; // No restrictions + } + + return Object.fromEntries( + Object.entries(operators).filter(([key, _]) => { + return ( + !(key in FILTER_PROPERTY_TO_KIND) || + allowedKinds.has(FILTER_PROPERTY_TO_KIND[key as keyof typeof FILTER_PROPERTY_TO_KIND]) + ); + }), + ) as Partial; + } + // #endregion } diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 491e531b5..b4db562b7 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -86,42 +86,36 @@ export type SlicingOptions = { /** * Kinds of filter operations. */ -export enum FilterKind { +export type FilterKind = /** * Covers "equals", "not", "in", "notIn". */ - Equality, - + | 'Equality' /** * Covers "gt", "gte", "lt", "lte". */ - Range, - + | 'Range' /** * Covers "contains", "startsWith", "endsWith". */ - Like, - + | 'Like' /** * Covers all Json filter operations. */ - Json, - + | 'Json' /** * Covers "has", "hasEvery", "hasSome", "isEmpty". */ - List, - + | 'List' /** * Covers "is", "isNot", "some", "none", "every" for relations. */ - Relation, -} + | 'Relation'; /** * Model slicing options. */ -type ModelSlicingOptions> = { +export type ModelSlicingOptions> = { /** * ORM query operations to include for the model. If not specified, all operations are included * by default. @@ -133,8 +127,26 @@ type ModelSlicingOptions]?: FieldSlicingOptions; + } & { + $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[]; }; /** 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 index e003dad14..6d7139bcc 100644 --- a/packages/orm/src/client/type-utils.ts +++ b/packages/orm/src/client/type-utils.ts @@ -1,10 +1,12 @@ import type { GetModels, SchemaDef } from '@zenstackhq/schema'; import type { GetProcedureNames } from './crud-types'; import type { AllCrudOperations } from './crud/operations/base'; -import type { QueryOptions, SlicingOptions } from './options'; +import type { FilterKind, QueryOptions, SlicingOptions } from './options'; type IsNever = [T] extends [never] ? true : false; +// #region Model slicing + /** * Filters models based on slicing configuration. */ @@ -28,6 +30,10 @@ export type GetSlicedModels< GetModels : GetModels; +// #endregion + +// #region Operation slicing + /** * Filters query operations based on slicing configuration for a specific model. */ @@ -105,6 +111,10 @@ type GetAllExcludedOperations> = S extends { : never : never; +// #endregion + +// #region Procedure slicing + /** * Filters procedures based on slicing configuration. */ @@ -131,3 +141,119 @@ export type GetSlicedProcedures< : // 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; +} + ? Model extends keyof Config + ? GetIncludedFilterKindsFromModelConfig + : // 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; +} + ? Model extends keyof Config + ? GetExcludedFilterKindsFromModelConfig + : // 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 index 2f74ab1c5..e4f484d36 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'; import { schema } from '../schemas/basic/schema'; import { schema as proceduresSchema } from '../schemas/procedures/schema'; -describe('Model slicing tests', () => { +describe('Query slicing tests', () => { describe('Model inclusion/exclusion', () => { it('includes all models when no slicing config', async () => { const db = await createTestClient(schema); @@ -437,8 +437,6 @@ describe('Model slicing tests', () => { expect(db.$procs.setAdmin).toBeDefined(); expect(db.$procs.getOverview).toBeDefined(); expect(db.$procs.createMultiple).toBeDefined(); - - await db.$disconnect(); }); it('includes only specified procedures with includedProcedures', async () => { @@ -465,8 +463,6 @@ describe('Model slicing tests', () => { expect(db.$procs.getOverview).toBeUndefined(); // @ts-expect-error - createMultiple should not be accessible expect(db.$procs.createMultiple).toBeUndefined(); - - await db.$disconnect(); }); it('excludes specified procedures with excludedProcedures', async () => { @@ -492,8 +488,6 @@ describe('Model slicing tests', () => { expect(db.$procs.setAdmin).toBeUndefined(); // @ts-expect-error - createMultiple should be excluded expect(db.$procs.createMultiple).toBeUndefined(); - - await db.$disconnect(); }); it('applies both includedProcedures and excludedProcedures (exclusion takes precedence)', async () => { @@ -523,8 +517,6 @@ describe('Model slicing tests', () => { expect(db.$procs.getOverview).toBeUndefined(); // @ts-expect-error - createMultiple was not included expect(db.$procs.createMultiple).toBeUndefined(); - - await db.$disconnect(); }); it('excludes all procedures when includedProcedures is empty array', async () => { @@ -551,8 +543,6 @@ describe('Model slicing tests', () => { expect(db.$procs.getOverview).toBeUndefined(); // @ts-expect-error - createMultiple should not be accessible expect(db.$procs.createMultiple).toBeUndefined(); - - await db.$disconnect(); }); it('has no effect when excludedProcedures is empty array', async () => { @@ -570,8 +560,817 @@ describe('Model slicing tests', () => { 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); + + // Range 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"']); + }); - await db.$disconnect(); + 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 - user relation filter excluded + where: { user: { is: { email: 'test' } } }, + }), + ).toBeRejectedByValidation(['"user"']); + }); + }); + + 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"']); + }); }); }); }); 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[] From d4cf8ce859b77e6f78b0302b2346cc4ed7524e41 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:14:39 +0800 Subject: [PATCH 06/16] fix: filter slicing typing --- .../auth-adapters/better-auth/src/adapter.ts | 2 +- packages/orm/src/client/crud-types.ts | 24 ++++++++--------- packages/orm/src/client/options.ts | 27 ++----------------- 3 files changed, 15 insertions(+), 38 deletions(-) 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/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 7ef86238f..04891659a 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -306,7 +306,7 @@ type Filter< > = Field extends RelationFields ? // relation - AllowedKinds extends 'Relation' + 'Relation' extends AllowedKinds ? RelationFilter : never : FieldIsArray extends true @@ -346,7 +346,7 @@ type EnumFilter< AllowedKinds extends FilterKind, > = | NullableIf, Nullable> - | ((AllowedKinds extends 'Equality' + | (('Equality' extends AllowedKinds ? { /** * Checks for equality with the specified enum value. @@ -392,7 +392,7 @@ type ArrayFilter< Schema extends SchemaDef, Type extends string, AllowedKinds extends FilterKind, -> = (AllowedKinds extends 'Equality' +> = ('Equality' extends AllowedKinds ? { /** * Checks if the array equals the specified array. @@ -400,7 +400,7 @@ type ArrayFilter< equals?: MapScalarType[] | null; } : {}) & - (AllowedKinds extends 'List' + ('List' extends AllowedKinds ? { /** * Checks if the array contains all elements of the specified array. @@ -458,7 +458,7 @@ type CommonPrimitiveFilter< * Builds a negated filter. */ not?: PrimitiveFilter; -} & (AllowedKinds extends 'Equality' +} & ('Equality' extends AllowedKinds ? { /** * Checks for equality with the specified value. @@ -476,7 +476,7 @@ type CommonPrimitiveFilter< notIn?: DataType[]; } : {}) & - (AllowedKinds extends 'Range' + ('Range' extends AllowedKinds ? { /** * Checks if the value is less than the specified value. @@ -512,7 +512,7 @@ export type StringFilter< > = | NullableIf | (CommonPrimitiveFilter & - (AllowedKinds extends 'Like' + ('Like' extends AllowedKinds ? { /** * Checks if the string contains the specified substring. @@ -623,7 +623,7 @@ export type BytesFilter< AllowedKinds extends FilterKind = FilterKind, > = | NullableIf - | ((AllowedKinds extends 'Equality' + | (('Equality' extends AllowedKinds ? { /** * Checks for equality with the specified value. @@ -671,7 +671,7 @@ export type BooleanFilter< AllowedKinds extends FilterKind = FilterKind, > = | NullableIf - | ((AllowedKinds extends 'Equality' + | (('Equality' extends AllowedKinds ? { /** * Checks for equality with the specified value. @@ -703,7 +703,7 @@ export type BooleanFilter< } : {})); -export type JsonFilter = (AllowedKinds extends 'Equality' +export type JsonFilter = ('Equality' extends AllowedKinds ? { /** * Checks for equality with the specified value. @@ -716,7 +716,7 @@ export type JsonFilter = (AllowedK not?: JsonValue | JsonNullValues; } : {}) & - (AllowedKinds extends 'Json' + ('Json' extends AllowedKinds ? { /** * JSON path to select the value to filter on. If omitted, the whole JSON value is used. @@ -774,7 +774,7 @@ type TypedJsonTypedFilter< Array extends boolean, Optional extends boolean, AllowedKinds extends FilterKind, -> = AllowedKinds extends 'Json' +> = 'Json' extends AllowedKinds ? | (Array extends true ? ArrayTypedJsonFilter diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index b4db562b7..f02d201b3 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,6 +1,7 @@ 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'; @@ -86,31 +87,7 @@ export type SlicingOptions = { /** * Kinds of filter operations. */ -export type FilterKind = - /** - * Covers "equals", "not", "in", "notIn". - */ - | 'Equality' - /** - * Covers "gt", "gte", "lt", "lte". - */ - | 'Range' - /** - * Covers "contains", "startsWith", "endsWith". - */ - | 'Like' - /** - * Covers all Json filter operations. - */ - | 'Json' - /** - * Covers "has", "hasEvery", "hasSome", "isEmpty". - */ - | 'List' - /** - * Covers "is", "isNot", "some", "none", "every" for relations. - */ - | 'Relation'; +export type FilterKind = FilterPropertyToKind[keyof FilterPropertyToKind]; /** * Model slicing options. From c69a206c8ba6daeb022d972c0e0efe943ce18adb Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:00:01 +0800 Subject: [PATCH 07/16] fix: exclude excluded models from select/include --- packages/orm/src/client/contract.ts | 26 +- packages/orm/src/client/crud-types.ts | 56 ++-- .../orm/src/client/crud/validator/index.ts | 43 +++- packages/orm/src/client/type-utils.ts | 101 ++++---- tests/e2e/orm/client-api/slicing.test.ts | 239 ++++++++++++++++++ 5 files changed, 378 insertions(+), 87 deletions(-) diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index 2cc293ea3..fdc0b2f06 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -299,6 +299,15 @@ export const CRUD_EXT = [...CRUD, 'post-update'] as const; //#region Model operations +type SliceOperations< + T extends Record, + Schema extends SchemaDef, + Model extends GetModels, + Options extends ClientOptions, +> = { + [Key in keyof T as Key extends GetSlicedOperations ? Key : never]: T[Key]; +}; + export type AllModelOperations< Schema extends SchemaDef, Model extends GetModels, @@ -328,12 +337,13 @@ export type AllModelOperations< * ``` */ createManyAndReturn< - T extends CreateManyAndReturnArgs & + T extends CreateManyAndReturnArgs & ExtractExtQueryArgs, >( args?: SelectSubset< T, - CreateManyAndReturnArgs & ExtractExtQueryArgs + CreateManyAndReturnArgs & + ExtractExtQueryArgs >, ): ZenStackPromise[]>; @@ -913,14 +923,10 @@ export type ModelOperations< Model extends GetModels, Options extends ClientOptions = ClientOptions, ExtQueryArgs = {}, -> = Pick< - AllModelOperations, - Exclude< - GetSlicedOperations, - // exclude operations not applicable to delegate models - IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never - > & - keyof AllModelOperations +> = Omit< + SliceOperations, Schema, Model, Options>, + // exclude operations not applicable to delegate models + IsDelegateModel extends true ? OperationsIneligibleForDelegateModels : never >; //#endregion diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 04891659a..ed68ab5dc 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -52,7 +52,7 @@ import type { import type { ClientContract } from './contract'; import type { FilterKind, QueryOptions } from './options'; import type { ToKyselySchema } from './query-builder'; -import type { GetSlicedFilterKindsForField } from './type-utils'; +import type { GetSlicedFilterKindsForField, GetSlicedModels } from './type-utils'; //#region Query results @@ -941,32 +941,36 @@ 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, Options extends QueryOptions> = | boolean @@ -986,10 +990,15 @@ type SelectCount, Opti export type IncludeInput< Schema extends SchemaDef, Model extends GetModels, + Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, - Options extends QueryOptions = {}, > = { - [Key in RelationFields]?: + [Key in RelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]?: | boolean | FindArgs< Schema, @@ -1201,7 +1210,7 @@ export type FindArgs< : {}) : {}) & (AllowFilter extends true ? FilterArgs : {}) & - SelectIncludeOmit; + SelectIncludeOmit; export type FindManyArgs< Schema extends SchemaDef, @@ -1227,7 +1236,7 @@ export type FindUniqueArgs< Options extends QueryOptions = QueryOptions, > = { where: WhereUniqueInput; -} & SelectIncludeOmit; +} & SelectIncludeOmit; //#endregion @@ -1239,16 +1248,15 @@ export type CreateArgs< Options extends QueryOptions = QueryOptions, > = { data: CreateInput; -} & SelectIncludeOmit; +} & 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, keyof T & OptionalFieldsForCreate @@ -1454,7 +1462,7 @@ export type UpdateArgs< * The unique filter to find the record to update. */ where: WhereUniqueInput; -} & SelectIncludeOmit; +} & SelectIncludeOmit; export type UpdateManyArgs< Schema extends SchemaDef, @@ -1466,7 +1474,7 @@ export type UpdateManyAndReturnArgs< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = UpdateManyPayload & Omit, 'include'>; +> = UpdateManyPayload & SelectIncludeOmit; type UpdateManyPayload< Schema extends SchemaDef, @@ -1509,7 +1517,7 @@ export type UpsertArgs< * The unique filter to find the record to update. */ where: WhereUniqueInput; -} & SelectIncludeOmit; +} & SelectIncludeOmit; type UpdateScalarInput< Schema extends SchemaDef, @@ -1729,7 +1737,7 @@ export type DeleteArgs< * The unique filter to find the record to delete. */ where: WhereUniqueInput; -} & SelectIncludeOmit; +} & SelectIncludeOmit; export type DeleteManyArgs< Schema extends SchemaDef, diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index a83fdb2a6..0554ce918 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -138,8 +138,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), @@ -1097,7 +1097,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(); } @@ -1212,7 +1215,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(); + } } } @@ -2159,5 +2165,34 @@ export class InputValidator { ) as Partial; } + /** + * 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/type-utils.ts b/packages/orm/src/client/type-utils.ts index 6d7139bcc..356d5d759 100644 --- a/packages/orm/src/client/type-utils.ts +++ b/packages/orm/src/client/type-utils.ts @@ -41,10 +41,10 @@ export type GetSlicedOperations< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions, -> = Options['slicing'] extends infer S - ? S extends SlicingOptions - ? GetIncludedOperations extends infer IO - ? GetExcludedOperations extends infer EO +> = 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 @@ -58,55 +58,58 @@ export type GetSlicedOperations< : AllCrudOperations : AllCrudOperations; -export type GetIncludedOperations, Model extends string> = S extends { - models: infer Config; -} - ? Model extends keyof Config - ? 'includedOperations' extends keyof Config[Model] - ? // 'includedOperations' is specified for the model - Config[Model] extends { includedOperations: readonly [] } - ? // special marker for empty array (mute all) - '_none_' - : // use the specified includedOperations - Config[Model] 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 - : never; +export type GetIncludedOperations< + Slicing extends SlicingOptions, + Model extends string, +> = 'models' extends keyof Slicing + ? Slicing extends { models: infer Config } + ? Model extends keyof Config + ? 'includedOperations' extends keyof Config[Model] + ? // 'includedOperations' is specified for the model + Config[Model] extends { includedOperations: readonly [] } + ? // special marker for empty array (mute all) + '_none_' + : // use the specified includedOperations + Config[Model] 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> = S extends { - models: infer Config; -} - ? '$all' extends keyof Config - ? Config['$all'] extends { includedOperations: readonly [] } - ? '_none_' - : Config['$all'] extends { includedOperations: readonly (infer IO)[] } - ? IO - : 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> = S extends { - models: infer Config; -} - ? Model extends keyof Config - ? Config[Model] 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 +type GetExcludedOperations, Model extends string> = 'models' extends keyof Slicing + ? Slicing extends { models: infer Config } + ? Model extends keyof Config + ? Config[Model] 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> = S extends { - models: infer M; -} - ? '$all' extends keyof M - ? M['$all'] extends { excludedOperations: readonly (infer EO)[] } - ? EO +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; @@ -203,7 +206,7 @@ type GetFieldIncludedFilterKinds< Model extends string, Field extends string, > = S extends { - models: infer Config; + models?: infer Config; } ? Model extends keyof Config ? GetIncludedFilterKindsFromModelConfig @@ -240,7 +243,7 @@ type GetFieldExcludedFilterKinds< Model extends string, Field extends string, > = S extends { - models: infer Config; + models?: infer Config; } ? Model extends keyof Config ? GetExcludedFilterKindsFromModelConfig diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index e4f484d36..149869d9e 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -158,6 +158,245 @@ describe('Query slicing tests', () => { 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(); + }); }); describe('Operation inclusion/exclusion', () => { From fece8e960a338ab5e08da89ce90047c5d6b61712 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:14:57 +0800 Subject: [PATCH 08/16] fix: relation create/update should respect model exclusion --- packages/orm/src/client/crud-types.ts | 21 ++- .../orm/src/client/crud/validator/index.ts | 8 + tests/e2e/orm/client-api/slicing.test.ts | 150 ++++++++++++++++++ 3 files changed, 176 insertions(+), 3 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index ed68ab5dc..21f1e3b2b 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1351,7 +1351,12 @@ type CreateRelationPayload< Schema, Model, { - [Key in RelationFields]: CreateRelationFieldPayload; + [Key in RelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]: CreateRelationFieldPayload; } >; @@ -1381,7 +1386,12 @@ type CreateWithNonOwnedRelationPayload< Schema, Model, { - [Key in NonOwnedRelationFields]: CreateRelationFieldPayload; + [Key in NonOwnedRelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]: CreateRelationFieldPayload; } >; @@ -1588,7 +1598,12 @@ type UpdateRelationInput< Without extends string = never, > = Omit< { - [Key in RelationFields]?: UpdateRelationFieldPayload; + [Key in RelationFields as RelationFieldType extends GetSlicedModels< + Schema, + Options + > + ? Key + : never]?: UpdateRelationFieldPayload; }, Without >; diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 0554ce918..d5ffa991a 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -1358,6 +1358,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) { @@ -1671,6 +1675,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) { diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index 149869d9e..cc1720cf3 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -397,6 +397,156 @@ describe('Query slicing tests', () => { }); 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', () => { From 681ef2568870872bc59abb4031c44bde2eaffb37 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 17 Feb 2026 20:36:04 +0800 Subject: [PATCH 09/16] fix: direct field value filter should be controlled by filter slicing --- packages/orm/src/client/crud-types.ts | 12 +- .../orm/src/client/crud/validator/index.ts | 101 ++++++++++------- tests/e2e/orm/client-api/slicing.test.ts | 107 ++++++++++++++++++ 3 files changed, 175 insertions(+), 45 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 21f1e3b2b..b788540ca 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -345,7 +345,7 @@ type EnumFilter< WithAggregations extends boolean, AllowedKinds extends FilterKind, > = - | NullableIf, Nullable> + | ('Equality' extends AllowedKinds ? NullableIf, Nullable> : never) | (('Equality' extends AllowedKinds ? { /** @@ -510,7 +510,7 @@ export type StringFilter< WithAggregations extends boolean, AllowedKinds extends FilterKind = FilterKind, > = - | NullableIf + | ('Equality' extends AllowedKinds ? NullableIf : never) | (CommonPrimitiveFilter & ('Like' extends AllowedKinds ? { @@ -560,7 +560,7 @@ export type NumberFilter< WithAggregations extends boolean, AllowedKinds extends FilterKind = FilterKind, > = - | NullableIf + | ('Equality' extends AllowedKinds ? NullableIf : never) | (CommonPrimitiveFilter & (WithAggregations extends true ? { @@ -596,7 +596,7 @@ export type DateTimeFilter< WithAggregations extends boolean, AllowedKinds extends FilterKind = FilterKind, > = - | NullableIf + | ('Equality' extends AllowedKinds ? NullableIf : never) | (CommonPrimitiveFilter & (WithAggregations extends true ? { @@ -622,7 +622,7 @@ export type BytesFilter< WithAggregations extends boolean, AllowedKinds extends FilterKind = FilterKind, > = - | NullableIf + | ('Equality' extends AllowedKinds ? NullableIf : never) | (('Equality' extends AllowedKinds ? { /** @@ -670,7 +670,7 @@ export type BooleanFilter< WithAggregations extends boolean, AllowedKinds extends FilterKind = FilterKind, > = - | NullableIf + | ('Equality' extends AllowedKinds ? NullableIf : never) | (('Equality' extends AllowedKinds ? { /** diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index d5ffa991a..2b8cd2f5f 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -559,6 +559,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); @@ -602,11 +613,13 @@ export class InputValidator { } } } 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(model, fieldDef, withAggregations); + fieldSchema = this.makeEnumFilterSchema(model, fieldDef, withAggregations, ignoreSlicing); } } else if (fieldDef.array) { // array field @@ -615,7 +628,7 @@ export class InputValidator { fieldSchema = this.makeTypedJsonFilterSchema(model, fieldDef); } else { // primitive field - fieldSchema = this.makePrimitiveFilterSchema(model, fieldDef, withAggregations); + fieldSchema = this.makePrimitiveFilterSchema(model, fieldDef, withAggregations, ignoreSlicing); } } @@ -626,6 +639,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) { @@ -639,13 +653,12 @@ export class InputValidator { if (enumDef) { // enum if (Object.keys(enumDef.values).length > 0) { - fieldSchema = this.makeEnumFilterSchema(model, def, false); + fieldSchema = this.makeEnumFilterSchema(model, def, false, true); } else { fieldSchema = z.never(); } } else { - // regular field - fieldSchema = this.makePrimitiveFilterSchema(model, def, false); + fieldSchema = this.makePrimitiveFilterSchema(model, def, false, true); } return [key, fieldSchema]; }), @@ -776,7 +789,12 @@ export class InputValidator { } @cache() - private makeEnumFilterSchema(model: string, fieldInfo: FieldInfo, withAggregations: boolean) { + private makeEnumFilterSchema( + model: string, + fieldInfo: FieldInfo, + withAggregations: boolean, + ignoreSlicing: boolean = false, + ) { const enumName = fieldInfo.type; const optional = !!fieldInfo.optional; const array = !!fieldInfo.array; @@ -787,7 +805,7 @@ export class InputValidator { if (array) { return this.internalMakeArrayFilterSchema(model, fieldInfo.name, baseSchema); } - const allowedFilterKinds = this.getEffectiveFilterKinds(model, fieldInfo.name); + const allowedFilterKinds = ignoreSlicing ? undefined : this.getEffectiveFilterKinds(model, fieldInfo.name); const components = this.makeCommonPrimitiveFilterComponents( baseSchema, optional, @@ -797,12 +815,7 @@ export class InputValidator { allowedFilterKinds, ); - // If all filter operators are excluded, return z.never() - if (Object.keys(components).length === 0) { - return z.never(); - } - - return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); + return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds); } @cache() @@ -831,8 +844,13 @@ export class InputValidator { } @cache() - private makePrimitiveFilterSchema(model: string, fieldInfo: FieldInfo, withAggregations: boolean) { - const allowedFilterKinds = this.getEffectiveFilterKinds(model, fieldInfo.name); + private makePrimitiveFilterSchema( + model: string, + fieldInfo: FieldInfo, + withAggregations: boolean, + ignoreSlicing = false, + ) { + const allowedFilterKinds = ignoreSlicing ? undefined : this.getEffectiveFilterKinds(model, fieldInfo.name); const type = fieldInfo.type as BuiltinType; const optional = !!fieldInfo.optional; return match(type) @@ -935,12 +953,7 @@ export class InputValidator { allowedFilterKinds, ); - // If all filter operators are excluded, return z.never() - if (Object.keys(components).length === 0) { - return z.never(); - } - - return z.union([this.nullableIf(z.boolean(), optional), z.strictObject(components)]); + return this.createUnionFilterSchema(z.boolean(), optional, components, allowedFilterKinds); } @cache() @@ -959,12 +972,7 @@ export class InputValidator { allowedFilterKinds, ); - // If all filter operators are excluded, return z.never() - if (Object.keys(components).length === 0) { - return z.never(); - } - - return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); + return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds); } private makeCommonPrimitiveFilterComponents( @@ -1022,12 +1030,7 @@ export class InputValidator { allowedFilterKinds, ); - // If all filter operators are excluded, return z.never() - if (Object.keys(components).length === 0) { - return z.never(); - } - - return z.union([this.nullableIf(baseSchema, optional), z.strictObject(components)]); + return this.createUnionFilterSchema(baseSchema, optional, components, allowedFilterKinds); } private makeNumberFilterSchema( @@ -1078,12 +1081,7 @@ export class InputValidator { ...filteredStringOperators, }; - // If all filter operators are excluded, return z.never() - if (Object.keys(allComponents).length === 0) { - return z.never(); - } - - return z.union([this.nullableIf(z.string(), optional), z.strictObject(allComponents)]); + return this.createUnionFilterSchema(z.string(), optional, allComponents, allowedFilterKinds); } private makeStringModeSchema() { @@ -2173,6 +2171,31 @@ export class InputValidator { ) as Partial; } + private createUnionFilterSchema( + valueSchema: ZodType, + optional: boolean, + components: Record, + allowedFilterKinds: Set | undefined, + ) { + // If all filter operators are excluded + if (Object.keys(components).length === 0) { + // if equality filters are allowed, allow direct value + if (!allowedFilterKinds || allowedFilterKinds.has('Equality')) { + return this.nullableIf(valueSchema, optional); + } + // otherwise nothing is allowed + return z.never(); + } + + if (!allowedFilterKinds || allowedFilterKinds.has('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. diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index cc1720cf3..6fd44b6ee 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -1761,5 +1761,112 @@ describe('Query slicing tests', () => { ).toBeRejectedByValidation(['"equals"']); }); }); + + 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'); + }); + }); }); }); From 663fcce05dd368b4dbe4d3e7605dd55e093291fa Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:32:45 +0800 Subject: [PATCH 10/16] fix: tanstack-query typing for filter slicing --- packages/clients/tanstack-query/src/react.ts | 76 +++++++++---------- .../tanstack-query/src/svelte/index.svelte.ts | 48 ++++++------ packages/clients/tanstack-query/src/vue.ts | 48 ++++++------ .../test/react-sliced-client.test-d.ts | 45 +++++++++++ .../test/svelte-sliced-client.test-d.ts | 45 +++++++++++ .../test/vue-sliced-client.test-d.ts | 45 +++++++++++ 6 files changed, 221 insertions(+), 86 deletions(-) diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index ce4bb9c26..0a15ce6fa 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -222,52 +222,52 @@ export type ModelQueryHooks< Schema, Model, { - 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; @@ -275,61 +275,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 c5d1e57dc..60bd234b1 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -209,32 +209,32 @@ export type ModelQueryHooks< Schema, Model, { - 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; @@ -242,44 +242,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 d6b36bb92..5bb364cbd 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -211,32 +211,32 @@ export type ModelQueryHooks< Schema, Model, { - 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; @@ -244,46 +244,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 index edda1afbb..9d0405757 100644 --- a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts @@ -2,6 +2,7 @@ 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, { @@ -45,4 +46,48 @@ describe('React client sliced client test', () => { 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/svelte-sliced-client.test-d.ts b/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts index 95069d1af..92fbae94a 100644 --- a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts @@ -2,6 +2,7 @@ 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, { @@ -45,4 +46,48 @@ describe('Svelte client sliced client test', () => { 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 index 4f84f5f83..327f81628 100644 --- a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts @@ -2,6 +2,7 @@ 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, { @@ -45,4 +46,48 @@ describe('Vue client sliced client test', () => { 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'); + }); }); From 3cf6c1a233d082c9a15c44dde8d526e10b0d8453 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:32:50 -0800 Subject: [PATCH 11/16] fix: misc improvements - type simplification - addressing PR comments --- .../tanstack-query/src/common/types.ts | 23 +- packages/clients/tanstack-query/src/react.ts | 235 +++++++++--------- .../tanstack-query/src/svelte/index.svelte.ts | 13 +- packages/clients/tanstack-query/src/vue.ts | 13 +- packages/orm/src/client/contract.ts | 19 +- packages/orm/src/client/crud-types.ts | 72 +++--- .../orm/src/client/crud/validator/index.ts | 51 ++-- packages/orm/src/client/options.ts | 26 +- 8 files changed, 226 insertions(+), 226 deletions(-) diff --git a/packages/clients/tanstack-query/src/common/types.ts b/packages/clients/tanstack-query/src/common/types.ts index 02aa4248e..f745ceeb4 100644 --- a/packages/clients/tanstack-query/src/common/types.ts +++ b/packages/clients/tanstack-query/src/common/types.ts @@ -63,28 +63,25 @@ type HooksOperationsIneligibleForDelegateModels = OperationsIneligibleForDelegat ? `use${Capitalize}` : never; -/** - * Trim operations that are ineligible for delegate models from the given model operations type. - */ -export type TrimDelegateModelOperations< - Schema extends SchemaDef, - Model extends GetModels, - T extends Record, -> = IsDelegateModel extends true ? Omit : T; - -type Modifiers = '' | 'Suspense' | 'Infinite' | `SuspenseInfinite`; +type Modifiers = '' | 'Suspense' | 'Infinite' | 'SuspenseInfinite'; /** - * Trim hooks based on slicing configuration. + * Trim CRUD operation hooks to include only eligible operations. */ -export type TrimSlicedHooks< +export type TrimSlicedOperations< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions, T extends Record, > = { + // trim operations based on slicing options [Key in keyof T as Key extends `use${Modifiers}${Capitalize>}` - ? Key + ? IsDelegateModel extends true + ? // trim operations ineligible for delegate models + Key extends HooksOperationsIneligibleForDelegateModels + ? never + : Key + : Key : never]: T[Key]; }; diff --git a/packages/clients/tanstack-query/src/react.ts b/packages/clients/tanstack-query/src/react.ts index 0a15ce6fa..5129750bf 100644 --- a/packages/clients/tanstack-query/src/react.ts +++ b/packages/clients/tanstack-query/src/react.ts @@ -64,8 +64,7 @@ import type { ExtraQueryOptions, ProcedureReturn, QueryContext, - TrimDelegateModelOperations, - TrimSlicedHooks, + TrimSlicedOperations, WithOptimistic, } from './common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -198,7 +197,7 @@ type ProcedureHookGroup = QueryOptions> = +export type ProcedureHooks> = Schema['procedures'] extends Record ? { /** @@ -214,126 +213,122 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimSlicedHooks< +> = TrimSlicedOperations< Schema, Model, Options, - TrimDelegateModelOperations< - Schema, - Model, - { - useFindUnique>( - args: SelectSubset>, - options?: ModelQueryOptions | null>, - ): ModelQueryResult | null>; - - useSuspenseFindUnique>( - args: SelectSubset>, - options?: ModelSuspenseQueryOptions | null>, - ): ModelSuspenseQueryResult | null>; - - useFindFirst>( - args?: SelectSubset>, - options?: ModelQueryOptions | null>, - ): ModelQueryResult | null>; - - useSuspenseFindFirst>( - args?: SelectSubset>, - options?: ModelSuspenseQueryOptions | null>, - ): ModelSuspenseQueryResult | null>; - - useExists>( - args?: Subset>, - options?: ModelQueryOptions, - ): ModelQueryResult; - - useFindMany>( - args?: SelectSubset>, - options?: ModelQueryOptions[]>, - ): ModelQueryResult[]>; - - useSuspenseFindMany>( - args?: SelectSubset>, - options?: ModelSuspenseQueryOptions[]>, - ): ModelSuspenseQueryResult[]>; - - useInfiniteFindMany>( - args?: SelectSubset>, - options?: ModelInfiniteQueryOptions[]>, - ): ModelInfiniteQueryResult[]>>; - - useSuspenseInfiniteFindMany>( - args?: SelectSubset>, - options?: ModelSuspenseInfiniteQueryOptions[]>, - ): ModelSuspenseInfiniteQueryResult[]>>; - - useCreate>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useCreateMany>( - options?: ModelMutationOptions, - ): ModelMutationResult; - - useCreateManyAndReturn>( - options?: ModelMutationOptions[], T>, - ): ModelMutationModelResult; - - useUpdate>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useUpdateMany>( - options?: ModelMutationOptions, - ): ModelMutationResult; - - useUpdateManyAndReturn>( - options?: ModelMutationOptions[], T>, - ): ModelMutationModelResult; - - useUpsert>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useDelete>( - options?: ModelMutationOptions, T>, - ): ModelMutationModelResult; - - useDeleteMany>( - options?: ModelMutationOptions, - ): ModelMutationResult; - - useCount>( - args?: Subset>, - options?: ModelQueryOptions>, - ): ModelQueryResult>; - - useSuspenseCount>( - args?: Subset>, - options?: ModelSuspenseQueryOptions>, - ): ModelSuspenseQueryResult>; - - useAggregate>( - args: Subset>, - options?: ModelQueryOptions>, - ): ModelQueryResult>; - - useSuspenseAggregate>( - args: Subset>, - options?: ModelSuspenseQueryOptions>, - ): ModelSuspenseQueryResult>; - - useGroupBy>( - args: Subset>, - options?: ModelQueryOptions>, - ): ModelQueryResult>; - - useSuspenseGroupBy>( - args: Subset>, - options?: ModelSuspenseQueryOptions>, - ): ModelSuspenseQueryResult>; - } - > + { + useFindUnique>( + args: SelectSubset>, + options?: ModelQueryOptions | null>, + ): ModelQueryResult | null>; + + useSuspenseFindUnique>( + args: SelectSubset>, + options?: ModelSuspenseQueryOptions | null>, + ): ModelSuspenseQueryResult | null>; + + useFindFirst>( + args?: SelectSubset>, + options?: ModelQueryOptions | null>, + ): ModelQueryResult | null>; + + useSuspenseFindFirst>( + args?: SelectSubset>, + options?: ModelSuspenseQueryOptions | null>, + ): ModelSuspenseQueryResult | null>; + + useExists>( + args?: Subset>, + options?: ModelQueryOptions, + ): ModelQueryResult; + + useFindMany>( + args?: SelectSubset>, + options?: ModelQueryOptions[]>, + ): ModelQueryResult[]>; + + useSuspenseFindMany>( + args?: SelectSubset>, + options?: ModelSuspenseQueryOptions[]>, + ): ModelSuspenseQueryResult[]>; + + useInfiniteFindMany>( + args?: SelectSubset>, + options?: ModelInfiniteQueryOptions[]>, + ): ModelInfiniteQueryResult[]>>; + + useSuspenseInfiniteFindMany>( + args?: SelectSubset>, + options?: ModelSuspenseInfiniteQueryOptions[]>, + ): ModelSuspenseInfiniteQueryResult[]>>; + + useCreate>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useCreateMany>( + options?: ModelMutationOptions, + ): ModelMutationResult; + + useCreateManyAndReturn>( + options?: ModelMutationOptions[], T>, + ): ModelMutationModelResult; + + useUpdate>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useUpdateMany>( + options?: ModelMutationOptions, + ): ModelMutationResult; + + useUpdateManyAndReturn>( + options?: ModelMutationOptions[], T>, + ): ModelMutationModelResult; + + useUpsert>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useDelete>( + options?: ModelMutationOptions, T>, + ): ModelMutationModelResult; + + useDeleteMany>( + options?: ModelMutationOptions, + ): ModelMutationResult; + + useCount>( + args?: Subset>, + options?: ModelQueryOptions>, + ): ModelQueryResult>; + + useSuspenseCount>( + args?: Subset>, + options?: ModelSuspenseQueryOptions>, + ): ModelSuspenseQueryResult>; + + useAggregate>( + args: Subset>, + options?: ModelQueryOptions>, + ): ModelQueryResult>; + + useSuspenseAggregate>( + args: Subset>, + options?: ModelSuspenseQueryOptions>, + ): ModelSuspenseQueryResult>; + + useGroupBy>( + args: Subset>, + options?: ModelQueryOptions>, + ): ModelQueryResult>; + + 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 60bd234b1..45af47cb3 100644 --- a/packages/clients/tanstack-query/src/svelte/index.svelte.ts +++ b/packages/clients/tanstack-query/src/svelte/index.svelte.ts @@ -65,8 +65,7 @@ import type { ExtraQueryOptions, ProcedureReturn, QueryContext, - TrimDelegateModelOperations, - TrimSlicedHooks, + TrimSlicedOperations, WithOptimistic, } from '../common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -185,7 +184,7 @@ type ProcedureHookGroup = QueryOptions> = +export type ProcedureHooks> = Schema['procedures'] extends Record ? { /** @@ -201,14 +200,11 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimSlicedHooks< +> = TrimSlicedOperations< Schema, Model, Options, - TrimDelegateModelOperations< - Schema, - Model, - { + { useFindUnique>( args: Accessor>>, options?: Accessor | null>>, @@ -283,7 +279,6 @@ export type ModelQueryHooks< options?: Accessor>>, ): ModelQueryResult>; } - > >; /** diff --git a/packages/clients/tanstack-query/src/vue.ts b/packages/clients/tanstack-query/src/vue.ts index 5bb364cbd..b45a28333 100644 --- a/packages/clients/tanstack-query/src/vue.ts +++ b/packages/clients/tanstack-query/src/vue.ts @@ -63,8 +63,7 @@ import type { ExtraQueryOptions, ProcedureReturn, QueryContext, - TrimDelegateModelOperations, - TrimSlicedHooks, + TrimSlicedOperations, WithOptimistic, } from './common/types.js'; export type { FetchFn } from '@zenstackhq/client-helpers/fetch'; @@ -187,7 +186,7 @@ type ProcedureHookGroup = QueryOptions> = +export type ProcedureHooks> = Schema['procedures'] extends Record ? { /** @@ -203,14 +202,11 @@ export type ModelQueryHooks< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = TrimSlicedHooks< +> = TrimSlicedOperations< Schema, Model, Options, - TrimDelegateModelOperations< - Schema, - Model, - { + { useFindUnique>( args: MaybeRefOrGetter>>, options?: MaybeRefOrGetter | null>>, @@ -287,7 +283,6 @@ export type ModelQueryHooks< options?: MaybeRefOrGetter>>, ): ModelQueryResult>; } - > >; /** diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index fdc0b2f06..9b038722f 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -297,16 +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, -> = { - [Key in keyof T as Key extends GetSlicedOperations ? Key : never]: T[Key]; -}; +> = 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, @@ -923,11 +928,7 @@ export type ModelOperations< Model extends GetModels, Options extends ClientOptions = ClientOptions, ExtQueryArgs = {}, -> = Omit< - SliceOperations, Schema, Model, Options>, - // 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 b788540ca..5b050f56a 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -287,7 +287,7 @@ export type WhereInput< ? Key extends RelationFields ? never : Key - : Key]?: Filter; + : Key]?: FieldFilter; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -296,7 +296,7 @@ export type WhereInput< NOT?: OrArray>; }; -type Filter< +type FieldFilter< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, @@ -306,9 +306,7 @@ type Filter< > = Field extends RelationFields ? // relation - 'Relation' extends AllowedKinds - ? RelationFilter - : never + RelationFilter : FieldIsArray extends true ? // array ArrayFilter, AllowedKinds> @@ -362,14 +360,13 @@ type EnumFilter< * 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 + : {}) & { + /** + * Builds a negated filter. + */ + not?: EnumFilter; + } & (WithAggregations extends true ? { /** * Filters against the count of records. @@ -453,12 +450,7 @@ type CommonPrimitiveFilter< Nullable extends boolean, WithAggregations extends boolean, AllowedKinds extends FilterKind, -> = { - /** - * Builds a negated filter. - */ - not?: PrimitiveFilter; -} & ('Equality' extends AllowedKinds +> = ('Equality' extends AllowedKinds ? { /** * Checks for equality with the specified value. @@ -503,7 +495,12 @@ type CommonPrimitiveFilter< */ between?: [start: DataType, end: DataType]; } - : {}); + : {}) & { + /** + * Builds a negated filter. + */ + not?: PrimitiveFilter; + }; export type StringFilter< Nullable extends boolean, @@ -639,14 +636,13 @@ export type BytesFilter< * Checks if the value is not in the specified list of values. */ notIn?: Uint8Array[]; - - /** - * Builds a negated filter. - */ - not?: BytesFilter; } - : {}) & - (WithAggregations extends true + : {}) & { + /** + * Builds a negated filter. + */ + not?: BytesFilter; + } & (WithAggregations extends true ? { /** * Filters against the count of records. @@ -677,14 +673,13 @@ export type BooleanFilter< * Checks for equality with the specified value. */ equals?: NullableIf; - - /** - * Builds a negated filter. - */ - not?: BooleanFilter; } - : {}) & - (WithAggregations extends true + : {}) & { + /** + * Builds a negated filter. + */ + not?: BooleanFilter; + } & (WithAggregations extends true ? { /** * Filters against the count of records. @@ -1073,10 +1068,12 @@ type RelationFilter< Model extends GetModels, Field extends RelationFields, Options extends QueryOptions, -> = - FieldIsArray extends true + AllowedKinds extends FilterKind, +> = 'Relation' extends AllowedKinds + ? FieldIsArray extends true ? ToManyRelationFilter - : ToOneRelationFilter; + : ToOneRelationFilter + : never; //#endregion @@ -1256,7 +1253,8 @@ export type CreateManyAndReturnArgs< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, -> = CreateManyInput & SelectIncludeOmit; +> = CreateManyInput & SelectIncludeOmit; + type OptionalWrap, T extends object> = Optional< T, keyof T & OptionalFieldsForCreate diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 2b8cd2f5f..343027cdf 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -713,7 +713,7 @@ export class InputValidator { } @cache() - private makeTypedJsonFilterSchema(model: string, fieldInfo: FieldInfo) { + private makeTypedJsonFilterSchema(contextModel: string | undefined, fieldInfo: FieldInfo) { const field = fieldInfo.name; const type = fieldInfo.type; const optional = !!fieldInfo.optional; @@ -730,19 +730,20 @@ export class InputValidator { for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) { if (this.isTypeDefType(fieldDef.type)) { // recursive typed JSON - use same model/field for nested typed JSON - fieldSchemas[fieldName] = this.makeTypedJsonFilterSchema(model, { - ...fieldDef, - name: field, - }).optional(); + fieldSchemas[fieldName] = this.makeTypedJsonFilterSchema(contextModel, fieldDef).optional(); } else { // enum, array, primitives const enumDef = getEnum(this.schema, fieldDef.type); if (enumDef) { - fieldSchemas[fieldName] = this.makeEnumFilterSchema(model, fieldDef, false).optional(); + fieldSchemas[fieldName] = this.makeEnumFilterSchema(contextModel, fieldDef, false).optional(); } else if (fieldDef.array) { - fieldSchemas[fieldName] = this.makeArrayFilterSchema(model, fieldDef).optional(); + fieldSchemas[fieldName] = this.makeArrayFilterSchema(contextModel, fieldDef).optional(); } else { - fieldSchemas[fieldName] = this.makePrimitiveFilterSchema(model, fieldDef, false).optional(); + fieldSchemas[fieldName] = this.makePrimitiveFilterSchema( + contextModel, + fieldDef, + false, + ).optional(); } } } @@ -751,7 +752,7 @@ export class InputValidator { } const recursiveSchema = z - .lazy(() => this.makeTypedJsonFilterSchema(model, { name: field, type, optional, array: false })) + .lazy(() => this.makeTypedJsonFilterSchema(contextModel, { name: field, type, optional, array: false })) .optional(); if (array) { // array filter @@ -773,7 +774,7 @@ export class InputValidator { } // plain json filter - candidates.push(this.makeJsonFilterSchema(model, field, optional)); + candidates.push(this.makeJsonFilterSchema(contextModel, field, optional)); if (optional) { // allow null as well @@ -790,7 +791,7 @@ export class InputValidator { @cache() private makeEnumFilterSchema( - model: string, + model: string | undefined, fieldInfo: FieldInfo, withAggregations: boolean, ignoreSlicing: boolean = false, @@ -819,7 +820,7 @@ export class InputValidator { } @cache() - private makeArrayFilterSchema(model: string, fieldInfo: FieldInfo) { + private makeArrayFilterSchema(model: string | undefined, fieldInfo: FieldInfo) { return this.internalMakeArrayFilterSchema( model, fieldInfo.name, @@ -827,8 +828,8 @@ export class InputValidator { ); } - private internalMakeArrayFilterSchema(model: string, field: string, elementSchema: ZodType) { - const allowedFilterKinds = this.getEffectiveFilterKinds(model, field); + private internalMakeArrayFilterSchema(contextModel: string | undefined, field: string, elementSchema: ZodType) { + const allowedFilterKinds = this.getEffectiveFilterKinds(contextModel, field); const operators = { equals: elementSchema.array().optional(), has: elementSchema.optional(), @@ -845,12 +846,14 @@ export class InputValidator { @cache() private makePrimitiveFilterSchema( - model: string, + contextModel: string | undefined, fieldInfo: FieldInfo, withAggregations: boolean, ignoreSlicing = false, ) { - const allowedFilterKinds = ignoreSlicing ? undefined : this.getEffectiveFilterKinds(model, fieldInfo.name); + const allowedFilterKinds = ignoreSlicing + ? undefined + : this.getEffectiveFilterKinds(contextModel, fieldInfo.name); const type = fieldInfo.type as BuiltinType; const optional = !!fieldInfo.optional; return match(type) @@ -866,7 +869,7 @@ export class InputValidator { .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(model, fieldInfo.name, optional)) + .with('Json', () => this.makeJsonFilterSchema(contextModel, fieldInfo.name, optional)) .with('Unsupported', () => z.never()) .exhaustive(); } @@ -899,8 +902,8 @@ export class InputValidator { } @cache() - private makeJsonFilterSchema(model: string, field: string, optional: boolean) { - const allowedFilterKinds = this.getEffectiveFilterKinds(model, field); + 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.has('Json')) { @@ -2084,10 +2087,16 @@ export class InputValidator { * Gets the effective set of allowed FilterKind values for a specific model and field. * Respects the precedence: field-level > model-level $all > global $all. */ - private getEffectiveFilterKinds(model: string, field: string): Set | undefined { + private getEffectiveFilterKinds(model: string | undefined, field: string): Set | undefined { + if (!model) { + // no restrictions + return undefined; + } + const slicing = this.options.slicing; if (!slicing?.models) { - return undefined; // No restrictions + // no slicing or no model-specific slicing, no restrictions + return undefined; } // Check field-level settings for the specific model diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index f02d201b3..a6a743ea3 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -44,7 +44,7 @@ export type ZModelFunction = ( /** * Options for slicing ORM client's capabilities by including/excluding certain models, operations, - * fields, or filter kinds. + * filters, etc. */ export type SlicingOptions = { /** @@ -104,9 +104,19 @@ export type ModelSlicingOptions]?: FieldSlicingOptions; } & { + /** + * Field slicing options that apply to all fields. Field-specific options will override these + * general options if both are specified. + */ $all?: FieldSlicingOptions; }; }; @@ -127,7 +137,7 @@ type FieldSlicingOptions = { }; /** - * Default query options without any customization. + * Partial ORM client options that defines customizable behaviors. */ export type QueryOptions = { /** @@ -135,16 +145,16 @@ export type QueryOptions = { */ omit?: OmitConfig; - /** - * Options for slicing ORM client's capabilities by including/excluding certain models, operations, filters, etc. - */ - slicing?: SlicingOptions; - /** * 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; }; /** @@ -241,6 +251,6 @@ export type HasProcedures = Schema extends { : false; /** - * Extract QueryOptions from an object with $options property + * Extracts QueryOptions from an object with '$options' property. */ export type GetQueryOptions = T['$options']; From 3ed02fe96845f1edd1de7324760b821975c07a61 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 18 Feb 2026 05:19:26 -0800 Subject: [PATCH 12/16] fix: stable cache keys --- .../orm/src/client/crud/validator/index.ts | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 343027cdf..390b5d96f 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -75,6 +75,7 @@ type FieldInfo = { export class InputValidator { private readonly schemaCache = new Map(); + private readonly allFilterKinds = [...new Set(Object.values(FILTER_PROPERTY_TO_KIND))]; constructor(private readonly client: ClientContract) {} @@ -582,7 +583,7 @@ export class InputValidator { // Check if Relation filter kind is allowed const allowedFilterKinds = this.getEffectiveFilterKinds(model, field); - if (allowedFilterKinds && !allowedFilterKinds.has('Relation')) { + if (allowedFilterKinds && !allowedFilterKinds.includes('Relation')) { // Relation filters are not allowed for this field - use z.never() fieldSchema = z.never(); } else { @@ -906,7 +907,7 @@ export class InputValidator { const allowedFilterKinds = this.getEffectiveFilterKinds(contextModel, field); // Check if Json filter kind is allowed - if (allowedFilterKinds && !allowedFilterKinds.has('Json')) { + if (allowedFilterKinds && !allowedFilterKinds.includes('Json')) { // Return a never schema if Json filters are not allowed return z.never(); } @@ -930,7 +931,7 @@ export class InputValidator { private makeDateTimeFilterSchema( optional: boolean, withAggregations: boolean, - allowedFilterKinds: Set | undefined, + allowedFilterKinds: string[] | undefined, ): ZodType { return this.makeCommonPrimitiveFilterSchema( z.union([z.iso.datetime(), z.date()]), @@ -945,7 +946,7 @@ export class InputValidator { private makeBooleanFilterSchema( optional: boolean, withAggregations: boolean, - allowedFilterKinds: Set | undefined, + allowedFilterKinds: string[] | undefined, ): ZodType { const components = this.makeCommonPrimitiveFilterComponents( z.boolean(), @@ -963,7 +964,7 @@ export class InputValidator { private makeBytesFilterSchema( optional: boolean, withAggregations: boolean, - allowedFilterKinds: Set | undefined, + allowedFilterKinds: string[] | undefined, ): ZodType { const baseSchema = z.instanceof(Uint8Array); const components = this.makeCommonPrimitiveFilterComponents( @@ -984,7 +985,7 @@ export class InputValidator { makeThis: () => ZodType, supportedOperators: string[] | undefined = undefined, withAggregations: Array<'_count' | '_avg' | '_sum' | '_min' | '_max'> | undefined = undefined, - allowedFilterKinds: Set | undefined = undefined, + allowedFilterKinds: string[] | undefined = undefined, ) { const commonAggSchema = () => this.makeCommonPrimitiveFilterSchema(baseSchema, false, makeThis, undefined, allowedFilterKinds).optional(); @@ -1022,7 +1023,7 @@ export class InputValidator { optional: boolean, makeThis: () => ZodType, withAggregations: Array | undefined = undefined, - allowedFilterKinds: Set | undefined = undefined, + allowedFilterKinds: string[] | undefined = undefined, ): z.ZodType { const components = this.makeCommonPrimitiveFilterComponents( baseSchema, @@ -1040,7 +1041,7 @@ export class InputValidator { baseSchema: ZodType, optional: boolean, withAggregations: boolean, - allowedFilterKinds: Set | undefined, + allowedFilterKinds: string[] | undefined, ): ZodType { return this.makeCommonPrimitiveFilterSchema( baseSchema, @@ -1054,7 +1055,7 @@ export class InputValidator { private makeStringFilterSchema( optional: boolean, withAggregations: boolean, - allowedFilterKinds: Set | undefined, + allowedFilterKinds: string[] | undefined, ): ZodType { const baseComponents = this.makeCommonPrimitiveFilterComponents( z.string(), @@ -2087,7 +2088,7 @@ export class InputValidator { * Gets the effective set of allowed FilterKind values for a specific model and field. * Respects the precedence: field-level > model-level $all > global $all. */ - private getEffectiveFilterKinds(model: string | undefined, field: string): Set | undefined { + private getEffectiveFilterKinds(model: string | undefined, field: string): string[] | undefined { if (!model) { // no restrictions return undefined; @@ -2134,25 +2135,22 @@ export class InputValidator { /** * Computes the effective set of filter kinds based on inclusion and exclusion lists. */ - private computeFilterKinds( - included: readonly string[] | undefined, - excluded: readonly string[] | undefined, - ): Set | undefined { - let result: Set | undefined; + private computeFilterKinds(included: readonly string[] | undefined, excluded: readonly string[] | undefined) { + let result: string[] | undefined; if (included !== undefined) { // Start with the included set - result = new Set(included); + result = [...included]; } if (excluded !== undefined) { if (!result) { // If no inclusion list, start with all filter kinds - result = new Set(['Equality', 'Range', 'Like', 'Json', 'List', 'Relation']); + result = [...this.allFilterKinds]; } // Remove excluded kinds for (const kind of excluded) { - result.delete(kind); + result = result.filter((k) => k !== kind); } } @@ -2164,7 +2162,7 @@ export class InputValidator { */ private trimFilterOperators>( operators: T, - allowedKinds: Set | undefined, + allowedKinds: string[] | undefined, ): Partial { if (!allowedKinds) { return operators; // No restrictions @@ -2174,7 +2172,7 @@ export class InputValidator { Object.entries(operators).filter(([key, _]) => { return ( !(key in FILTER_PROPERTY_TO_KIND) || - allowedKinds.has(FILTER_PROPERTY_TO_KIND[key as keyof typeof FILTER_PROPERTY_TO_KIND]) + allowedKinds.includes(FILTER_PROPERTY_TO_KIND[key as keyof typeof FILTER_PROPERTY_TO_KIND]) ); }), ) as Partial; @@ -2184,19 +2182,19 @@ export class InputValidator { valueSchema: ZodType, optional: boolean, components: Record, - allowedFilterKinds: Set | undefined, + 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.has('Equality')) { + if (!allowedFilterKinds || allowedFilterKinds.includes('Equality')) { return this.nullableIf(valueSchema, optional); } // otherwise nothing is allowed return z.never(); } - if (!allowedFilterKinds || allowedFilterKinds.has('Equality')) { + if (!allowedFilterKinds || allowedFilterKinds.includes('Equality')) { // direct value or filter operators return z.union([this.nullableIf(valueSchema, optional), z.strictObject(components)]); } else { From 48ba279e427f7d0b6c3efdb0d1ec0d97c7c6afd0 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:28:26 -0800 Subject: [PATCH 13/16] fix: address PR comments --- .../orm/src/client/crud/validator/index.ts | 34 +++-- tests/e2e/orm/client-api/slicing.test.ts | 129 ++++++++++++++++++ 2 files changed, 155 insertions(+), 8 deletions(-) diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 390b5d96f..14d06a5da 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -2086,7 +2086,7 @@ export class InputValidator { /** * Gets the effective set of allowed FilterKind values for a specific model and field. - * Respects the precedence: field-level > model-level $all > global $all. + * Respects the precedence: model[field] > model.$all > $all[field] > $all.$all. */ private getEffectiveFilterKinds(model: string | undefined, field: string): string[] | undefined { if (!model) { @@ -2100,16 +2100,23 @@ export class InputValidator { 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 = (slicing.models as any)[model]; + const modelConfig = modelsRecord[model]; if (modelConfig?.fields) { const fieldConfig = modelConfig.fields[field]; if (fieldConfig) { return this.computeFilterKinds(fieldConfig.includedFilterKinds, fieldConfig.excludedFilterKinds); } - // Fallback to field-level $all - const allFieldsConfig = modelConfig.fields.$all; + // Fallback to field-level $all for the specific model + const allFieldsConfig = modelConfig.fields['$all']; if (allFieldsConfig) { return this.computeFilterKinds( allFieldsConfig.includedFilterKinds, @@ -2119,12 +2126,23 @@ export class InputValidator { } // Fallback to model-level $all - const allModelsConfig = (slicing.models as any).$all; + const allModelsConfig = modelsRecord['$all']; if (allModelsConfig?.fields) { - if (allModelsConfig.fields.$all) { + // 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( - allModelsConfig.fields.$all.includedFilterKinds, - allModelsConfig.fields.$all.excludedFilterKinds, + allModelsAllFieldsConfig.includedFilterKinds, + allModelsAllFieldsConfig.excludedFilterKinds, ); } } diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index 6fd44b6ee..f86307cbe 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -1760,6 +1760,135 @@ describe('Query slicing tests', () => { }), ).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', () => { From d5526d412eaa9f4997c2d6094d4e8ef0ada80a90 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:47:36 -0800 Subject: [PATCH 14/16] fix: options config model name casing --- packages/orm/src/client/client-impl.ts | 4 +- .../orm/src/client/crud/validator/index.ts | 4 +- packages/orm/src/client/options.ts | 2 +- packages/orm/src/client/type-utils.ts | 20 +++---- tests/e2e/orm/client-api/slicing.test.ts | 52 +++++++++---------- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 7b19a1d04..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, @@ -791,7 +791,7 @@ function createModelCrudHandler( // Filter operations based on slicing configuration const slicing = client.$options.slicing; if (slicing?.models) { - const modelSlicing = slicing.models[model as any]; + const modelSlicing = slicing.models[lowerCaseFirst(model) as any]; const allSlicing = slicing.models.$all; // Determine includedOperations: model-specific takes precedence over $all diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 14d06a5da..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'; @@ -2108,7 +2108,7 @@ export class InputValidator { const modelsRecord = slicing.models as Record; // Check field-level settings for the specific model - const modelConfig = modelsRecord[model]; + const modelConfig = modelsRecord[lowerCaseFirst(model)]; if (modelConfig?.fields) { const fieldConfig = modelConfig.fields[field]; if (fieldConfig) { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index a6a743ea3..80aa82261 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -64,7 +64,7 @@ export type SlicingOptions = { /** * Model-specific slicing options. */ - [Model in GetModels]?: ModelSlicingOptions; + [Model in GetModels as Uncapitalize]?: ModelSlicingOptions; } & { /** * Slicing options that apply to all models. Model-specific options will override these general diff --git a/packages/orm/src/client/type-utils.ts b/packages/orm/src/client/type-utils.ts index 356d5d759..3a4589f71 100644 --- a/packages/orm/src/client/type-utils.ts +++ b/packages/orm/src/client/type-utils.ts @@ -63,14 +63,14 @@ export type GetIncludedOperations< Model extends string, > = 'models' extends keyof Slicing ? Slicing extends { models: infer Config } - ? Model extends keyof Config - ? 'includedOperations' extends keyof Config[Model] + ? Uncapitalize extends keyof Config + ? 'includedOperations' extends keyof Config[Uncapitalize] ? // 'includedOperations' is specified for the model - Config[Model] extends { includedOperations: readonly [] } + Config[Uncapitalize] extends { includedOperations: readonly [] } ? // special marker for empty array (mute all) '_none_' : // use the specified includedOperations - Config[Model] extends { includedOperations: readonly (infer IO)[] } + Config[Uncapitalize] extends { includedOperations: readonly (infer IO)[] } ? IO : never : // fallback to $all if 'includedOperations' not specified for the model @@ -94,8 +94,8 @@ export type GetAllIncludedOperations> = 'mod type GetExcludedOperations, Model extends string> = 'models' extends keyof Slicing ? Slicing extends { models: infer Config } - ? Model extends keyof Config - ? Config[Model] extends { excludedOperations: readonly (infer EO)[] } + ? Uncapitalize extends keyof Config + ? Config[Uncapitalize] extends { excludedOperations: readonly (infer EO)[] } ? EO : // fallback to $all if 'excludedOperations' not specified for the model GetAllExcludedOperations @@ -208,8 +208,8 @@ type GetFieldIncludedFilterKinds< > = S extends { models?: infer Config; } - ? Model extends keyof Config - ? GetIncludedFilterKindsFromModelConfig + ? Uncapitalize extends keyof Config + ? GetIncludedFilterKindsFromModelConfig], Field> : // Model not in config, fallback to $all '$all' extends keyof Config ? GetIncludedFilterKindsFromModelConfig @@ -245,8 +245,8 @@ type GetFieldExcludedFilterKinds< > = S extends { models?: infer Config; } - ? Model extends keyof Config - ? GetExcludedFilterKindsFromModelConfig + ? Uncapitalize extends keyof Config + ? GetExcludedFilterKindsFromModelConfig], Field> : // Model not in config, fallback to $all '$all' extends keyof Config ? GetExcludedFilterKindsFromModelConfig diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index f86307cbe..90754eb44 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -554,7 +554,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { includedOperations: ['findMany', 'create'] as const, }, }, @@ -581,7 +581,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { excludedOperations: ['delete', 'deleteMany', 'update', 'updateMany'] as const, }, }, @@ -611,7 +611,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { includedOperations: ['findMany', 'findUnique', 'create', 'update'] as const, excludedOperations: ['update'] as const, }, @@ -640,7 +640,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { includedOperations: ['findMany', 'create'] as const, }, }, @@ -667,7 +667,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { includedOperations: AllReadOperations, }, }, @@ -697,7 +697,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { includedOperations: [] as const, }, }, @@ -749,7 +749,7 @@ describe('Query slicing tests', () => { $all: { includedOperations: ['findMany', 'count'] as const, }, - User: { + user: { includedOperations: ['findMany', 'create', 'update'] as const, }, }, @@ -984,7 +984,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality'] as const, @@ -1027,7 +1027,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { excludedFilterKinds: ['Like', 'Range'] as const, @@ -1070,7 +1070,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality', 'Range', 'Like'] as const, @@ -1112,7 +1112,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: [] as const, @@ -1155,7 +1155,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality', 'Range'] as const, @@ -1250,7 +1250,7 @@ describe('Query slicing tests', () => { }, }, }, - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality', 'Like'] as const, @@ -1294,7 +1294,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { excludedFilterKinds: ['Relation'] as const, @@ -1382,7 +1382,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { // Field-level: restrict 'name' to only Equality filters fields: { name: { @@ -1429,7 +1429,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { email: { excludedFilterKinds: ['Like'] as const, @@ -1470,7 +1470,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { name: { includedFilterKinds: ['Equality', 'Like', 'Range'] as const, @@ -1512,7 +1512,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { // $all: only Equality filters for all fields by default $all: { @@ -1558,7 +1558,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { name: { includedFilterKinds: [] as const, @@ -1600,7 +1600,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { name: { includedFilterKinds: ['Equality'] as const, @@ -1669,7 +1669,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { // $all: exclude Range filters for all fields $all: { @@ -1725,7 +1725,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { age: { includedFilterKinds: ['Range'] as const, @@ -1830,7 +1830,7 @@ describe('Query slicing tests', () => { }, }, }, - User: { + user: { fields: { // Level 2: all User fields default to Equality + Range $all: { @@ -1896,7 +1896,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality'] as const, @@ -1922,7 +1922,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Range'] as const, @@ -1950,7 +1950,7 @@ describe('Query slicing tests', () => { const options = { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Range'] as const, From 61397e23a37f41e79045b5a178415fc4169c52d4 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 18 Feb 2026 06:50:37 -0800 Subject: [PATCH 15/16] improve tests --- tests/e2e/orm/client-api/slicing.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/e2e/orm/client-api/slicing.test.ts b/tests/e2e/orm/client-api/slicing.test.ts index 90754eb44..5ba5fcb35 100644 --- a/tests/e2e/orm/client-api/slicing.test.ts +++ b/tests/e2e/orm/client-api/slicing.test.ts @@ -967,7 +967,7 @@ describe('Query slicing tests', () => { }); expect(equalityResult).toHaveLength(1); - // Range filters + // empty filters const rangeResult = await db.user.findMany({ where: {}, }); @@ -1012,7 +1012,7 @@ describe('Query slicing tests', () => { // @ts-expect-error - gte is not allowed (Range filters excluded) where: { age: { gte: 20 } }, }), - ).toBeRejectedByValidation(['"gte']); + ).toBeRejectedByValidation(['"gte"']); // Like filters should cause type error await expect( @@ -1370,10 +1370,10 @@ describe('Query slicing tests', () => { await expect( db.post.findMany({ - // @ts-expect-error - user relation filter excluded - where: { user: { is: { email: 'test' } } }, + // @ts-expect-error - author relation filter excluded + where: { author: { is: { email: 'test' } } }, }), - ).toBeRejectedByValidation(['"user"']); + ).toBeRejectedByValidation(['"where.author"']); }); }); From 5f9769980e63b9133d5f103b2b957103a04f2e7a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 18 Feb 2026 07:01:02 -0800 Subject: [PATCH 16/16] fix: tanstack test cases --- .../tanstack-query/test/react-sliced-client.test-d.ts | 6 +++--- .../tanstack-query/test/svelte-sliced-client.test-d.ts | 6 +++--- .../clients/tanstack-query/test/vue-sliced-client.test-d.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) 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 index 9d0405757..8b31551d7 100644 --- a/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/react-sliced-client.test-d.ts @@ -10,7 +10,7 @@ describe('React client sliced client test', () => { slicing: { includedModels: ['User', 'Post'], models: { - User: { + user: { includedOperations: ['findUnique', 'findMany', 'update'], excludedOperations: ['update'], }, @@ -33,7 +33,7 @@ describe('React client sliced client test', () => { { slicing: { models: { - User: { + user: { includedOperations: ['findUnique', 'findMany', 'update']; }; }; @@ -53,7 +53,7 @@ describe('React client sliced client test', () => { { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality']; 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 index 92fbae94a..5b83f6ff4 100644 --- a/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/svelte-sliced-client.test-d.ts @@ -10,7 +10,7 @@ describe('Svelte client sliced client test', () => { slicing: { includedModels: ['User', 'Post'], models: { - User: { + user: { includedOperations: ['findUnique', 'findMany', 'update'], excludedOperations: ['update'], }, @@ -33,7 +33,7 @@ describe('Svelte client sliced client test', () => { { slicing: { models: { - User: { + user: { includedOperations: ['findUnique', 'findMany', 'update']; }; }; @@ -53,7 +53,7 @@ describe('Svelte client sliced client test', () => { { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality']; 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 index 327f81628..5fdd11d6a 100644 --- a/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts +++ b/packages/clients/tanstack-query/test/vue-sliced-client.test-d.ts @@ -10,7 +10,7 @@ describe('Vue client sliced client test', () => { slicing: { includedModels: ['User', 'Post'], models: { - User: { + user: { includedOperations: ['findUnique', 'findMany', 'update'], excludedOperations: ['update'], }, @@ -33,7 +33,7 @@ describe('Vue client sliced client test', () => { { slicing: { models: { - User: { + user: { includedOperations: ['findUnique', 'findMany', 'update']; }; }; @@ -53,7 +53,7 @@ describe('Vue client sliced client test', () => { { slicing: { models: { - User: { + user: { fields: { $all: { includedFilterKinds: ['Equality'];