From 5d83b85acb7bdf95e450e53d50dbf23362943eb0 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 13:47:29 -0500 Subject: [PATCH 01/18] feat(orm): add result plugin extension point for computed query fields Implement a new `result` extension point that allows plugins to declare computed fields on query results with automatic type safety and select/omit awareness. Changes: - Add ExtResultFieldDef and ExtResultBase types to plugin.ts - Add ExtResult generic parameter to RuntimePlugin, AnyPlugin, definePlugin - Thread ExtResult through ClientContract and all CRUD return types - Add ExtractExtResult, ExtResultSelectOmitFields, SelectAwareExtResult type helpers - Implement recursive runtime computation in client-impl.ts with needs injection/stripping - Add ext result fields to Zod validation for select/omit schemas - Support nested relation ext result computation (both runtime and types) - Add 26 comprehensive tests covering single-model and nested relation scenarios Features: - Type-safe computed fields on query results - Select/omit-aware types (only include selected fields in result type) - Automatic field dependency (needs) injection and stripping - Multi-plugin composition support - Full support for nested relations (include/select with ext results) - Works with $transaction, $setAuth, $setOptions, etc. Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/client-impl.ts | 267 +++++- packages/orm/src/client/contract.ts | 96 +-- packages/orm/src/client/crud-types.ts | 121 ++- packages/orm/src/client/plugin.ts | 34 +- packages/orm/src/client/zod/factory.ts | 29 +- packages/plugins/policy/src/plugin.ts | 2 +- tests/e2e/orm/plugin-infra/ext-result.test.ts | 757 ++++++++++++++++++ .../e2e/orm/plugin-infra/ext-result/schema.ts | 80 ++ 8 files changed, 1304 insertions(+), 82 deletions(-) create mode 100644 tests/e2e/orm/plugin-infra/ext-result.test.ts create mode 100644 tests/e2e/orm/plugin-infra/ext-result/schema.ts diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 1046d0ca8..04a90e532 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -38,6 +38,7 @@ import * as BuiltinFunctions from './functions'; import { SchemaDbPusher } from './helpers/schema-db-pusher'; import type { ClientOptions, ProceduresOptions } from './options'; import type { AnyPlugin } from './plugin'; +import { getField } from './query-utils'; import { createZenStackPromise, type ZenStackPromise } from './promise'; import { ResultProcessor } from './result-processor'; @@ -547,6 +548,11 @@ function createModelCrudHandler( inputValidator: InputValidator, resultProcessor: ResultProcessor, ): ModelOperations { + // check if any plugin defines ext result fields + const plugins = client.$options.plugins ?? []; + const schema = client.$schema; + const hasAnyExtResult = hasExtResultDefs(plugins); + const createPromise = ( operation: CoreCrudOperations, nominalOperation: AllCrudOperations, @@ -557,17 +563,30 @@ function createModelCrudHandler( ) => { return createZenStackPromise(async (txClient?: ClientContract) => { let proceed = async (_args: unknown) => { + // prepare args for ext result: strip ext result field names from select/omit, + // inject needs fields into select (recursively handles nested relations) + const processedArgs = + postProcess && hasAnyExtResult + ? prepareArgsForExtResult(_args, model, schema, plugins) + : _args; + const _handler = txClient ? handler.withClient(txClient) : handler; - const r = await _handler.handle(operation, _args); + const r = await _handler.handle(operation, processedArgs); if (!r && throwIfNoResult) { throw createNotFoundError(model); } let result: unknown; if (r && postProcess) { - result = resultProcessor.processResult(r, model, args); + result = resultProcessor.processResult(r, model, processedArgs); } else { result = r ?? null; } + + // compute ext result fields (recursively handles nested relations) + if (result && postProcess && hasAnyExtResult) { + result = applyExtResult(result, model, _args, schema, plugins); + } + return result; }; @@ -823,3 +842,247 @@ function createModelCrudHandler( return operations as ModelOperations; } + +// #region Extended result field helpers + +type ExtResultDef = { needs: Record; compute: (data: any) => unknown }; + +/** + * Returns true if any plugin defines ext result fields for any model. + */ +function hasExtResultDefs(plugins: AnyPlugin[]): boolean { + return plugins.some((p) => p.result && Object.keys(p.result).length > 0); +} + +/** + * Collects extended result field definitions from all plugins for a given model. + */ +function collectExtResultDefs(model: string, plugins: AnyPlugin[]): Map { + const defs = new Map(); + for (const plugin of plugins) { + const resultConfig = plugin.result; + if (resultConfig) { + const modelConfig = resultConfig[model]; + if (modelConfig) { + for (const [fieldName, fieldDef] of Object.entries(modelConfig)) { + defs.set(fieldName, fieldDef as ExtResultDef); + } + } + } + } + return defs; +} + +/** + * Prepares query args for extended result fields (recursive): + * - Strips ext result field names from `select` and `omit` + * - Injects `needs` fields into `select` when ext result fields are explicitly selected + * - Recurses into `include` and `select` for nested relation fields + */ +function prepareArgsForExtResult( + args: unknown, + model: string, + schema: SchemaDef, + plugins: AnyPlugin[], +): unknown { + if (!args || typeof args !== 'object') { + return args; + } + + const extResultDefs = collectExtResultDefs(model, plugins); + const typedArgs = args as Record; + let result = typedArgs; + let changed = false; + + const select = typedArgs['select'] as Record | undefined; + const omit = typedArgs['omit'] as Record | undefined; + const include = typedArgs['include'] as Record | undefined; + + if (select && extResultDefs.size > 0) { + const newSelect = { ...select }; + for (const [fieldName, fieldDef] of extResultDefs) { + if (newSelect[fieldName]) { + delete newSelect[fieldName]; + // inject needs fields + for (const needField of Object.keys(fieldDef.needs)) { + if (!newSelect[needField]) { + newSelect[needField] = true; + } + } + } + } + result = { ...result, select: newSelect }; + changed = true; + } + + if (omit && extResultDefs.size > 0) { + const newOmit = { ...omit }; + for (const fieldName of extResultDefs.keys()) { + if (newOmit[fieldName]) { + delete newOmit[fieldName]; + } + } + result = { ...result, omit: newOmit }; + changed = true; + } + + // Recurse into nested relations in `include` + if (include) { + const newInclude = { ...include }; + let includeChanged = false; + for (const [field, value] of Object.entries(newInclude)) { + if (value && typeof value === 'object') { + const fieldDef = getField(schema, model, field); + if (fieldDef?.relation) { + const targetModel = fieldDef.type; + const processed = prepareArgsForExtResult(value, targetModel, schema, plugins); + if (processed !== value) { + newInclude[field] = processed; + includeChanged = true; + } + } + } + } + if (includeChanged) { + result = changed ? { ...result, include: newInclude } : { ...typedArgs, include: newInclude }; + changed = true; + } + } + + // Recurse into nested relations in `select` (relation fields can have nested args) + if (select) { + const currentSelect = (changed ? (result as Record)['select'] : select) as + | Record + | undefined; + if (currentSelect) { + const newSelect = { ...currentSelect }; + let selectChanged = false; + for (const [field, value] of Object.entries(newSelect)) { + if (value && typeof value === 'object') { + const fieldDef = getField(schema, model, field); + if (fieldDef?.relation) { + const targetModel = fieldDef.type; + const processed = prepareArgsForExtResult(value, targetModel, schema, plugins); + if (processed !== value) { + newSelect[field] = processed; + selectChanged = true; + } + } + } + } + if (selectChanged) { + result = { ...result, select: newSelect }; + changed = true; + } + } + } + + return changed ? result : args; +} + +/** + * Applies extended result field computation to query results (recursive). + * Processes the current model's ext result fields, then recurses into nested relation data. + */ +function applyExtResult( + result: unknown, + model: string, + originalArgs: unknown, + schema: SchemaDef, + plugins: AnyPlugin[], +): unknown { + if (Array.isArray(result)) { + for (let i = 0; i < result.length; i++) { + result[i] = applyExtResultToRow(result[i], model, originalArgs, schema, plugins); + } + return result; + } else { + return applyExtResultToRow(result, model, originalArgs, schema, plugins); + } +} + +function applyExtResultToRow( + row: unknown, + model: string, + originalArgs: unknown, + schema: SchemaDef, + plugins: AnyPlugin[], +): unknown { + if (!row || typeof row !== 'object') { + return row; + } + + const data = row as Record; + const extResultDefs = collectExtResultDefs(model, plugins); + const typedArgs = (originalArgs && typeof originalArgs === 'object' ? originalArgs : {}) as Record; + const select = typedArgs['select'] as Record | undefined; + const omit = typedArgs['omit'] as Record | undefined; + const include = typedArgs['include'] as Record | undefined; + + // Determine which ext result fields were selected/omitted at this level + const selectedExtResultFields = select ? new Set() : undefined; + const omittedExtResultFields = omit ? new Set() : undefined; + const injectedNeedsFields = new Set(); + + if (select && extResultDefs.size > 0) { + for (const [fieldName, fieldDef] of extResultDefs) { + if (select[fieldName]) { + selectedExtResultFields!.add(fieldName); + // Track injected needs: fields that were NOT in the original select + for (const needField of Object.keys(fieldDef.needs)) { + if (!select[needField]) { + injectedNeedsFields.add(needField); + } + } + } + } + } + + if (omit && extResultDefs.size > 0) { + for (const fieldName of extResultDefs.keys()) { + if (omit[fieldName]) { + omittedExtResultFields!.add(fieldName); + } + } + } + + // Compute ext result fields for the current model + for (const [fieldName, fieldDef] of extResultDefs) { + if (omittedExtResultFields?.has(fieldName)) { + continue; + } + if (selectedExtResultFields !== undefined && !selectedExtResultFields.has(fieldName)) { + continue; + } + const needsSatisfied = Object.keys(fieldDef.needs).every((needField) => needField in data); + if (needsSatisfied) { + data[fieldName] = fieldDef.compute(data); + } + } + + // Strip injected needs fields that weren't originally requested + for (const field of injectedNeedsFields) { + delete data[field]; + } + + // Recurse into nested relation data + const relationSource = include ?? select; + if (relationSource) { + for (const [field, value] of Object.entries(relationSource)) { + if (data[field] == null) { + continue; + } + const fieldDef = getField(schema, model, field); + if (!fieldDef?.relation) { + continue; + } + const targetModel = fieldDef.type; + const nestedArgs = value && typeof value === 'object' ? value : undefined; + data[field] = applyExtResult(data[field], targetModel, nestedArgs, schema, plugins); + } + } + + return data; +} + +// #endregion diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index c6b772aa4..c2587eae4 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -40,7 +40,7 @@ import type { UpsertArgs, } from './crud-types'; import type { ClientOptions, QueryOptions } from './options'; -import type { ExtClientMembersBase, ExtQueryArgsBase, RuntimePlugin } from './plugin'; +import type { ExtClientMembersBase, ExtQueryArgsBase, ExtResultBase, RuntimePlugin } from './plugin'; import type { ZenStackPromise } from './promise'; import type { ToKysely } from './query-builder'; import type { GetSlicedModels, GetSlicedOperations, GetSlicedProcedures } from './type-utils'; @@ -67,6 +67,7 @@ export type ClientContract< Options extends ClientOptions = ClientOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, ExtClientMembers extends ExtClientMembersBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The schema definition. @@ -124,7 +125,7 @@ export type ClientContract< /** * Sets the current user identity. */ - $setAuth(auth: AuthType | undefined): ClientContract; + $setAuth(auth: AuthType | undefined): ClientContract; /** * Returns a new client with new options applied. @@ -135,7 +136,7 @@ export type ClientContract< */ $setOptions>( options: NewOptions, - ): ClientContract; + ): ClientContract; /** * Returns a new client enabling/disabling input validations expressed with attributes like @@ -143,7 +144,7 @@ export type ClientContract< * * @deprecated Use {@link $setOptions} instead. */ - $setInputValidation(enable: boolean): ClientContract; + $setInputValidation(enable: boolean): ClientContract; /** * The Kysely query builder instance. @@ -159,7 +160,7 @@ export type ClientContract< * Starts an interactive transaction. */ $transaction( - callback: (tx: TransactionClientContract) => Promise, + callback: (tx: TransactionClientContract) => Promise, options?: { isolationLevel?: TransactionIsolationLevel }, ): Promise; @@ -178,14 +179,15 @@ export type ClientContract< PluginSchema extends SchemaDef = Schema, PluginExtQueryArgs extends ExtQueryArgsBase = {}, PluginExtClientMembers extends ExtClientMembersBase = {}, + PluginExtResult extends ExtResultBase = {}, >( - plugin: RuntimePlugin, - ): ClientContract; + plugin: RuntimePlugin, + ): ClientContract; /** * Returns a new client with the specified plugin removed. */ - $unuse(pluginId: string): ClientContract; + $unuse(pluginId: string): ClientContract; /** * Returns a new client with all plugins removed. @@ -213,7 +215,7 @@ export type ClientContract< */ $pushSchema(): Promise; } & { - [Key in GetSlicedModels as Uncapitalize]: ModelOperations; + [Key in GetSlicedModels as Uncapitalize]: ModelOperations; } & ProcedureOperations & ExtClientMembers; @@ -225,7 +227,8 @@ export type TransactionClientContract< Options extends ClientOptions, ExtQueryArgs extends ExtQueryArgsBase, ExtClientMembers extends ExtClientMembersBase, -> = Omit, TransactionUnsupportedMethods>; + ExtResult extends ExtResultBase = {}, +> = Omit, TransactionUnsupportedMethods>; export type ProcedureOperations< Schema extends SchemaDef, @@ -293,7 +296,8 @@ export type AllModelOperations< Model extends GetModels, Options extends QueryOptions, ExtQueryArgs extends ExtQueryArgsBase, -> = CommonModelOperations & + ExtResult extends ExtResultBase = {}, +> = CommonModelOperations & // provider-specific operations (Schema['provider']['type'] extends 'mysql' ? {} @@ -316,9 +320,9 @@ export type AllModelOperations< * }); * ``` */ - createManyAndReturn>( - args?: SelectSubset>, - ): ZenStackPromise[]>; + createManyAndReturn>( + args?: SelectSubset>, + ): ZenStackPromise[]>; /** * Updates multiple entities and returns them. @@ -342,9 +346,9 @@ export type AllModelOperations< * }); * ``` */ - updateManyAndReturn>( - args: Subset>, - ): ZenStackPromise[]>; + updateManyAndReturn>( + args: Subset>, + ): ZenStackPromise[]>; }); type CommonModelOperations< @@ -352,6 +356,7 @@ type CommonModelOperations< Model extends GetModels, Options extends QueryOptions, ExtQueryArgs extends ExtQueryArgsBase, + ExtResult extends ExtResultBase = {}, > = { /** * Returns a list of entities. @@ -434,9 +439,9 @@ type CommonModelOperations< * }); // result: `{ _count: { posts: number } }` * ``` */ - findMany>( - args?: SelectSubset>, - ): ZenStackPromise[]>; + findMany>( + args?: SelectSubset>, + ): ZenStackPromise[]>; /** * Returns a uniquely identified entity. @@ -444,9 +449,9 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findUnique>( - args: SelectSubset>, - ): ZenStackPromise | null>; + findUnique>( + args: SelectSubset>, + ): ZenStackPromise | null>; /** * Returns a uniquely identified entity or throws `NotFoundError` if not found. @@ -454,9 +459,9 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findUniqueOrThrow>( - args: SelectSubset>, - ): ZenStackPromise>; + findUniqueOrThrow>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Returns the first entity. @@ -464,9 +469,9 @@ type CommonModelOperations< * @returns a single entity or null if not found * @see {@link findMany} */ - findFirst>( - args?: SelectSubset>, - ): ZenStackPromise | null>; + findFirst>( + args?: SelectSubset>, + ): ZenStackPromise | null>; /** * Returns the first entity or throws `NotFoundError` if not found. @@ -474,9 +479,9 @@ type CommonModelOperations< * @returns a single entity * @see {@link findMany} */ - findFirstOrThrow>( - args?: SelectSubset>, - ): ZenStackPromise>; + findFirstOrThrow>( + args?: SelectSubset>, + ): ZenStackPromise>; /** * Creates a new entity. @@ -530,9 +535,9 @@ type CommonModelOperations< * }); * ``` */ - create>( - args: SelectSubset>, - ): ZenStackPromise>; + create>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Creates multiple entities. Only scalar fields are allowed. @@ -680,9 +685,9 @@ type CommonModelOperations< * }); * ``` */ - update>( - args: SelectSubset>, - ): ZenStackPromise>; + update>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Updates multiple entities. @@ -728,9 +733,9 @@ type CommonModelOperations< * }); * ``` */ - upsert>( - args: SelectSubset>, - ): ZenStackPromise>; + upsert>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Deletes a uniquely identifiable entity. @@ -751,9 +756,9 @@ type CommonModelOperations< * }); // result: `{ id: string; email: string }` * ``` */ - delete>( - args: SelectSubset>, - ): ZenStackPromise>; + delete>( + args: SelectSubset>, + ): ZenStackPromise>; /** * Deletes multiple entities. @@ -887,7 +892,8 @@ export type ModelOperations< Model extends GetModels, Options extends ClientOptions = ClientOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, -> = SliceOperations, Schema, Model, Options>; + ExtResult extends ExtResultBase = {}, +> = SliceOperations, Schema, Model, Options>; //#endregion diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 6c335cf93..c13cd99b4 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -58,7 +58,7 @@ import type { CoreUpdateOperations, } from './crud/operations/base'; import type { FilterKind, QueryOptions } from './options'; -import type { ExtQueryArgsBase } from './plugin'; +import type { ExtQueryArgsBase, ExtResultBase } from './plugin'; import type { ToKyselySchema } from './query-builder'; import type { GetSlicedFilterKindsForField, GetSlicedModels } from './type-utils'; @@ -152,21 +152,25 @@ type ModelSelectResult< Select, Omit, Options extends QueryOptions, + ExtResult extends ExtResultBase = {}, > = { [Key in keyof Select as Select[Key] extends false | undefined ? // not selected never - : Key extends '_count' - ? // select "_count" - Select[Key] extends SelectCount - ? Key - : never - : Key extends keyof Omit - ? Omit[Key] extends true - ? // omit - never - : Key - : Key]: Key extends '_count' + : Key extends keyof ExtractExtResult + ? // ext result field — handled by SelectAwareExtResult intersection in ModelResult + never + : Key extends '_count' + ? // select "_count" + Select[Key] extends SelectCount + ? Key + : never + : Key extends keyof Omit + ? Omit[Key] extends true + ? // omit + never + : Key + : Key]: Key extends '_count' ? // select "_count" result SelectCountResult : Key extends NonRelationFields @@ -180,7 +184,8 @@ type ModelSelectResult< Select[Key], Options, ModelFieldIsOptional, - FieldIsArray + FieldIsArray, + ExtResult > : never; }; @@ -201,12 +206,13 @@ export type ModelResult< Options extends QueryOptions = QueryOptions, Optional = false, Array = false, + ExtResult extends ExtResultBase = {}, > = WrapType< - Args extends { + (Args extends { select: infer S extends object; omit?: infer O extends object; } & Record - ? ModelSelectResult + ? ModelSelectResult : Args extends { include: infer I extends object; omit?: infer O extends object; @@ -222,7 +228,8 @@ export type ModelResult< I[Key], Options, ModelFieldIsOptional, - FieldIsArray + FieldIsArray, + ExtResult >; } & ('_count' extends keyof I ? I['_count'] extends false | undefined @@ -231,7 +238,8 @@ export type ModelResult< : {}) : Args extends { omit: infer O } & Record ? DefaultModelResult - : DefaultModelResult, + : DefaultModelResult) & + SelectAwareExtResult, Optional, Array >; @@ -243,14 +251,16 @@ export type SimplifiedResult< Options extends QueryOptions = QueryOptions, Optional = false, Array = false, -> = Simplify>; + ExtResult extends ExtResultBase = {}, +> = Simplify>; export type SimplifiedPlainResult< Schema extends SchemaDef, Model extends GetModels, Args = {}, Options extends QueryOptions = QueryOptions, -> = Simplify>; + ExtResult extends ExtResultBase = {}, +> = Simplify>; export type TypeDefResult< Schema extends SchemaDef, @@ -946,16 +956,17 @@ export type SelectIncludeOmit< AllowCount extends boolean, Options extends QueryOptions = QueryOptions, AllowRelation extends boolean = true, + ExtResult extends ExtResultBase = {}, > = { /** * Explicitly select fields and relations to be returned by the query. */ - select?: SelectInput | null; + select?: (SelectInput & ExtResultSelectOmitFields) | null; /** * Explicitly omit fields from the query result. */ - omit?: OmitInput | null; + omit?: (OmitInput & ExtResultSelectOmitFields) | null; } & (AllowRelation extends true ? { /** @@ -1203,6 +1214,7 @@ export type FindArgs< Options extends QueryOptions, Collection extends boolean, AllowFilter extends boolean = true, + ExtResult extends ExtResultBase = {}, > = (Collection extends true ? SortAndTakeArgs & (ProviderSupportsDistinct extends true @@ -1215,21 +1227,23 @@ export type FindArgs< : {}) : {}) & (AllowFilter extends true ? FilterArgs : {}) & - SelectIncludeOmit; + SelectIncludeOmit; export type FindManyArgs< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, -> = FindArgs & ExtractExtQueryArgs; + ExtResult extends ExtResultBase = {}, +> = FindArgs & ExtractExtQueryArgs; export type FindFirstArgs< Schema extends SchemaDef, Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, -> = FindArgs & ExtractExtQueryArgs; + ExtResult extends ExtResultBase = {}, +> = FindArgs & ExtractExtQueryArgs; export type ExistsArgs< Schema extends SchemaDef, @@ -1243,9 +1257,10 @@ export type FindUniqueArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = { where: WhereUniqueInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; //#endregion @@ -1257,9 +1272,10 @@ export type CreateArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = { data: CreateInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; export type CreateManyArgs< @@ -1274,8 +1290,9 @@ export type CreateManyAndReturnArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = CreateManyInput & - SelectIncludeOmit & + SelectIncludeOmit & ExtractExtQueryArgs; type OptionalWrap, T extends object> = Optional< @@ -1484,6 +1501,7 @@ export type UpdateArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The data to update the record with. @@ -1494,7 +1512,7 @@ export type UpdateArgs< * The unique filter to find the record to update. */ where: WhereUniqueInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; export type UpdateManyArgs< @@ -1509,8 +1527,9 @@ export type UpdateManyAndReturnArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = UpdateManyPayload & - SelectIncludeOmit & + SelectIncludeOmit & ExtractExtQueryArgs; type UpdateManyPayload< @@ -1540,6 +1559,7 @@ export type UpsertArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The data to create the record if it doesn't exist. @@ -1555,7 +1575,7 @@ export type UpsertArgs< * The unique filter to find the record to update. */ where: WhereUniqueInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; type UpdateScalarInput< @@ -1777,12 +1797,13 @@ export type DeleteArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The unique filter to find the record to delete. */ where: WhereUniqueInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; export type DeleteManyArgs< @@ -2409,4 +2430,42 @@ type ExtractExtQueryArgs = ( : {}) & ('$all' extends keyof ExtQueryArgs ? ExtQueryArgs['$all'] : {}); +/** + * Extracts extended result field types for a specific model from ExtResult. + * Maps `{ needs, compute }` definitions to `{ fieldName: ReturnType }`. + * When ExtResult is `{}`, this resolves to `{}` (no-op for intersection). + */ +export type ExtractExtResult = Model extends keyof ExtResult + ? { [K in keyof ExtResult[Model]]: ExtResult[Model][K] extends { compute: (...args: any[]) => infer R } ? R : never } + : {}; + +/** + * Extracts extended result field names as optional boolean keys for use in select/omit inputs. + * When ExtResult is `{}`, this resolves to `{}` (no-op for intersection). + */ +export type ExtResultSelectOmitFields = keyof ExtResult extends never + ? {} + : Model extends keyof ExtResult + ? { [K in keyof ExtResult[Model]]?: boolean } + : {}; + +/** + * Select/omit-aware version of ExtractExtResult. + * - If T has `select`, only includes ext result fields that are explicitly selected. + * - If T has `omit`, excludes ext result fields that are explicitly omitted. + * - Otherwise, includes all ext result fields. + */ +export type SelectAwareExtResult = + keyof ExtResult extends never + ? {} + : T extends { select: infer S } + ? S extends null | undefined + ? ExtractExtResult + : Pick, Extract>> + : T extends { omit: infer O } + ? O extends null | undefined + ? ExtractExtResult + : Omit, Extract>> + : ExtractExtResult; + // #endregion diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index e531242e3..4197d06fc 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -20,6 +20,26 @@ export type ExtQueryArgsBase = { */ export type ExtClientMembersBase = Record; +/** + * Definition for a single extended result field. + */ +export type ExtResultFieldDef = { + /** + * Fields required to compute this result field. + */ + needs: Record; + /** + * Computes the result field value from the query result row. + */ + compute: (data: any) => unknown; +}; + +/** + * Base shape of plugin-extended result fields. + * Keyed by model name, each value maps field names to their definitions. + */ +export type ExtResultBase = Record>; + /** * ZenStack runtime plugin. */ @@ -27,6 +47,7 @@ export interface RuntimePlugin< Schema extends SchemaDef, ExtQueryArgs extends ExtQueryArgsBase, ExtClientMembers extends Record, + ExtResult extends ExtResultBase = {}, > { /** * Plugin ID. @@ -81,9 +102,15 @@ export interface RuntimePlugin< * Extended client members (methods and properties). */ client?: ExtClientMembers; + + /** + * Extended result fields on query results. + * Keyed by model name, each value defines computed fields with `needs` and `compute`. + */ + result?: ExtResult; } -export type AnyPlugin = RuntimePlugin; +export type AnyPlugin = RuntimePlugin; /** * Defines a ZenStack runtime plugin. @@ -92,7 +119,10 @@ export function definePlugin< Schema extends SchemaDef, const ExtQueryArgs extends ExtQueryArgsBase = {}, const ExtClientMembers extends Record = {}, ->(plugin: RuntimePlugin): RuntimePlugin { + const ExtResult extends ExtResultBase = {}, +>( + plugin: RuntimePlugin, +): RuntimePlugin { return plugin; } diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 0f0eb61e8..ff26e25ca 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -121,7 +121,7 @@ export class ZodSchemaFactory< } } - private get plugins(): RuntimePlugin[] { + private get plugins(): RuntimePlugin[] { return this.options.plugins ?? []; } @@ -901,6 +901,19 @@ export class ZodSchemaFactory< } } + // add ext result fields from plugins + for (const plugin of this.plugins) { + const resultConfig = plugin.result; + if (resultConfig) { + const modelConfig = resultConfig[model]; + if (modelConfig) { + for (const field of Object.keys(modelConfig)) { + fields[field] = z.boolean().optional(); + } + } + } + } + return z.strictObject(fields); } @@ -1006,6 +1019,20 @@ export class ZodSchemaFactory< } } } + + // add ext result fields from plugins + for (const plugin of this.plugins) { + const resultConfig = plugin.result; + if (resultConfig) { + const modelConfig = resultConfig[model]; + if (modelConfig) { + for (const field of Object.keys(modelConfig)) { + fields[field] = z.boolean().optional(); + } + } + } + } + return z.strictObject(fields); } diff --git a/packages/plugins/policy/src/plugin.ts b/packages/plugins/policy/src/plugin.ts index 3f67309f3..d5cf192c4 100644 --- a/packages/plugins/policy/src/plugin.ts +++ b/packages/plugins/policy/src/plugin.ts @@ -3,7 +3,7 @@ import type { SchemaDef } from '@zenstackhq/orm/schema'; import { check } from './functions'; import { PolicyHandler } from './policy-handler'; -export class PolicyPlugin implements RuntimePlugin { +export class PolicyPlugin implements RuntimePlugin { get id() { return 'policy' as const; } diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts new file mode 100644 index 000000000..574ed5782 --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -0,0 +1,757 @@ +import { definePlugin, type ClientContract } from '@zenstackhq/orm'; +import { createTestClient } from '@zenstackhq/testtools'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { schema } from './ext-result/schema'; + +describe('Plugin extended result fields', () => { + let db: ClientContract; + + beforeEach(async () => { + db = await createTestClient(schema); + await db.user.deleteMany(); + }); + + afterEach(async () => { + await db?.$disconnect(); + }); + + it('should compute virtual fields on findMany results', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + greeting: { + needs: { name: true }, + compute: (user) => `Hello, ${user.name}!`, + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.user.create({ data: { name: 'Bob' } }); + + const users = await extDb.user.findMany(); + expect(users).toHaveLength(2); + expect(users[0]!.greeting).toBe('Hello, Alice!'); + expect(users[1]!.greeting).toBe('Hello, Bob!'); + }); + + it('should compute virtual fields on findUnique', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + greeting: { + needs: { name: true }, + compute: (user) => `Hello, ${user.name}!`, + }, + }, + }, + }), + ); + + const created = await extDb.user.create({ data: { name: 'Alice' } }); + const user = await extDb.user.findUnique({ where: { id: created.id } }); + expect(user?.greeting).toBe('Hello, Alice!'); + }); + + it('should compute virtual fields on findFirst', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + const user = await extDb.user.findFirst(); + expect(user?.upperName).toBe('ALICE'); + }); + + it('should compute virtual fields on findUniqueOrThrow and findFirstOrThrow', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + const created = await extDb.user.create({ data: { name: 'Alice' } }); + const user1 = await extDb.user.findUniqueOrThrow({ where: { id: created.id } }); + expect(user1.upperName).toBe('ALICE'); + + const user2 = await extDb.user.findFirstOrThrow(); + expect(user2.upperName).toBe('ALICE'); + }); + + it('should compute virtual fields on create, update, upsert, delete', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + // create + const created = await extDb.user.create({ data: { name: 'Alice' } }); + expect(created.upperName).toBe('ALICE'); + + // update + const updated = await extDb.user.update({ + where: { id: created.id }, + data: { name: 'Bob' }, + }); + expect(updated.upperName).toBe('BOB'); + + // upsert + const upserted = await extDb.user.upsert({ + where: { id: created.id }, + create: { name: 'Charlie' }, + update: { name: 'Charlie' }, + }); + expect(upserted.upperName).toBe('CHARLIE'); + + // delete + const deleted = await extDb.user.delete({ where: { id: created.id } }); + expect(deleted.upperName).toBe('CHARLIE'); + }); + + it('should compute virtual fields on createManyAndReturn', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + const users = await extDb.user.createManyAndReturn({ + data: [{ name: 'Alice' }, { name: 'Bob' }], + }); + expect(users).toHaveLength(2); + expect(users[0]!.upperName).toBe('ALICE'); + expect(users[1]!.upperName).toBe('BOB'); + }); + + it('should NOT compute virtual fields on count, aggregate, exists, createMany, updateMany, deleteMany', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + const count = await extDb.user.count(); + expect(count).toBe(1); + expect((count as any).upperName).toBeUndefined(); + + const exists = await extDb.user.exists({ where: { id: 1 } }); + expect(typeof exists).toBe('boolean'); + + const createManyResult = await extDb.user.createMany({ data: [{ name: 'Bob' }] }); + expect(createManyResult.count).toBe(1); + expect((createManyResult as any).upperName).toBeUndefined(); + + const updateManyResult = await extDb.user.updateMany({ + where: { name: 'Bob' }, + data: { name: 'Charlie' }, + }); + expect(updateManyResult.count).toBe(1); + expect((updateManyResult as any).upperName).toBeUndefined(); + + const deleteManyResult = await extDb.user.deleteMany({ where: { name: 'Charlie' } }); + expect(deleteManyResult.count).toBe(1); + expect((deleteManyResult as any).upperName).toBeUndefined(); + }); + + it('should compute only selected virtual fields when using select', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + idDoubled: { + needs: { id: true }, + compute: (user) => user.id * 2, + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // Select only upperName — needs (name) should be injected and stripped + const users = await extDb.user.findMany({ select: { id: true, upperName: true } }); + expect(users[0]!.upperName).toBe('ALICE'); + expect((users[0] as any).idDoubled).toBeUndefined(); + // name was injected as a need but should be stripped from the result + expect((users[0] as any).name).toBeUndefined(); + // id was explicitly selected + expect(users[0]!.id).toBeDefined(); + }); + + it('should not compute virtual fields when not selected explicitly', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // Select only id — virtual field not selected, should not appear + const users = await extDb.user.findMany({ select: { id: true } }); + expect(users[0]!.id).toBeDefined(); + expect((users[0] as any).upperName).toBeUndefined(); + }); + + it('should exclude virtual fields when using omit', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + const users = await extDb.user.findMany({ omit: { upperName: true } }); + expect(users[0]!.name).toBe('Alice'); + expect((users[0] as any).upperName).toBeUndefined(); + }); + + it('should compose virtual fields from multiple plugins', async () => { + const plugin1 = definePlugin({ + id: 'plugin1', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }); + + const plugin2 = definePlugin({ + id: 'plugin2', + result: { + User: { + idDoubled: { + needs: { id: true }, + compute: (user) => user.id * 2, + }, + }, + }, + }); + + const extDb = db.$use(plugin1).$use(plugin2); + await extDb.user.create({ data: { name: 'Alice' } }); + + const users = await extDb.user.findMany(); + expect(users[0]!.upperName).toBe('ALICE'); + expect(users[0]!.idDoubled).toBe(2); + }); + + it('should remove virtual fields when plugin is removed via $unuse', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + const users1 = await extDb.user.findMany(); + expect(users1[0]!.upperName).toBe('ALICE'); + + const plainDb = extDb.$unuse('greeting'); + const users2 = await plainDb.user.findMany(); + expect((users2[0]! as any).upperName).toBeUndefined(); + }); + + it('should remove all virtual fields when $unuseAll is called', async () => { + const extDb = db + .$use( + definePlugin({ + id: 'p1', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ) + .$use( + definePlugin({ + id: 'p2', + result: { + User: { + idDoubled: { + needs: { id: true }, + compute: (user) => user.id * 2, + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + const users1 = await extDb.user.findMany(); + expect(users1[0]!.upperName).toBe('ALICE'); + expect(users1[0]!.idDoubled).toBe(2); + + const cleanDb = extDb.$unuseAll(); + const users2 = await cleanDb.user.findMany(); + expect((users2[0]! as any).upperName).toBeUndefined(); + expect((users2[0]! as any).idDoubled).toBeUndefined(); + }); + + it('should compute virtual fields inside $transaction', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.$transaction(async (tx) => { + const created = await tx.user.create({ data: { name: 'Alice' } }); + expect(created.upperName).toBe('ALICE'); + + const found = await tx.user.findFirst(); + expect(found?.upperName).toBe('ALICE'); + }); + }); + + it('should accept virtual fields in select/omit via Zod validation', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // select with virtual field should not throw + await expect(extDb.user.findMany({ select: { id: true, upperName: true } })).resolves.toBeDefined(); + + // omit with virtual field should not throw + await expect(extDb.user.findMany({ omit: { upperName: true } })).resolves.toBeDefined(); + }); + + it('should handle virtual fields that depend on multiple needs', async () => { + const extDb = db.$use( + definePlugin({ + id: 'full-info', + result: { + User: { + fullInfo: { + needs: { id: true, name: true }, + compute: (user) => `${user.id}:${user.name}`, + }, + }, + }, + }), + ); + + const created = await extDb.user.create({ data: { name: 'Alice' } }); + const users = await extDb.user.findMany(); + expect(users[0]!.fullInfo).toBe(`${created.id}:Alice`); + }); + + it('should inject needs and strip them when using select with virtual field', async () => { + const extDb = db.$use( + definePlugin({ + id: 'full-info', + result: { + User: { + fullInfo: { + needs: { id: true, name: true }, + compute: (user) => `${user.id}:${user.name}`, + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // Select only the virtual field — needs (id, name) are injected but stripped + const users = await extDb.user.findMany({ select: { fullInfo: true } }); + expect(users).toHaveLength(1); + expect(users[0]!.fullInfo).toMatch(/^\d+:Alice$/); + // id and name were injected needs — should be stripped + expect((users[0] as any).id).toBeUndefined(); + expect((users[0] as any).name).toBeUndefined(); + }); + + it('should not strip needs fields that were explicitly selected', async () => { + const extDb = db.$use( + definePlugin({ + id: 'full-info', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // Explicitly select both the virtual field and its need + const users = await extDb.user.findMany({ select: { name: true, upperName: true } }); + expect(users).toHaveLength(1); + expect(users[0]!.upperName).toBe('ALICE'); + // name was explicitly selected — should NOT be stripped + expect(users[0]!.name).toBe('Alice'); + }); + + it('should have correct types when select includes ext result fields', async () => { + const extDb = db.$use( + definePlugin({ + id: 'typing-test', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + idDoubled: { + needs: { id: true }, + compute: (user) => user.id * 2, + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // When selecting only upperName, the result type should include upperName but not idDoubled + const selected = await extDb.user.findMany({ select: { upperName: true } }); + const first = selected[0]!; + // upperName should be accessible + const _upper: string = first.upperName; + expect(_upper).toBe('ALICE'); + // idDoubled should NOT be in the type + // @ts-expect-error - idDoubled was not selected + first.idDoubled; + // id should NOT be in the type (not selected) + // @ts-expect-error - id was not selected + first.id; + + // When omitting upperName, idDoubled should still be present + const omitted = await extDb.user.findMany({ omit: { upperName: true } }); + const omittedFirst = omitted[0]!; + // idDoubled should be accessible + const _doubled: number = omittedFirst.idDoubled; + expect(_doubled).toBe(2); + // upperName should NOT be in the type + // @ts-expect-error - upperName was omitted + omittedFirst.upperName; + + // When no select/omit, both should be present + const all = await extDb.user.findMany(); + const allFirst = all[0]!; + const _u: string = allFirst.upperName; + const _d: number = allFirst.idDoubled; + expect(_u).toBe('ALICE'); + expect(_d).toBe(2); + }); + + it('should compute ext result fields on included relations', async () => { + const extDb = db.$use( + definePlugin({ + id: 'post-ext', + result: { + Post: { + upperTitle: { + needs: { title: true }, + compute: (post) => post.title.toUpperCase(), + }, + }, + }, + }), + ); + + const user = await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello World', authorId: user.id } }); + await extDb.post.create({ data: { title: 'Second Post', authorId: user.id } }); + + const users = await extDb.user.findMany({ include: { posts: true } }); + expect(users).toHaveLength(1); + expect(users[0]!.posts).toHaveLength(2); + expect(users[0]!.posts[0]!.upperTitle).toBe('HELLO WORLD'); + expect(users[0]!.posts[1]!.upperTitle).toBe('SECOND POST'); + }); + + it('should compute ext result fields on both parent and nested relations', async () => { + const extDb = db.$use( + definePlugin({ + id: 'both-ext', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + Post: { + upperTitle: { + needs: { title: true }, + compute: (post) => post.title.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + + const users = await extDb.user.findMany({ include: { posts: true } }); + expect(users[0]!.upperName).toBe('ALICE'); + expect(users[0]!.posts[0]!.upperTitle).toBe('HELLO'); + }); + + it('should handle ext result fields on nested relations with select', async () => { + const extDb = db.$use( + definePlugin({ + id: 'post-ext', + result: { + Post: { + upperTitle: { + needs: { title: true }, + compute: (post) => post.title.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + + // Include posts with select that includes the ext result field + const users = await extDb.user.findMany({ + include: { posts: { select: { id: true, upperTitle: true } } }, + }); + // Verify the type is string, not never + const _upperTitle: string = users[0]!.posts[0]!.upperTitle; + expect(_upperTitle).toBe('HELLO'); + // title was injected as a need but should be stripped + expect((users[0]!.posts[0]! as any).title).toBeUndefined(); + // id was explicitly selected + expect(users[0]!.posts[0]!.id).toBeDefined(); + }); + + it('should NOT compute ext result fields on nested relations when omitted', async () => { + const extDb = db.$use( + definePlugin({ + id: 'post-ext', + result: { + Post: { + upperTitle: { + needs: { title: true }, + compute: (post) => post.title.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + + const users = await extDb.user.findMany({ + include: { posts: { omit: { upperTitle: true } } }, + }); + expect(users[0]!.posts[0]!.title).toBe('Hello'); + expect((users[0]!.posts[0]! as any).upperTitle).toBeUndefined(); + }); + + it('should compute ext result fields on relations fetched via select', async () => { + const extDb = db.$use( + definePlugin({ + id: 'post-ext', + result: { + Post: { + upperTitle: { + needs: { title: true }, + compute: (post) => post.title.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + + // Use top-level select that includes a relation + const users = await extDb.user.findMany({ + select: { id: true, posts: true }, + }); + expect(users[0]!.posts[0]!.upperTitle).toBe('HELLO'); + }); + + it('should compute ext result fields on to-one nested relations', async () => { + const extDb = db.$use( + definePlugin({ + id: 'user-ext', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + + // Include to-one relation (post.author) + const posts = await extDb.post.findMany({ include: { author: true } }); + expect(posts[0]!.author.upperName).toBe('ALICE'); + }); + + it('should have correct types for ext result fields on nested relations', async () => { + const extDb = db.$use( + definePlugin({ + id: 'nested-types', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + Post: { + upperTitle: { + needs: { title: true }, + compute: (post) => post.title.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + + // include: { posts: true } — nested posts should have upperTitle in the type + const users = await extDb.user.findMany({ include: { posts: true } }); + const post = users[0]!.posts[0]!; + const _title: string = post.upperTitle; + expect(_title).toBe('HELLO'); + + // to-one relation — author should have upperName in the type + const posts = await extDb.post.findMany({ include: { author: true } }); + const author = posts[0]!.author; + const _name: string = author.upperName; + expect(_name).toBe('ALICE'); + + // Without include, nested ext result fields should not appear + const plainUsers = await extDb.user.findMany(); + // @ts-expect-error - posts not included, so no posts property + plainUsers[0]!.posts; + // But top-level ext result should work + const _topLevel: string = plainUsers[0]!.upperName; + expect(_topLevel).toBe('ALICE'); + }); +}); diff --git a/tests/e2e/orm/plugin-infra/ext-result/schema.ts b/tests/e2e/orm/plugin-infra/ext-result/schema.ts new file mode 100644 index 000000000..da2f81501 --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result/schema.ts @@ -0,0 +1,80 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaDef, ExpressionUtils } from "@zenstackhq/schema"; +export class SchemaType implements SchemaDef { + provider = { + type: "sqlite" + } as const; + models = { + User: { + name: "User", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + name: { + name: "name", + type: "String" + }, + posts: { + name: "posts", + type: "Post", + array: true, + relation: { opposite: "author" } + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + }, + Post: { + name: "Post", + fields: { + id: { + name: "id", + type: "Int", + id: true, + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("autoincrement") }] }], + default: ExpressionUtils.call("autoincrement") + }, + title: { + name: "title", + type: "String" + }, + content: { + name: "content", + type: "String", + optional: true + }, + author: { + name: "author", + type: "User", + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }], + relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } + }, + authorId: { + name: "authorId", + type: "Int", + foreignKeyFor: ["author"] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); From e9bcc6047d95684f3dd4be44435164f1b2d4b87c Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 14:00:33 -0500 Subject: [PATCH 02/18] fix(orm): handle omitted needs dependencies for ext result fields When a user omits a field that is a `needs` dependency of an active ext result field, the computed field was silently missing from results. Ensure needs dependencies are un-omitted for the DB query and stripped from the final result, mirroring the existing `select` path behavior. Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/client-impl.ts | 19 ++++++++++++-- tests/e2e/orm/plugin-infra/ext-result.test.ts | 25 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 04a90e532..b59c6dca0 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -917,9 +917,17 @@ function prepareArgsForExtResult( if (omit && extResultDefs.size > 0) { const newOmit = { ...omit }; - for (const fieldName of extResultDefs.keys()) { + for (const [fieldName, fieldDef] of extResultDefs) { if (newOmit[fieldName]) { + // strip ext result field names from omit (they don't exist in the DB) delete newOmit[fieldName]; + } else { + // this ext result field is active — ensure its needs are not omitted + for (const needField of Object.keys(fieldDef.needs)) { + if (newOmit[needField]) { + delete newOmit[needField]; + } + } } } result = { ...result, omit: newOmit }; @@ -1039,9 +1047,16 @@ function applyExtResultToRow( } if (omit && extResultDefs.size > 0) { - for (const fieldName of extResultDefs.keys()) { + for (const [fieldName, fieldDef] of extResultDefs) { if (omit[fieldName]) { omittedExtResultFields!.add(fieldName); + } else { + // this ext result field is active — track needs that were originally omitted + for (const needField of Object.keys(fieldDef.needs)) { + if (omit[needField]) { + injectedNeedsFields.add(needField); + } + } } } } diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 574ed5782..7fbffa1a7 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -280,6 +280,31 @@ describe('Plugin extended result fields', () => { expect((users[0] as any).upperName).toBeUndefined(); }); + it('should still compute virtual fields when their needs dependency is omitted', async () => { + const extDb = db.$use( + definePlugin({ + id: 'greeting', + result: { + User: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + + // omit the `name` field which is a `needs` dependency of `upperName` + const users = await extDb.user.findMany({ omit: { name: true } }); + // upperName should still be computed even though its needs dep was omitted + expect(users[0]!.upperName).toBe('ALICE'); + // the omitted `name` field should not appear in the result + expect((users[0] as any).name).toBeUndefined(); + }); + it('should compose virtual fields from multiple plugins', async () => { const plugin1 = definePlugin({ id: 'plugin1', From ce8e9840a172d955f7d3f5c8a606c7df5c5a7611 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 14:25:34 -0500 Subject: [PATCH 03/18] fix(orm): address review feedback for ext result plugin - Gate ext-result computation to model-row operations only, excluding groupBy which uses postProcess but returns aggregated rows - Fix SelectAwareExtResult type to respect boolean values in select/omit (e.g. `{ select: { field: false } }` no longer type-includes the field) - Add orderBy to findMany test to ensure deterministic ordering - Remove "aggregate" from test name that didn't test aggregate Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/client-impl.ts | 23 +++++++++++++++---- packages/orm/src/client/crud-types.ts | 15 ++++++++++-- tests/e2e/orm/plugin-infra/ext-result.test.ts | 4 ++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index b59c6dca0..cfcf1a9aa 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -553,6 +553,19 @@ function createModelCrudHandler( const schema = client.$schema; const hasAnyExtResult = hasExtResultDefs(plugins); + // operations that return model rows and should have ext result fields applied + const extResultOperations = new Set([ + 'findMany', + 'findUnique', + 'findFirst', + 'create', + 'createManyAndReturn', + 'update', + 'updateManyAndReturn', + 'upsert', + 'delete', + ]); + const createPromise = ( operation: CoreCrudOperations, nominalOperation: AllCrudOperations, @@ -565,10 +578,10 @@ function createModelCrudHandler( let proceed = async (_args: unknown) => { // prepare args for ext result: strip ext result field names from select/omit, // inject needs fields into select (recursively handles nested relations) - const processedArgs = - postProcess && hasAnyExtResult - ? prepareArgsForExtResult(_args, model, schema, plugins) - : _args; + const shouldApplyExtResult = hasAnyExtResult && extResultOperations.has(operation); + const processedArgs = shouldApplyExtResult + ? prepareArgsForExtResult(_args, model, schema, plugins) + : _args; const _handler = txClient ? handler.withClient(txClient) : handler; const r = await _handler.handle(operation, processedArgs); @@ -583,7 +596,7 @@ function createModelCrudHandler( } // compute ext result fields (recursively handles nested relations) - if (result && postProcess && hasAnyExtResult) { + if (result && shouldApplyExtResult) { result = applyExtResult(result, model, _args, schema, plugins); } diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index c13cd99b4..982357034 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -2455,17 +2455,28 @@ export type ExtResultSelectOmitFields = { + [K in Keys]: K extends keyof S ? (S[K] extends false | undefined ? never : K) : never; +}[Keys]; + export type SelectAwareExtResult = keyof ExtResult extends never ? {} : T extends { select: infer S } ? S extends null | undefined ? ExtractExtResult - : Pick, Extract>> + : Pick< + ExtractExtResult, + TruthyKeys>> + > : T extends { omit: infer O } ? O extends null | undefined ? ExtractExtResult - : Omit, Extract>> + : Omit< + ExtractExtResult, + TruthyKeys>> + > : ExtractExtResult; // #endregion diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 7fbffa1a7..fe2e220ea 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -33,7 +33,7 @@ describe('Plugin extended result fields', () => { await extDb.user.create({ data: { name: 'Alice' } }); await extDb.user.create({ data: { name: 'Bob' } }); - const users = await extDb.user.findMany(); + const users = await extDb.user.findMany({ orderBy: { id: 'asc' } }); expect(users).toHaveLength(2); expect(users[0]!.greeting).toBe('Hello, Alice!'); expect(users[1]!.greeting).toBe('Hello, Bob!'); @@ -164,7 +164,7 @@ describe('Plugin extended result fields', () => { expect(users[1]!.upperName).toBe('BOB'); }); - it('should NOT compute virtual fields on count, aggregate, exists, createMany, updateMany, deleteMany', async () => { + it('should NOT compute virtual fields on count, exists, createMany, updateMany, deleteMany', async () => { const extDb = db.$use( definePlugin({ id: 'greeting', From 22085eba42d1bd1c7c3ff27a44d6034abc93588b Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 14:35:34 -0500 Subject: [PATCH 04/18] fix(tests): use type assertions for nested relation ext result tests Ext result fields in nested relation select/omit are not yet reflected in the type system. Use `as any` casts to fix TypeScript compilation errors in the nested relation test cases while still testing runtime behavior. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/orm/plugin-infra/ext-result.test.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index fe2e220ea..a0ebe4292 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -650,16 +650,16 @@ describe('Plugin extended result fields', () => { await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); // Include posts with select that includes the ext result field + // Note: ext result fields in nested relation select/omit are not yet reflected in types const users = await extDb.user.findMany({ - include: { posts: { select: { id: true, upperTitle: true } } }, + include: { posts: { select: { id: true, upperTitle: true } as any } }, }); - // Verify the type is string, not never - const _upperTitle: string = users[0]!.posts[0]!.upperTitle; - expect(_upperTitle).toBe('HELLO'); + const post = (users[0] as any).posts[0]!; + expect(post.upperTitle).toBe('HELLO'); // title was injected as a need but should be stripped - expect((users[0]!.posts[0]! as any).title).toBeUndefined(); + expect(post.title).toBeUndefined(); // id was explicitly selected - expect(users[0]!.posts[0]!.id).toBeDefined(); + expect(post.id).toBeDefined(); }); it('should NOT compute ext result fields on nested relations when omitted', async () => { @@ -680,11 +680,13 @@ describe('Plugin extended result fields', () => { await extDb.user.create({ data: { name: 'Alice' } }); await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); + // Note: ext result fields in nested relation select/omit are not yet reflected in types const users = await extDb.user.findMany({ - include: { posts: { omit: { upperTitle: true } } }, + include: { posts: { omit: { upperTitle: true } as any } }, }); - expect(users[0]!.posts[0]!.title).toBe('Hello'); - expect((users[0]!.posts[0]! as any).upperTitle).toBeUndefined(); + const post = (users[0] as any).posts[0]!; + expect(post.title).toBe('Hello'); + expect(post.upperTitle).toBeUndefined(); }); it('should compute ext result fields on relations fetched via select', async () => { From 7ddf9b50198a978bc1e5bd7014a1c4e6b13f8872 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 15:22:28 -0500 Subject: [PATCH 05/18] fix(tests): skip createManyAndReturn test on MySQL MySQL does not support createManyAndReturn. Add an early return guard matching the pattern used in other test files. Co-Authored-By: Claude Opus 4.6 --- tests/e2e/orm/plugin-infra/ext-result.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index a0ebe4292..43f204570 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -142,6 +142,11 @@ describe('Plugin extended result fields', () => { }); it('should compute virtual fields on createManyAndReturn', async () => { + if (db.$schema.provider.type === 'mysql') { + // MySQL does not support createManyAndReturn + return; + } + const extDb = db.$use( definePlugin({ id: 'greeting', From b5ada0f67499040e12da161d9904906804237f92 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 15:51:21 -0500 Subject: [PATCH 06/18] feat(orm): thread ExtResult through nested relation types and add resultField helper - Add ExtResult parameter to IncludeInput and SelectInput, and pass it through SelectIncludeOmit, so nested relation select/omit inputs recognize ext result field names without requiring `as any` casts - Add resultField() helper function that provides typed compute parameters based on needs keys via correlated type inference - Remove as-any casts from nested relation tests now that types work - Add test for resultField helper Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/crud-types.ts | 11 ++-- packages/orm/src/client/plugin.ts | 28 ++++++++++ tests/e2e/orm/plugin-infra/ext-result.test.ts | 53 ++++++++++++++----- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 982357034..16a2d877a 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -961,7 +961,7 @@ export type SelectIncludeOmit< /** * Explicitly select fields and relations to be returned by the query. */ - select?: (SelectInput & ExtResultSelectOmitFields) | null; + select?: (SelectInput & ExtResultSelectOmitFields) | null; /** * Explicitly omit fields from the query result. @@ -972,7 +972,7 @@ export type SelectIncludeOmit< /** * Specifies relations to be included in the query result. All scalar fields are included. */ - include?: IncludeInput | null; + include?: IncludeInput | null; } : {}); @@ -982,9 +982,10 @@ export type SelectInput< Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, AllowRelation extends boolean = true, + ExtResult extends ExtResultBase = {}, > = { [Key in NonRelationFields]?: boolean; -} & (AllowRelation extends true ? IncludeInput : {}); +} & (AllowRelation extends true ? IncludeInput : {}); type SelectCount, Options extends QueryOptions> = | boolean @@ -1006,6 +1007,7 @@ export type IncludeInput< Model extends GetModels, Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, + ExtResult extends ExtResultBase = {}, > = { [Key in RelationFields as RelationFieldType extends GetSlicedModels< Schema, @@ -1024,7 +1026,8 @@ export type IncludeInput< ? true : ModelFieldIsOptional extends true ? true - : false + : false, + ExtResult >; } & (AllowCount extends true ? // _count is only allowed if the model has to-many relations diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 4197d06fc..a8e9d2f2d 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -126,6 +126,34 @@ export function definePlugin< return plugin; } +/** + * Defines a single extended result field with typed `compute` parameter. + * + * The `compute` callback receives an object whose keys match the `needs` declaration, + * providing autocomplete and type checking for needed fields. + * + * @example + * ```typescript + * definePlugin({ + * id: 'my-plugin', + * result: { + * User: { + * fullName: resultField({ + * needs: { firstName: true, lastName: true }, + * compute: (user) => `${user.firstName} ${user.lastName}`, + * }), + * }, + * }, + * }); + * ``` + */ +export function resultField, R>(def: { + needs: N; + compute: (data: { [K in keyof N]: any }) => R; +}): { needs: N; compute: (data: { [K in keyof N]: any }) => R } { + return def; +} + // #region OnProcedure hooks type OnProcedureCallback = (ctx: OnProcedureHookContext) => Promise; diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 43f204570..da983c975 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -1,4 +1,4 @@ -import { definePlugin, type ClientContract } from '@zenstackhq/orm'; +import { definePlugin, resultField, type ClientContract } from '@zenstackhq/orm'; import { createTestClient } from '@zenstackhq/testtools'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { schema } from './ext-result/schema'; @@ -142,7 +142,7 @@ describe('Plugin extended result fields', () => { }); it('should compute virtual fields on createManyAndReturn', async () => { - if (db.$schema.provider.type === 'mysql') { + if ((db.$schema.provider.type as string) === 'mysql') { // MySQL does not support createManyAndReturn return; } @@ -655,16 +655,14 @@ describe('Plugin extended result fields', () => { await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); // Include posts with select that includes the ext result field - // Note: ext result fields in nested relation select/omit are not yet reflected in types const users = await extDb.user.findMany({ - include: { posts: { select: { id: true, upperTitle: true } as any } }, + include: { posts: { select: { id: true, upperTitle: true } } }, }); - const post = (users[0] as any).posts[0]!; - expect(post.upperTitle).toBe('HELLO'); + expect(users[0]!.posts[0]!.upperTitle).toBe('HELLO'); // title was injected as a need but should be stripped - expect(post.title).toBeUndefined(); + expect((users[0]!.posts[0]! as any).title).toBeUndefined(); // id was explicitly selected - expect(post.id).toBeDefined(); + expect(users[0]!.posts[0]!.id).toBeDefined(); }); it('should NOT compute ext result fields on nested relations when omitted', async () => { @@ -685,13 +683,11 @@ describe('Plugin extended result fields', () => { await extDb.user.create({ data: { name: 'Alice' } }); await extDb.post.create({ data: { title: 'Hello', authorId: 1 } }); - // Note: ext result fields in nested relation select/omit are not yet reflected in types const users = await extDb.user.findMany({ - include: { posts: { omit: { upperTitle: true } as any } }, + include: { posts: { omit: { upperTitle: true } } }, }); - const post = (users[0] as any).posts[0]!; - expect(post.title).toBe('Hello'); - expect(post.upperTitle).toBeUndefined(); + expect(users[0]!.posts[0]!.title).toBe('Hello'); + expect((users[0]!.posts[0]! as any).upperTitle).toBeUndefined(); }); it('should compute ext result fields on relations fetched via select', async () => { @@ -786,4 +782,35 @@ describe('Plugin extended result fields', () => { const _topLevel: string = plainUsers[0]!.upperName; expect(_topLevel).toBe('ALICE'); }); + + it('should support resultField helper for typed compute', async () => { + const extDb = db.$use( + definePlugin({ + id: 'typed-compute', + result: { + User: { + upperName: resultField({ + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }), + }, + Post: { + titleAndContent: resultField({ + needs: { title: true, content: true }, + compute: (post) => `${post.title}: ${post.content ?? 'no content'}`, + }), + }, + }, + }), + ); + + await extDb.user.create({ data: { name: 'Alice' } }); + await extDb.post.create({ data: { title: 'Hello', content: 'World', authorId: 1 } }); + + const users = await extDb.user.findMany(); + expect(users[0]!.upperName).toBe('ALICE'); + + const posts = await extDb.post.findMany(); + expect(posts[0]!.titleAndContent).toBe('Hello: World'); + }); }); From 83ef2c539ac2e4919eae687c311a44f5ea5d127c Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 20:17:56 -0500 Subject: [PATCH 07/18] refactor(orm): deduplicate ext result field types and helpers Import ExtResultFieldDef from plugin.ts instead of redeclaring a local duplicate type, and extract duplicated Zod factory loop into a shared addExtResultFields helper method. Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/client-impl.ts | 17 ++++++++--------- packages/orm/src/client/zod/factory.ts | 22 +++++++--------------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index cfcf1a9aa..629b9ccc4 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -37,7 +37,7 @@ import { ZenStackQueryExecutor } from './executor/zenstack-query-executor'; import * as BuiltinFunctions from './functions'; import { SchemaDbPusher } from './helpers/schema-db-pusher'; import type { ClientOptions, ProceduresOptions } from './options'; -import type { AnyPlugin } from './plugin'; +import type { AnyPlugin, ExtResultFieldDef } from './plugin'; import { getField } from './query-utils'; import { createZenStackPromise, type ZenStackPromise } from './promise'; import { ResultProcessor } from './result-processor'; @@ -551,7 +551,7 @@ function createModelCrudHandler( // check if any plugin defines ext result fields const plugins = client.$options.plugins ?? []; const schema = client.$schema; - const hasAnyExtResult = hasExtResultDefs(plugins); + const hasAnyExtResult = hasExtResultFieldDefs(plugins); // operations that return model rows and should have ext result fields applied const extResultOperations = new Set([ @@ -858,27 +858,26 @@ function createModelCrudHandler( // #region Extended result field helpers -type ExtResultDef = { needs: Record; compute: (data: any) => unknown }; /** * Returns true if any plugin defines ext result fields for any model. */ -function hasExtResultDefs(plugins: AnyPlugin[]): boolean { +function hasExtResultFieldDefs(plugins: AnyPlugin[]): boolean { return plugins.some((p) => p.result && Object.keys(p.result).length > 0); } /** * Collects extended result field definitions from all plugins for a given model. */ -function collectExtResultDefs(model: string, plugins: AnyPlugin[]): Map { - const defs = new Map(); +function collectExtResultFieldDefs(model: string, plugins: AnyPlugin[]): Map { + const defs = new Map(); for (const plugin of plugins) { const resultConfig = plugin.result; if (resultConfig) { const modelConfig = resultConfig[model]; if (modelConfig) { for (const [fieldName, fieldDef] of Object.entries(modelConfig)) { - defs.set(fieldName, fieldDef as ExtResultDef); + defs.set(fieldName, fieldDef as ExtResultFieldDef); } } } @@ -902,7 +901,7 @@ function prepareArgsForExtResult( return args; } - const extResultDefs = collectExtResultDefs(model, plugins); + const extResultDefs = collectExtResultFieldDefs(model, plugins); const typedArgs = args as Record; let result = typedArgs; let changed = false; @@ -1034,7 +1033,7 @@ function applyExtResultToRow( } const data = row as Record; - const extResultDefs = collectExtResultDefs(model, plugins); + const extResultDefs = collectExtResultFieldDefs(model, plugins); const typedArgs = (originalArgs && typeof originalArgs === 'object' ? originalArgs : {}) as Record; const select = typedArgs['select'] as Record | undefined; const omit = typedArgs['omit'] as Record | undefined; diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index ff26e25ca..abf417a5f 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -901,18 +901,7 @@ export class ZodSchemaFactory< } } - // add ext result fields from plugins - for (const plugin of this.plugins) { - const resultConfig = plugin.result; - if (resultConfig) { - const modelConfig = resultConfig[model]; - if (modelConfig) { - for (const field of Object.keys(modelConfig)) { - fields[field] = z.boolean().optional(); - } - } - } - } + this.addExtResultFields(model, fields); return z.strictObject(fields); } @@ -1020,7 +1009,12 @@ export class ZodSchemaFactory< } } - // add ext result fields from plugins + this.addExtResultFields(model, fields); + + return z.strictObject(fields); + } + + private addExtResultFields(model: string, fields: Record) { for (const plugin of this.plugins) { const resultConfig = plugin.result; if (resultConfig) { @@ -1032,8 +1026,6 @@ export class ZodSchemaFactory< } } } - - return z.strictObject(fields); } @cache() From e983addff55a7080e77079c749e087e14deb5388 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 20:39:02 -0500 Subject: [PATCH 08/18] chore(tests): add missing schema.zmodel for ext-result tests Add the source .zmodel file so the generated schema.ts can be regenerated, matching the convention used by sibling test directories. Co-Authored-By: Claude Opus 4.6 --- .../orm/plugin-infra/ext-result/schema.zmodel | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/e2e/orm/plugin-infra/ext-result/schema.zmodel diff --git a/tests/e2e/orm/plugin-infra/ext-result/schema.zmodel b/tests/e2e/orm/plugin-infra/ext-result/schema.zmodel new file mode 100644 index 000000000..5566b92c8 --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result/schema.zmodel @@ -0,0 +1,17 @@ +datasource db { + provider = "sqlite" +} + +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String? + author User @relation(fields: [authorId], references: [id]) + authorId Int +} From 755b5656f8088ff6de4dac9d77bab46e742c45d1 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Mar 2026 20:40:07 -0500 Subject: [PATCH 09/18] chore(tests): add missing generated files for ext-result tests Add input.ts and models.ts generated alongside schema.ts, matching the convention used by sibling test directories. Co-Authored-By: Claude Opus 4.6 --- .../e2e/orm/plugin-infra/ext-result/input.ts | 52 +++++++++++++++++++ .../e2e/orm/plugin-infra/ext-result/models.ts | 11 ++++ 2 files changed, 63 insertions(+) create mode 100644 tests/e2e/orm/plugin-infra/ext-result/input.ts create mode 100644 tests/e2e/orm/plugin-infra/ext-result/models.ts diff --git a/tests/e2e/orm/plugin-infra/ext-result/input.ts b/tests/e2e/orm/plugin-infra/ext-result/input.ts new file mode 100644 index 000000000..c6b620ee6 --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result/input.ts @@ -0,0 +1,52 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { FindManyArgs as $FindManyArgs, FindUniqueArgs as $FindUniqueArgs, FindFirstArgs as $FindFirstArgs, ExistsArgs as $ExistsArgs, CreateArgs as $CreateArgs, CreateManyArgs as $CreateManyArgs, CreateManyAndReturnArgs as $CreateManyAndReturnArgs, UpdateArgs as $UpdateArgs, UpdateManyArgs as $UpdateManyArgs, UpdateManyAndReturnArgs as $UpdateManyAndReturnArgs, UpsertArgs as $UpsertArgs, DeleteArgs as $DeleteArgs, DeleteManyArgs as $DeleteManyArgs, CountArgs as $CountArgs, AggregateArgs as $AggregateArgs, GroupByArgs as $GroupByArgs, WhereInput as $WhereInput, SelectInput as $SelectInput, IncludeInput as $IncludeInput, OmitInput as $OmitInput, QueryOptions as $QueryOptions } from "@zenstackhq/orm"; +import type { SimplifiedPlainResult as $Result, SelectIncludeOmit as $SelectIncludeOmit } from "@zenstackhq/orm"; +export type UserFindManyArgs = $FindManyArgs<$Schema, "User">; +export type UserFindUniqueArgs = $FindUniqueArgs<$Schema, "User">; +export type UserFindFirstArgs = $FindFirstArgs<$Schema, "User">; +export type UserExistsArgs = $ExistsArgs<$Schema, "User">; +export type UserCreateArgs = $CreateArgs<$Schema, "User">; +export type UserCreateManyArgs = $CreateManyArgs<$Schema, "User">; +export type UserCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "User">; +export type UserUpdateArgs = $UpdateArgs<$Schema, "User">; +export type UserUpdateManyArgs = $UpdateManyArgs<$Schema, "User">; +export type UserUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "User">; +export type UserUpsertArgs = $UpsertArgs<$Schema, "User">; +export type UserDeleteArgs = $DeleteArgs<$Schema, "User">; +export type UserDeleteManyArgs = $DeleteManyArgs<$Schema, "User">; +export type UserCountArgs = $CountArgs<$Schema, "User">; +export type UserAggregateArgs = $AggregateArgs<$Schema, "User">; +export type UserGroupByArgs = $GroupByArgs<$Schema, "User">; +export type UserWhereInput = $WhereInput<$Schema, "User">; +export type UserSelect = $SelectInput<$Schema, "User">; +export type UserInclude = $IncludeInput<$Schema, "User">; +export type UserOmit = $OmitInput<$Schema, "User">; +export type UserGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "User", Args, Options>; +export type PostFindManyArgs = $FindManyArgs<$Schema, "Post">; +export type PostFindUniqueArgs = $FindUniqueArgs<$Schema, "Post">; +export type PostFindFirstArgs = $FindFirstArgs<$Schema, "Post">; +export type PostExistsArgs = $ExistsArgs<$Schema, "Post">; +export type PostCreateArgs = $CreateArgs<$Schema, "Post">; +export type PostCreateManyArgs = $CreateManyArgs<$Schema, "Post">; +export type PostCreateManyAndReturnArgs = $CreateManyAndReturnArgs<$Schema, "Post">; +export type PostUpdateArgs = $UpdateArgs<$Schema, "Post">; +export type PostUpdateManyArgs = $UpdateManyArgs<$Schema, "Post">; +export type PostUpdateManyAndReturnArgs = $UpdateManyAndReturnArgs<$Schema, "Post">; +export type PostUpsertArgs = $UpsertArgs<$Schema, "Post">; +export type PostDeleteArgs = $DeleteArgs<$Schema, "Post">; +export type PostDeleteManyArgs = $DeleteManyArgs<$Schema, "Post">; +export type PostCountArgs = $CountArgs<$Schema, "Post">; +export type PostAggregateArgs = $AggregateArgs<$Schema, "Post">; +export type PostGroupByArgs = $GroupByArgs<$Schema, "Post">; +export type PostWhereInput = $WhereInput<$Schema, "Post">; +export type PostSelect = $SelectInput<$Schema, "Post">; +export type PostInclude = $IncludeInput<$Schema, "Post">; +export type PostOmit = $OmitInput<$Schema, "Post">; +export type PostGetPayload, Options extends $QueryOptions<$Schema> = $QueryOptions<$Schema>> = $Result<$Schema, "Post", Args, Options>; diff --git a/tests/e2e/orm/plugin-infra/ext-result/models.ts b/tests/e2e/orm/plugin-infra/ext-result/models.ts new file mode 100644 index 000000000..03524da52 --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result/models.ts @@ -0,0 +1,11 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// DO NOT MODIFY THIS FILE // +// This file is automatically generated by ZenStack CLI and should not be manually updated. // +////////////////////////////////////////////////////////////////////////////////////////////// + +/* eslint-disable */ + +import { type SchemaType as $Schema } from "./schema"; +import type { ModelResult as $ModelResult } from "@zenstackhq/orm"; +export type User = $ModelResult<$Schema, "User">; +export type Post = $ModelResult<$Schema, "Post">; From 6f8f3d684c0e8a2ffadc7ca974c8db22b7e073c4 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Mar 2026 10:09:03 -0500 Subject: [PATCH 10/18] perf(orm): hoist static Set and per-row Map in ext result helpers Move EXT_RESULT_OPERATIONS to a module-level constant to avoid reallocating on every Proxy get trap, and compute collectExtResultFieldDefs once per query instead of once per row. Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/client-impl.ts | 34 +++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 629b9ccc4..08b1a8042 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -553,19 +553,6 @@ function createModelCrudHandler( const schema = client.$schema; const hasAnyExtResult = hasExtResultFieldDefs(plugins); - // operations that return model rows and should have ext result fields applied - const extResultOperations = new Set([ - 'findMany', - 'findUnique', - 'findFirst', - 'create', - 'createManyAndReturn', - 'update', - 'updateManyAndReturn', - 'upsert', - 'delete', - ]); - const createPromise = ( operation: CoreCrudOperations, nominalOperation: AllCrudOperations, @@ -578,7 +565,7 @@ function createModelCrudHandler( let proceed = async (_args: unknown) => { // prepare args for ext result: strip ext result field names from select/omit, // inject needs fields into select (recursively handles nested relations) - const shouldApplyExtResult = hasAnyExtResult && extResultOperations.has(operation); + const shouldApplyExtResult = hasAnyExtResult && EXT_RESULT_OPERATIONS.has(operation); const processedArgs = shouldApplyExtResult ? prepareArgsForExtResult(_args, model, schema, plugins) : _args; @@ -858,6 +845,18 @@ function createModelCrudHandler( // #region Extended result field helpers +// operations that return model rows and should have ext result fields applied +const EXT_RESULT_OPERATIONS = new Set([ + 'findMany', + 'findUnique', + 'findFirst', + 'create', + 'createManyAndReturn', + 'update', + 'updateManyAndReturn', + 'upsert', + 'delete', +]); /** * Returns true if any plugin defines ext result fields for any model. @@ -1011,13 +1010,14 @@ function applyExtResult( schema: SchemaDef, plugins: AnyPlugin[], ): unknown { + const extResultDefs = collectExtResultFieldDefs(model, plugins); if (Array.isArray(result)) { for (let i = 0; i < result.length; i++) { - result[i] = applyExtResultToRow(result[i], model, originalArgs, schema, plugins); + result[i] = applyExtResultToRow(result[i], model, originalArgs, schema, plugins, extResultDefs); } return result; } else { - return applyExtResultToRow(result, model, originalArgs, schema, plugins); + return applyExtResultToRow(result, model, originalArgs, schema, plugins, extResultDefs); } } @@ -1027,13 +1027,13 @@ function applyExtResultToRow( originalArgs: unknown, schema: SchemaDef, plugins: AnyPlugin[], + extResultDefs: Map, ): unknown { if (!row || typeof row !== 'object') { return row; } const data = row as Record; - const extResultDefs = collectExtResultFieldDefs(model, plugins); const typedArgs = (originalArgs && typeof originalArgs === 'object' ? originalArgs : {}) as Record; const select = typedArgs['select'] as Record | undefined; const omit = typedArgs['omit'] as Record | undefined; From b87739cc745de3b0ab1b98255de7cbdd1ebffa9a Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 9 Mar 2026 12:42:52 -0400 Subject: [PATCH 11/18] refactor(orm): simplify ext result field cleanup in applyExtResultToRow Replace injected-needs tracking with declarative cleanup: after computing ext result fields, strip fields not in the original select/omit instead of surgically tracking which needs were injected. --- packages/orm/src/client/client-impl.ts | 56 ++++++++------------------ 1 file changed, 17 insertions(+), 39 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 08b1a8042..4c4a84a42 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -1039,46 +1039,12 @@ function applyExtResultToRow( const omit = typedArgs['omit'] as Record | undefined; const include = typedArgs['include'] as Record | undefined; - // Determine which ext result fields were selected/omitted at this level - const selectedExtResultFields = select ? new Set() : undefined; - const omittedExtResultFields = omit ? new Set() : undefined; - const injectedNeedsFields = new Set(); - - if (select && extResultDefs.size > 0) { - for (const [fieldName, fieldDef] of extResultDefs) { - if (select[fieldName]) { - selectedExtResultFields!.add(fieldName); - // Track injected needs: fields that were NOT in the original select - for (const needField of Object.keys(fieldDef.needs)) { - if (!select[needField]) { - injectedNeedsFields.add(needField); - } - } - } - } - } - - if (omit && extResultDefs.size > 0) { - for (const [fieldName, fieldDef] of extResultDefs) { - if (omit[fieldName]) { - omittedExtResultFields!.add(fieldName); - } else { - // this ext result field is active — track needs that were originally omitted - for (const needField of Object.keys(fieldDef.needs)) { - if (omit[needField]) { - injectedNeedsFields.add(needField); - } - } - } - } - } - // Compute ext result fields for the current model for (const [fieldName, fieldDef] of extResultDefs) { - if (omittedExtResultFields?.has(fieldName)) { + if (select && !select[fieldName]) { continue; } - if (selectedExtResultFields !== undefined && !selectedExtResultFields.has(fieldName)) { + if (omit?.[fieldName]) { continue; } const needsSatisfied = Object.keys(fieldDef.needs).every((needField) => needField in data); @@ -1087,9 +1053,21 @@ function applyExtResultToRow( } } - // Strip injected needs fields that weren't originally requested - for (const field of injectedNeedsFields) { - delete data[field]; + // Strip fields that shouldn't be in the result: when `select` was used, + // drop any field not in the original select and not a computed ext result field; + // when `omit` was used, re-delete any field the user originally omitted. + if (select) { + for (const key of Object.keys(data)) { + if (!select[key] && !extResultDefs.has(key)) { + delete data[key]; + } + } + } else if (omit) { + for (const key of Object.keys(omit)) { + if (omit[key] && !extResultDefs.has(key)) { + delete data[key]; + } + } } // Recurse into nested relation data From 76305dfbd9998e294da3890f13b6bddb82dcd95c Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 9 Mar 2026 12:50:29 -0400 Subject: [PATCH 12/18] docs(orm): move JSDoc comment to SelectAwareExtResult where it belongs --- packages/orm/src/client/crud-types.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 16a2d877a..bb49a1590 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -2452,17 +2452,16 @@ export type ExtResultSelectOmitFields = { + [K in Keys]: K extends keyof S ? (S[K] extends false | undefined ? never : K) : never; +}[Keys]; + /** * Select/omit-aware version of ExtractExtResult. * - If T has `select`, only includes ext result fields that are explicitly selected. * - If T has `omit`, excludes ext result fields that are explicitly omitted. * - Otherwise, includes all ext result fields. */ -// Extracts keys from S whose values are truthy (not false or undefined) -type TruthyKeys = { - [K in Keys]: K extends keyof S ? (S[K] extends false | undefined ? never : K) : never; -}[Keys]; - export type SelectAwareExtResult = keyof ExtResult extends never ? {} From 949a44bc20fe3140d737eb84b9fb20c3eb82e2e4 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 9 Mar 2026 13:52:30 -0400 Subject: [PATCH 13/18] feat(orm): tighten ExtResult type constraints and add validation tests Parameterize ExtResultBase with Schema across all type definitions for stricter type checking. Add NonRelationFields constraint on plugin result needs fields. Add type-level tests for invalid model names and invalid needs field names. Co-Authored-By: Claude Opus 4.6 --- packages/orm/src/client/contract.ts | 12 +++---- packages/orm/src/client/crud-types.ts | 34 +++++++++---------- packages/orm/src/client/plugin.ts | 29 +++++++++++----- tests/e2e/orm/plugin-infra/ext-result.test.ts | 34 +++++++++++++++++++ 4 files changed, 78 insertions(+), 31 deletions(-) diff --git a/packages/orm/src/client/contract.ts b/packages/orm/src/client/contract.ts index c2587eae4..4bf0c11a7 100644 --- a/packages/orm/src/client/contract.ts +++ b/packages/orm/src/client/contract.ts @@ -67,7 +67,7 @@ export type ClientContract< Options extends ClientOptions = ClientOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, ExtClientMembers extends ExtClientMembersBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The schema definition. @@ -179,7 +179,7 @@ export type ClientContract< PluginSchema extends SchemaDef = Schema, PluginExtQueryArgs extends ExtQueryArgsBase = {}, PluginExtClientMembers extends ExtClientMembersBase = {}, - PluginExtResult extends ExtResultBase = {}, + PluginExtResult extends ExtResultBase = {}, >( plugin: RuntimePlugin, ): ClientContract; @@ -227,7 +227,7 @@ export type TransactionClientContract< Options extends ClientOptions, ExtQueryArgs extends ExtQueryArgsBase, ExtClientMembers extends ExtClientMembersBase, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = Omit, TransactionUnsupportedMethods>; export type ProcedureOperations< @@ -296,7 +296,7 @@ export type AllModelOperations< Model extends GetModels, Options extends QueryOptions, ExtQueryArgs extends ExtQueryArgsBase, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = CommonModelOperations & // provider-specific operations (Schema['provider']['type'] extends 'mysql' @@ -356,7 +356,7 @@ type CommonModelOperations< Model extends GetModels, Options extends QueryOptions, ExtQueryArgs extends ExtQueryArgsBase, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * Returns a list of entities. @@ -892,7 +892,7 @@ export type ModelOperations< Model extends GetModels, Options extends ClientOptions = ClientOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = SliceOperations, Schema, Model, Options>; //#endregion diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index bb49a1590..f5a0cf1ff 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -152,7 +152,7 @@ type ModelSelectResult< Select, Omit, Options extends QueryOptions, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { [Key in keyof Select as Select[Key] extends false | undefined ? // not selected @@ -206,7 +206,7 @@ export type ModelResult< Options extends QueryOptions = QueryOptions, Optional = false, Array = false, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = WrapType< (Args extends { select: infer S extends object; @@ -251,7 +251,7 @@ export type SimplifiedResult< Options extends QueryOptions = QueryOptions, Optional = false, Array = false, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = Simplify>; export type SimplifiedPlainResult< @@ -259,7 +259,7 @@ export type SimplifiedPlainResult< Model extends GetModels, Args = {}, Options extends QueryOptions = QueryOptions, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = Simplify>; export type TypeDefResult< @@ -956,7 +956,7 @@ export type SelectIncludeOmit< AllowCount extends boolean, Options extends QueryOptions = QueryOptions, AllowRelation extends boolean = true, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * Explicitly select fields and relations to be returned by the query. @@ -982,7 +982,7 @@ export type SelectInput< Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, AllowRelation extends boolean = true, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { [Key in NonRelationFields]?: boolean; } & (AllowRelation extends true ? IncludeInput : {}); @@ -1007,7 +1007,7 @@ export type IncludeInput< Model extends GetModels, Options extends QueryOptions = QueryOptions, AllowCount extends boolean = true, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { [Key in RelationFields as RelationFieldType extends GetSlicedModels< Schema, @@ -1217,7 +1217,7 @@ export type FindArgs< Options extends QueryOptions, Collection extends boolean, AllowFilter extends boolean = true, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = (Collection extends true ? SortAndTakeArgs & (ProviderSupportsDistinct extends true @@ -1237,7 +1237,7 @@ export type FindManyArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = FindArgs & ExtractExtQueryArgs; export type FindFirstArgs< @@ -1245,7 +1245,7 @@ export type FindFirstArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = FindArgs & ExtractExtQueryArgs; export type ExistsArgs< @@ -1260,7 +1260,7 @@ export type FindUniqueArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { where: WhereUniqueInput; } & SelectIncludeOmit & @@ -1275,7 +1275,7 @@ export type CreateArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { data: CreateInput; } & SelectIncludeOmit & @@ -1293,7 +1293,7 @@ export type CreateManyAndReturnArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = CreateManyInput & SelectIncludeOmit & ExtractExtQueryArgs; @@ -1504,7 +1504,7 @@ export type UpdateArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The data to update the record with. @@ -1530,7 +1530,7 @@ export type UpdateManyAndReturnArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = UpdateManyPayload & SelectIncludeOmit & ExtractExtQueryArgs; @@ -1562,7 +1562,7 @@ export type UpsertArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The data to create the record if it doesn't exist. @@ -1800,7 +1800,7 @@ export type DeleteArgs< Model extends GetModels, Options extends QueryOptions = QueryOptions, ExtQueryArgs extends ExtQueryArgsBase = {}, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > = { /** * The unique filter to find the record to delete. diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index a8e9d2f2d..3d809238d 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -1,7 +1,7 @@ import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; import type { ZodType } from 'zod'; import type { ClientContract, ZModelFunction } from '.'; -import type { GetModels, SchemaDef } from '../schema'; +import type { GetModels, NonRelationFields, SchemaDef } from '../schema'; import type { MaybePromise } from '../utils/type-utils'; import type { AllCrudOperations, CoreCrudOperations } from './crud/operations/base'; @@ -22,23 +22,26 @@ export type ExtClientMembersBase = Record; /** * Definition for a single extended result field. + * When used without type parameters, accepts any field names and untyped compute. */ -export type ExtResultFieldDef = { +export type ExtResultFieldDef = Record> = { /** * Fields required to compute this result field. */ - needs: Record; + needs: Needs; /** * Computes the result field value from the query result row. */ - compute: (data: any) => unknown; + compute: (data: { [K in keyof Needs]: any }) => unknown; }; /** * Base shape of plugin-extended result fields. * Keyed by model name, each value maps field names to their definitions. */ -export type ExtResultBase = Record>; +export type ExtResultBase = Partial< + Record, Record> +>; /** * ZenStack runtime plugin. @@ -47,7 +50,7 @@ export interface RuntimePlugin< Schema extends SchemaDef, ExtQueryArgs extends ExtQueryArgsBase, ExtClientMembers extends Record, - ExtResult extends ExtResultBase = {}, + ExtResult extends ExtResultBase = {}, > { /** * Plugin ID. @@ -107,7 +110,17 @@ export interface RuntimePlugin< * Extended result fields on query results. * Keyed by model name, each value defines computed fields with `needs` and `compute`. */ - result?: ExtResult; + result?: { + [M in keyof ExtResult]: M extends GetModels + ? { + [F in keyof ExtResult[M]]: ExtResult[M][F] extends ExtResultFieldDef + ? keyof N extends NonRelationFields + ? ExtResult[M][F] + : ExtResultFieldDef, string>, true>> + : never; + } + : never; + }; } export type AnyPlugin = RuntimePlugin; @@ -119,7 +132,7 @@ export function definePlugin< Schema extends SchemaDef, const ExtQueryArgs extends ExtQueryArgsBase = {}, const ExtClientMembers extends Record = {}, - const ExtResult extends ExtResultBase = {}, + const ExtResult extends ExtResultBase = {}, >( plugin: RuntimePlugin, ): RuntimePlugin { diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index da983c975..6883ad5ac 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -813,4 +813,38 @@ describe('Plugin extended result fields', () => { const posts = await extDb.post.findMany(); expect(posts[0]!.titleAndContent).toBe('Hello: World'); }); + + it('should reject invalid model names in result config', () => { + // @ts-expect-error - "Userr" is not a valid model name + db.$use( + definePlugin({ + id: 'bad-model', + result: { + Userr: { + upperName: { + needs: { name: true }, + compute: (user) => user.name.toUpperCase(), + }, + }, + }, + }), + ); + }); + + it('should reject invalid needs field names', () => { + db.$use( + definePlugin({ + id: 'bad-needs', + result: { + User: { + upperName: { + // @ts-expect-error - "nonExistentField" is not a field on User + needs: { nonExistentField: true }, + compute: (user) => String(user.nonExistentField), + }, + }, + }, + }), + ); + }); }); From 06d0cf6346850c02fea0374ce7218c9df5589f75 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 10 Mar 2026 08:36:43 -0400 Subject: [PATCH 14/18] feat(orm): use lowercase model names in plugin result config Use Uncapitalize> for plugin result keys to match the lowercase-first convention used in ZenStackClient (e.g., db.user). Also export MapModelFieldType and strictly type compute data values via contextual typing in RuntimePlugin.result. --- packages/orm/src/client/client-impl.ts | 2 +- packages/orm/src/client/crud-types.ts | 19 +++-- packages/orm/src/client/plugin.ts | 36 ++++++++-- tests/e2e/orm/plugin-infra/ext-result.test.ts | 72 +++++++++---------- .../e2e/orm/plugin-infra/ext-result/schema.ts | 6 +- 5 files changed, 84 insertions(+), 51 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 4c4a84a42..3c7db8168 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -873,7 +873,7 @@ function collectExtResultFieldDefs(model: string, plugins: AnyPlugin[]): Map, Field extends GetModelFields, @@ -2438,9 +2438,16 @@ type ExtractExtQueryArgs = ( * Maps `{ needs, compute }` definitions to `{ fieldName: ReturnType }`. * When ExtResult is `{}`, this resolves to `{}` (no-op for intersection). */ -export type ExtractExtResult = Model extends keyof ExtResult - ? { [K in keyof ExtResult[Model]]: ExtResult[Model][K] extends { compute: (...args: any[]) => infer R } ? R : never } - : {}; +export type ExtractExtResult = + Uncapitalize extends keyof ExtResult + ? { + [K in keyof ExtResult[Uncapitalize]]: ExtResult[Uncapitalize][K] extends { + compute: (...args: any[]) => infer R; + } + ? R + : never; + } + : {}; /** * Extracts extended result field names as optional boolean keys for use in select/omit inputs. @@ -2448,8 +2455,8 @@ export type ExtractExtResult = keyof ExtResult extends never ? {} - : Model extends keyof ExtResult - ? { [K in keyof ExtResult[Model]]?: boolean } + : Uncapitalize extends keyof ExtResult + ? { [K in keyof ExtResult[Uncapitalize]]?: boolean } : {}; type TruthyKeys = { diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 3d809238d..f1841f4f7 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -1,7 +1,8 @@ import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; import type { ZodType } from 'zod'; import type { ClientContract, ZModelFunction } from '.'; -import type { GetModels, NonRelationFields, SchemaDef } from '../schema'; +import type { GetModelFields, GetModels, NonRelationFields, SchemaDef } from '../schema'; +import type { MapModelFieldType } from './crud-types'; import type { MaybePromise } from '../utils/type-utils'; import type { AllCrudOperations, CoreCrudOperations } from './crud/operations/base'; @@ -40,7 +41,7 @@ export type ExtResultFieldDef = Record = Partial< - Record, Record> + Record>, Record> >; /** @@ -111,12 +112,35 @@ export interface RuntimePlugin< * Keyed by model name, each value defines computed fields with `needs` and `compute`. */ result?: { - [M in keyof ExtResult]: M extends GetModels + [M in keyof ExtResult]: M extends Uncapitalize> ? { [F in keyof ExtResult[M]]: ExtResult[M][F] extends ExtResultFieldDef - ? keyof N extends NonRelationFields - ? ExtResult[M][F] - : ExtResultFieldDef, string>, true>> + ? keyof N extends NonRelationFields & GetModels> + ? { + needs: N; + compute: ( + data: { + [K in keyof N & + GetModelFields< + Schema, + Capitalize & GetModels + >]: MapModelFieldType< + Schema, + Capitalize & GetModels, + K + >; + }, + ) => unknown; + } + : ExtResultFieldDef< + Record< + Extract< + NonRelationFields & GetModels>, + string + >, + true + > + > : never; } : never; diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 6883ad5ac..6c174d537 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -20,7 +20,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { greeting: { needs: { name: true }, compute: (user) => `Hello, ${user.name}!`, @@ -44,7 +44,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { greeting: { needs: { name: true }, compute: (user) => `Hello, ${user.name}!`, @@ -64,7 +64,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -84,7 +84,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -107,7 +107,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -151,7 +151,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -174,7 +174,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -214,7 +214,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -245,7 +245,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -268,7 +268,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -290,7 +290,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -314,7 +314,7 @@ describe('Plugin extended result fields', () => { const plugin1 = definePlugin({ id: 'plugin1', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -326,7 +326,7 @@ describe('Plugin extended result fields', () => { const plugin2 = definePlugin({ id: 'plugin2', result: { - User: { + user: { idDoubled: { needs: { id: true }, compute: (user) => user.id * 2, @@ -348,7 +348,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -374,7 +374,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'p1', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -387,7 +387,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'p2', result: { - User: { + user: { idDoubled: { needs: { id: true }, compute: (user) => user.id * 2, @@ -414,7 +414,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -438,7 +438,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'greeting', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -462,7 +462,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'full-info', result: { - User: { + user: { fullInfo: { needs: { id: true, name: true }, compute: (user) => `${user.id}:${user.name}`, @@ -482,7 +482,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'full-info', result: { - User: { + user: { fullInfo: { needs: { id: true, name: true }, compute: (user) => `${user.id}:${user.name}`, @@ -508,7 +508,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'full-info', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -533,7 +533,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'typing-test', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -586,7 +586,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'post-ext', result: { - Post: { + post: { upperTitle: { needs: { title: true }, compute: (post) => post.title.toUpperCase(), @@ -612,13 +612,13 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'both-ext', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), }, }, - Post: { + post: { upperTitle: { needs: { title: true }, compute: (post) => post.title.toUpperCase(), @@ -641,7 +641,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'post-ext', result: { - Post: { + post: { upperTitle: { needs: { title: true }, compute: (post) => post.title.toUpperCase(), @@ -670,7 +670,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'post-ext', result: { - Post: { + post: { upperTitle: { needs: { title: true }, compute: (post) => post.title.toUpperCase(), @@ -695,7 +695,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'post-ext', result: { - Post: { + post: { upperTitle: { needs: { title: true }, compute: (post) => post.title.toUpperCase(), @@ -720,7 +720,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'user-ext', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -743,13 +743,13 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'nested-types', result: { - User: { + user: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), }, }, - Post: { + post: { upperTitle: { needs: { title: true }, compute: (post) => post.title.toUpperCase(), @@ -788,13 +788,13 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'typed-compute', result: { - User: { + user: { upperName: resultField({ needs: { name: true }, compute: (user) => user.name.toUpperCase(), }), }, - Post: { + post: { titleAndContent: resultField({ needs: { title: true, content: true }, compute: (post) => `${post.title}: ${post.content ?? 'no content'}`, @@ -815,12 +815,12 @@ describe('Plugin extended result fields', () => { }); it('should reject invalid model names in result config', () => { - // @ts-expect-error - "Userr" is not a valid model name + // @ts-expect-error - "userr" is not a valid model name db.$use( definePlugin({ id: 'bad-model', result: { - Userr: { + userr: { upperName: { needs: { name: true }, compute: (user) => user.name.toUpperCase(), @@ -836,7 +836,7 @@ describe('Plugin extended result fields', () => { definePlugin({ id: 'bad-needs', result: { - User: { + user: { upperName: { // @ts-expect-error - "nonExistentField" is not a field on User needs: { nonExistentField: true }, diff --git a/tests/e2e/orm/plugin-infra/ext-result/schema.ts b/tests/e2e/orm/plugin-infra/ext-result/schema.ts index da2f81501..03c65045d 100644 --- a/tests/e2e/orm/plugin-infra/ext-result/schema.ts +++ b/tests/e2e/orm/plugin-infra/ext-result/schema.ts @@ -59,13 +59,15 @@ export class SchemaType implements SchemaDef { author: { name: "author", type: "User", - attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("String", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("String", [ExpressionUtils.field("id")]) }] }], + attributes: [{ name: "@relation", args: [{ name: "fields", value: ExpressionUtils.array("Int", [ExpressionUtils.field("authorId")]) }, { name: "references", value: ExpressionUtils.array("Int", [ExpressionUtils.field("id")]) }] }], relation: { opposite: "posts", fields: ["authorId"], references: ["id"] } }, authorId: { name: "authorId", type: "Int", - foreignKeyFor: ["author"] + foreignKeyFor: [ + "author" + ] } }, idFields: ["id"], From dd55e9477f2b530d285cf0f3eb52ef71817f6b18 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 10 Mar 2026 09:36:41 -0400 Subject: [PATCH 15/18] fix(orm): fix ExtResult type inference and Zod factory model name casing Simplify the `result` property type in RuntimePlugin from a complex mapped/conditional type to plain `ExtResult`. The complex type prevented TypeScript from reverse-inferring ExtResult, causing it to fall back to {} and making all ext result fields invisible in return types. Also fix addExtResultFields in ZodSchemaFactory to use lowerCaseFirst(model) matching the convention used in the runtime helpers. Convert compile-time validation tests to runtime graceful-handling tests since model name and needs field validation requires an explicit Schema type parameter which isn't available through definePlugin(). --- packages/orm/src/client/plugin.ts | 40 ++----------------- packages/orm/src/client/zod/factory.ts | 2 +- tests/e2e/orm/plugin-infra/ext-result.test.ts | 24 +++++++---- 3 files changed, 20 insertions(+), 46 deletions(-) diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index f1841f4f7..7a654e840 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -1,8 +1,7 @@ import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; import type { ZodType } from 'zod'; import type { ClientContract, ZModelFunction } from '.'; -import type { GetModelFields, GetModels, NonRelationFields, SchemaDef } from '../schema'; -import type { MapModelFieldType } from './crud-types'; +import type { GetModels, SchemaDef } from '../schema'; import type { MaybePromise } from '../utils/type-utils'; import type { AllCrudOperations, CoreCrudOperations } from './crud/operations/base'; @@ -41,7 +40,7 @@ export type ExtResultFieldDef = Record = Partial< - Record>, Record> + Record>, Record any }>> >; /** @@ -111,40 +110,7 @@ export interface RuntimePlugin< * Extended result fields on query results. * Keyed by model name, each value defines computed fields with `needs` and `compute`. */ - result?: { - [M in keyof ExtResult]: M extends Uncapitalize> - ? { - [F in keyof ExtResult[M]]: ExtResult[M][F] extends ExtResultFieldDef - ? keyof N extends NonRelationFields & GetModels> - ? { - needs: N; - compute: ( - data: { - [K in keyof N & - GetModelFields< - Schema, - Capitalize & GetModels - >]: MapModelFieldType< - Schema, - Capitalize & GetModels, - K - >; - }, - ) => unknown; - } - : ExtResultFieldDef< - Record< - Extract< - NonRelationFields & GetModels>, - string - >, - true - > - > - : never; - } - : never; - }; + result?: ExtResult; } export type AnyPlugin = RuntimePlugin; diff --git a/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index abf417a5f..3b6ddb439 100644 --- a/packages/orm/src/client/zod/factory.ts +++ b/packages/orm/src/client/zod/factory.ts @@ -1018,7 +1018,7 @@ export class ZodSchemaFactory< for (const plugin of this.plugins) { const resultConfig = plugin.result; if (resultConfig) { - const modelConfig = resultConfig[model]; + const modelConfig = resultConfig[lowerCaseFirst(model)]; if (modelConfig) { for (const field of Object.keys(modelConfig)) { fields[field] = z.boolean().optional(); diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 6c174d537..9c0a0a2d7 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -814,9 +814,8 @@ describe('Plugin extended result fields', () => { expect(posts[0]!.titleAndContent).toBe('Hello: World'); }); - it('should reject invalid model names in result config', () => { - // @ts-expect-error - "userr" is not a valid model name - db.$use( + it('should ignore invalid model names in result config at runtime', async () => { + const extDb = db.$use( definePlugin({ id: 'bad-model', result: { @@ -826,25 +825,34 @@ describe('Plugin extended result fields', () => { compute: (user) => user.name.toUpperCase(), }, }, - }, + } as any, }), ); + + await extDb.user.create({ data: { name: 'Alice' } }); + // "userr" doesn't match any model, so no ext result fields are applied + const users = await extDb.user.findMany(); + expect(users[0]).not.toHaveProperty('upperName'); }); - it('should reject invalid needs field names', () => { - db.$use( + it('should handle invalid needs field names gracefully at runtime', async () => { + const extDb = db.$use( definePlugin({ id: 'bad-needs', result: { user: { upperName: { - // @ts-expect-error - "nonExistentField" is not a field on User needs: { nonExistentField: true }, compute: (user) => String(user.nonExistentField), }, }, - }, + } as any, }), ); + + await extDb.user.create({ data: { name: 'Alice' } }); + // "nonExistentField" is never in the result, so needsSatisfied is false and compute is skipped + const users = await extDb.user.findMany(); + expect(users[0]).not.toHaveProperty('upperName'); }); }); From ea7bf4ca9ecdbb459b343437a83b9869a4a09472 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 10 Mar 2026 09:46:38 -0400 Subject: [PATCH 16/18] fix(orm): validate ext result fields don't shadow real model fields Add a check in collectExtResultFieldDefs that throws if a plugin registers an ext result field name that already exists on the model. Without this, prepareArgsForExtResult would strip the real field from select/omit and applyExtResultToRow would overwrite the actual DB value with the computed one. --- packages/orm/src/client/client-impl.ts | 15 ++++++++++--- tests/e2e/orm/plugin-infra/ext-result.test.ts | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 3c7db8168..a87fb7d5f 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -868,7 +868,11 @@ function hasExtResultFieldDefs(plugins: AnyPlugin[]): boolean { /** * Collects extended result field definitions from all plugins for a given model. */ -function collectExtResultFieldDefs(model: string, plugins: AnyPlugin[]): Map { +function collectExtResultFieldDefs( + model: string, + schema: SchemaDef, + plugins: AnyPlugin[], +): Map { const defs = new Map(); for (const plugin of plugins) { const resultConfig = plugin.result; @@ -876,6 +880,11 @@ function collectExtResultFieldDefs(model: string, plugins: AnyPlugin[]): Map; let result = typedArgs; let changed = false; @@ -1010,7 +1019,7 @@ function applyExtResult( schema: SchemaDef, plugins: AnyPlugin[], ): unknown { - const extResultDefs = collectExtResultFieldDefs(model, plugins); + const extResultDefs = collectExtResultFieldDefs(model, schema, plugins); if (Array.isArray(result)) { for (let i = 0; i < result.length; i++) { result[i] = applyExtResultToRow(result[i], model, originalArgs, schema, plugins, extResultDefs); diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 9c0a0a2d7..889efb282 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -835,6 +835,28 @@ describe('Plugin extended result fields', () => { expect(users[0]).not.toHaveProperty('upperName'); }); + it('should reject ext result fields that shadow real model fields', async () => { + await db.user.create({ data: { name: 'Alice' } }); + + const extDb = db.$use( + definePlugin({ + id: 'shadow', + result: { + user: { + name: { + needs: { id: true }, + compute: (user) => `name-${user.id}`, + }, + }, + } as any, + }), + ); + + await expect(extDb.user.findMany()).rejects.toThrow( + /conflicts with an existing model field/, + ); + }); + it('should handle invalid needs field names gracefully at runtime', async () => { const extDb = db.$use( definePlugin({ From 8d2d39f342035160e36ee4e081061620c9459def Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Tue, 10 Mar 2026 11:48:10 -0400 Subject: [PATCH 17/18] chore: update typo --- packages/orm/src/client/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 7a654e840..8d8115849 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -140,7 +140,7 @@ export function definePlugin< * definePlugin({ * id: 'my-plugin', * result: { - * User: { + * user: { * fullName: resultField({ * needs: { firstName: true, lastName: true }, * compute: (user) => `${user.firstName} ${user.lastName}`, From 046e6c931c8aa5849927424bbfc5f945ffb7ba88 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 11 Mar 2026 11:14:09 -0400 Subject: [PATCH 18/18] fix(orm): validate ext result `needs` keys against model schema - Add runtime validation in collectExtResultFieldDefs to reject needs keys that don't exist on the model or reference relation fields - Improve ExtResultBase type to constrain needs keys to NonRelationFields for better autocomplete and type checking via `satisfies` - Update existing test from silent-skip to fail-fast behavior - Add test for relation fields in needs --- packages/orm/src/client/client-impl.ts | 8 +++++ packages/orm/src/client/plugin.ts | 15 +++++++--- tests/e2e/orm/plugin-infra/ext-result.test.ts | 29 +++++++++++++++---- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index a87fb7d5f..b2ac7266d 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -885,6 +885,14 @@ function collectExtResultFieldDefs( `Plugin "${plugin.id}" registers ext result field "${fieldName}" on model "${model}" which conflicts with an existing model field`, ); } + for (const needField of Object.keys((fieldDef as ExtResultFieldDef).needs ?? {})) { + const needDef = getField(schema, model, needField); + if (!needDef || needDef.relation) { + throw new Error( + `Plugin "${plugin.id}" registers ext result field "${fieldName}" on model "${model}" with invalid need "${needField}"`, + ); + } + } defs.set(fieldName, fieldDef as ExtResultFieldDef); } } diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index 8d8115849..39e6e7440 100644 --- a/packages/orm/src/client/plugin.ts +++ b/packages/orm/src/client/plugin.ts @@ -1,7 +1,7 @@ import type { OperationNode, QueryId, QueryResult, RootOperationNode, UnknownRow } from 'kysely'; import type { ZodType } from 'zod'; import type { ClientContract, ZModelFunction } from '.'; -import type { GetModels, SchemaDef } from '../schema'; +import type { GetModels, NonRelationFields, SchemaDef } from '../schema'; import type { MaybePromise } from '../utils/type-utils'; import type { AllCrudOperations, CoreCrudOperations } from './crud/operations/base'; @@ -38,10 +38,17 @@ export type ExtResultFieldDef = Record = Partial< - Record>, Record any }>> ->; +export type ExtResultBase = { + [M in Uncapitalize>]?: Record< + string, + { + needs: Partial & GetModels>, true>>; + compute: (...args: any[]) => any; + } + >; +}; /** * ZenStack runtime plugin. diff --git a/tests/e2e/orm/plugin-infra/ext-result.test.ts b/tests/e2e/orm/plugin-infra/ext-result.test.ts index 889efb282..56ed288cd 100644 --- a/tests/e2e/orm/plugin-infra/ext-result.test.ts +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -857,7 +857,7 @@ describe('Plugin extended result fields', () => { ); }); - it('should handle invalid needs field names gracefully at runtime', async () => { + it('should reject ext result fields with invalid needs field names', async () => { const extDb = db.$use( definePlugin({ id: 'bad-needs', @@ -872,9 +872,28 @@ describe('Plugin extended result fields', () => { }), ); - await extDb.user.create({ data: { name: 'Alice' } }); - // "nonExistentField" is never in the result, so needsSatisfied is false and compute is skipped - const users = await extDb.user.findMany(); - expect(users[0]).not.toHaveProperty('upperName'); + await expect(extDb.user.findMany()).rejects.toThrow( + /invalid need "nonExistentField"/, + ); + }); + + it('should reject ext result fields with relation fields in needs', async () => { + const extDb = db.$use( + definePlugin({ + id: 'bad-needs-relation', + result: { + user: { + postCount: { + needs: { posts: true }, + compute: (user) => String(user.posts), + }, + }, + } as any, + }), + ); + + await expect(extDb.user.findMany()).rejects.toThrow( + /invalid need "posts"/, + ); }); });