diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 1046d0ca8..b2ac7266d 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -37,7 +37,8 @@ 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'; @@ -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 = hasExtResultFieldDefs(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 shouldApplyExtResult = hasAnyExtResult && EXT_RESULT_OPERATIONS.has(operation); + const processedArgs = shouldApplyExtResult + ? 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 && shouldApplyExtResult) { + result = applyExtResult(result, model, _args, schema, plugins); + } + return result; }; @@ -823,3 +842,269 @@ function createModelCrudHandler( return operations as ModelOperations; } + +// #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. + */ +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 collectExtResultFieldDefs( + model: string, + schema: SchemaDef, + plugins: AnyPlugin[], +): Map { + const defs = new Map(); + for (const plugin of plugins) { + const resultConfig = plugin.result; + if (resultConfig) { + const modelConfig = resultConfig[lowerCaseFirst(model)]; + if (modelConfig) { + for (const [fieldName, fieldDef] of Object.entries(modelConfig)) { + if (getField(schema, model, fieldName)) { + throw new Error( + `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); + } + } + } + } + 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 = collectExtResultFieldDefs(model, schema, 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, 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 }; + 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 { + 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); + } + return result; + } else { + return applyExtResultToRow(result, model, originalArgs, schema, plugins, extResultDefs); + } +} + +function applyExtResultToRow( + row: unknown, + model: string, + originalArgs: unknown, + schema: SchemaDef, + plugins: AnyPlugin[], + extResultDefs: Map, +): unknown { + if (!row || typeof row !== 'object') { + return row; + } + + const data = row as Record; + 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; + + // Compute ext result fields for the current model + for (const [fieldName, fieldDef] of extResultDefs) { + if (select && !select[fieldName]) { + continue; + } + if (omit?.[fieldName]) { + continue; + } + const needsSatisfied = Object.keys(fieldDef.needs).every((needField) => needField in data); + if (needsSatisfied) { + data[fieldName] = fieldDef.compute(data); + } + } + + // 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 + 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..4bf0c11a7 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..230b162ff 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,22 +956,23 @@ 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 ? { /** * Specifies relations to be included in the query result. All scalar fields are included. */ - include?: IncludeInput | null; + include?: IncludeInput | null; } : {}); @@ -971,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 @@ -995,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, @@ -1013,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 @@ -1087,7 +1101,7 @@ type RelationFilter< //#region Field utils -type MapModelFieldType< +export type MapModelFieldType< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, @@ -1203,6 +1217,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 +1230,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 +1260,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 +1275,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 +1293,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 +1504,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 +1515,7 @@ export type UpdateArgs< * The unique filter to find the record to update. */ where: WhereUniqueInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; export type UpdateManyArgs< @@ -1509,8 +1530,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 +1562,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 +1578,7 @@ export type UpsertArgs< * The unique filter to find the record to update. */ where: WhereUniqueInput; -} & SelectIncludeOmit & +} & SelectIncludeOmit & ExtractExtQueryArgs; type UpdateScalarInput< @@ -1777,12 +1800,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 +2433,59 @@ 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 = + 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. + * When ExtResult is `{}`, this resolves to `{}` (no-op for intersection). + */ +export type ExtResultSelectOmitFields = keyof ExtResult extends never + ? {} + : Uncapitalize extends keyof ExtResult + ? { [K in keyof ExtResult[Uncapitalize]]?: boolean } + : {}; + +type TruthyKeys = { + [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. + */ +export type SelectAwareExtResult = + keyof ExtResult extends never + ? {} + : T extends { select: infer S } + ? S extends null | undefined + ? ExtractExtResult + : Pick< + ExtractExtResult, + TruthyKeys>> + > + : T extends { omit: infer O } + ? O extends null | undefined + ? ExtractExtResult + : Omit< + ExtractExtResult, + TruthyKeys>> + > + : ExtractExtResult; + // #endregion diff --git a/packages/orm/src/client/plugin.ts b/packages/orm/src/client/plugin.ts index e531242e3..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'; @@ -20,6 +20,36 @@ export type ExtQueryArgsBase = { */ 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 = Record> = { + /** + * Fields required to compute this result field. + */ + needs: Needs; + /** + * Computes the result field value from the query result row. + */ + 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. + * `needs` keys are constrained to non-relation fields of the corresponding model. + */ +export type ExtResultBase = { + [M in Uncapitalize>]?: Record< + string, + { + needs: Partial & GetModels>, true>>; + compute: (...args: any[]) => any; + } + >; +}; + /** * ZenStack runtime plugin. */ @@ -27,6 +57,7 @@ export interface RuntimePlugin< Schema extends SchemaDef, ExtQueryArgs extends ExtQueryArgsBase, ExtClientMembers extends Record, + ExtResult extends ExtResultBase = {}, > { /** * Plugin ID. @@ -81,9 +112,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,10 +129,41 @@ 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; } +/** + * 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/packages/orm/src/client/zod/factory.ts b/packages/orm/src/client/zod/factory.ts index 0f0eb61e8..3b6ddb439 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,8 @@ export class ZodSchemaFactory< } } + this.addExtResultFields(model, fields); + return z.strictObject(fields); } @@ -1006,9 +1008,26 @@ export class ZodSchemaFactory< } } } + + 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) { + const modelConfig = resultConfig[lowerCaseFirst(model)]; + if (modelConfig) { + for (const field of Object.keys(modelConfig)) { + fields[field] = z.boolean().optional(); + } + } + } + } + } + @cache() private makeIncludeSchema(model: string, options?: CreateSchemaOptions) { const modelDef = requireModel(this.schema, model); 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..56ed288cd --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result.test.ts @@ -0,0 +1,899 @@ +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'; + +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({ orderBy: { id: 'asc' } }); + 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 () => { + if ((db.$schema.provider.type as string) === 'mysql') { + // MySQL does not support createManyAndReturn + return; + } + + 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, 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 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', + 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 } } }, + }); + expect(users[0]!.posts[0]!.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'); + }); + + 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'); + }); + + it('should ignore invalid model names in result config at runtime', async () => { + const extDb = db.$use( + definePlugin({ + id: 'bad-model', + result: { + userr: { + upperName: { + needs: { name: true }, + 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 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 reject ext result fields with invalid needs field names', async () => { + const extDb = db.$use( + definePlugin({ + id: 'bad-needs', + result: { + user: { + upperName: { + needs: { nonExistentField: true }, + compute: (user) => String(user.nonExistentField), + }, + }, + } as any, + }), + ); + + 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"/, + ); + }); +}); 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">; 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..03c65045d --- /dev/null +++ b/tests/e2e/orm/plugin-infra/ext-result/schema.ts @@ -0,0 +1,82 @@ +////////////////////////////////////////////////////////////////////////////////////////////// +// 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("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" + ] + } + }, + idFields: ["id"], + uniqueFields: { + id: { type: "Int" } + } + } + } as const; + authType = "User" as const; + plugins = {}; +} +export const schema = new SchemaType(); 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 +}