From ca6debbd1af5ef739921a76d247ef8089fbb94ca Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 11:59:14 -0500 Subject: [PATCH 01/39] feat: add virtual field attribute and utility function --- packages/language/res/stdlib.zmodel | 5 +++++ packages/language/src/utils.ts | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index d0c3c0003..80384ae14 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -622,6 +622,11 @@ attribute @json() @@@targetField([TypeDefField]) */ attribute @computed() +/** + * Marks a field to be virtual + */ +attribute @virtual() + /** * Gets the current login user. */ diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index bdd8baa6a..59d2bc693 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -154,6 +154,13 @@ export function isComputedField(field: DataField) { return hasAttribute(field, '@computed'); } +/** + * Returns if the given field is a virtual field. + */ +export function isVirtualField(field: DataField) { + return hasAttribute(field, '@virtual'); +} + export function isDelegateModel(node: AstNode) { return isDataModel(node) && hasAttribute(node, '@@delegate'); } From 8796ed2ea4253ed14ee2dd877c2510e5eaa31dde Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 12:03:33 -0500 Subject: [PATCH 02/39] fix: pass authentication context to result processor in createModelCrudHandler --- packages/orm/src/client/client-impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index fc8f92c7c..96cbee3b6 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -456,7 +456,7 @@ function createModelCrudHandler( } let result: unknown; if (r && postProcess) { - result = resultProcessor.processResult(r, model, args); + result = resultProcessor.processResult(r, model, args, client.$auth); } else { result = r ?? null; } From 1f25dc0e533173686de45a67c7611f9b8d6fe7ca Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 13:24:55 -0500 Subject: [PATCH 03/39] feat: exclude virtual fields from WhereInput and OrderBy types --- packages/orm/src/client/crud-types.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 62be0c199..0bbe628a2 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -23,6 +23,8 @@ import type { GetTypeDefs, ModelFieldIsOptional, NonRelationFields, + NonVirtualFields, + NonVirtualNonRelationFields, ProcedureDef, RelationFields, RelationFieldType, @@ -281,7 +283,8 @@ export type WhereInput< ScalarOnly extends boolean = false, WithAggregations extends boolean = false, > = { - [Key in GetModelFields as ScalarOnly extends true + // Use NonVirtualFields to exclude virtual fields - they are computed at runtime and cannot be filtered in the database + [Key in NonVirtualFields as ScalarOnly extends true ? Key extends RelationFields ? never : Key @@ -755,7 +758,8 @@ export type OrderBy< WithRelation extends boolean, WithAggregation extends boolean, > = { - [Key in NonRelationFields]?: ModelFieldIsOptional extends true + // Use NonVirtualNonRelationFields to exclude virtual fields - they are computed at runtime and cannot be sorted in the database + [Key in NonVirtualNonRelationFields]?: ModelFieldIsOptional extends true ? | SortOrder | { From d64994e3579027309ff385849b435f5a971c7223 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 13:27:45 -0500 Subject: [PATCH 04/39] feat: skip virtual fields in select field processing --- packages/orm/src/client/crud/dialects/base-dialect.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 91848d57a..a9dcaa0c3 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1133,6 +1133,11 @@ export abstract class BaseCrudDialect { if (this.shouldOmitField(omit, model, field)) { continue; } + // virtual fields don't exist in the database, skip them + const fieldDef = modelDef.fields[field]; + if (fieldDef?.virtual) { + continue; + } result = this.buildSelectField(result, model, modelAlias, field); } From 64091ab63726e288d49c2d7fe8b44b9efdbfcbc8 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 13:59:40 -0500 Subject: [PATCH 05/39] feat: exclude virtual fields from relation selections in LateralJoinDialect and SqliteCrudDialect --- .../client/crud/dialects/lateral-join-dialect-base.ts | 10 +++++++--- packages/orm/src/client/crud/dialects/sqlite.ts | 5 ++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts index 6bc1f887c..924553a0f 100644 --- a/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts +++ b/packages/orm/src/client/crud/dialects/lateral-join-dialect-base.ts @@ -204,13 +204,13 @@ export abstract class LateralJoinDialectBase extends B } if (payload === true || !payload.select) { - // select all scalar fields except for omitted + // select all scalar fields except for omitted and virtual const omit = typeof payload === 'object' ? payload.omit : undefined; Object.assign( objArgs, ...Object.entries(relationModelDef.fields) - .filter(([, value]) => !value.relation) + .filter(([, value]) => !value.relation && !value.virtual) .filter(([name]) => !this.shouldOmitField(omit, relationModel, name)) .map(([field]) => ({ [field]: this.fieldRef(relationModel, field, relationModelAlias, false), @@ -233,6 +233,9 @@ export abstract class LateralJoinDialectBase extends B return { [field]: subJson }; } else { const fieldDef = requireField(this.schema, relationModel, field); + if (fieldDef.virtual) { + return null; + } const fieldValue = fieldDef.relation ? // reference the synthesized JSON field eb.ref(`${parentResultName}$${field}.$data`) @@ -240,7 +243,8 @@ export abstract class LateralJoinDialectBase extends B this.fieldRef(relationModel, field, relationModelAlias, false); return { [field]: fieldValue }; } - }), + }) + .filter((v) => v !== null), ); } diff --git a/packages/orm/src/client/crud/dialects/sqlite.ts b/packages/orm/src/client/crud/dialects/sqlite.ts index ee6de5da0..89fea61ac 100644 --- a/packages/orm/src/client/crud/dialects/sqlite.ts +++ b/packages/orm/src/client/crud/dialects/sqlite.ts @@ -253,7 +253,7 @@ export class SqliteCrudDialect extends BaseCrudDialect const omit = typeof payload === 'object' ? payload.omit : undefined; objArgs.push( ...Object.entries(relationModelDef.fields) - .filter(([, value]) => !value.relation) + .filter(([, value]) => !value.relation && !value.virtual) .filter(([name]) => !this.shouldOmitField(omit, relationModel, name)) .map(([field]) => [sql.lit(field), this.fieldRef(relationModel, field, subQueryName, false)]) .flatMap((v) => v), @@ -283,6 +283,8 @@ export class SqliteCrudDialect extends BaseCrudDialect value, ); return [sql.lit(field), subJson]; + } else if (fieldDef.virtual) { + return null; } else { return [ sql.lit(field), @@ -291,6 +293,7 @@ export class SqliteCrudDialect extends BaseCrudDialect } } }) + .filter((v) => v !== null) .flatMap((v) => v), ); } From 14f90d657f7736742f20ee5ebe00434cbf81a261 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 14:30:17 -0500 Subject: [PATCH 06/39] feat: exclude virtual fields from submodel field selections in BaseCrudDialect --- packages/orm/src/client/crud/dialects/base-dialect.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index a9dcaa0c3..edb01a2eb 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1148,9 +1148,11 @@ export abstract class BaseCrudDialect { result = result.select((eb) => { const jsonObject: Record> = {}; for (const field of Object.keys(subModel.fields)) { + const fieldDef = subModel.fields[field]; if ( isRelationField(this.schema, subModel.name, field) || - isInheritedField(this.schema, subModel.name, field) + isInheritedField(this.schema, subModel.name, field) || + fieldDef?.virtual ) { continue; } From e4c4e0681d9e21a9cf8a073435edc886eb50871d Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 14:34:54 -0500 Subject: [PATCH 07/39] feat: add support for virtual fields with context and computation functions --- packages/orm/src/client/options.ts | 37 +++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 6439e3996..ba5628dc1 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,7 +1,7 @@ import type { Dialect, Expression, ExpressionBuilder, KyselyConfig } from 'kysely'; import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, SchemaDef } from '../schema'; import type { PrependParameter } from '../utils/type-utils'; -import type { ClientContract, CRUD_EXT } from './contract'; +import type { AuthType, ClientContract, CRUD_EXT } from './contract'; import type { GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { AnyPlugin } from './plugin'; @@ -101,6 +101,14 @@ export type ClientOptions = { computedFields: ComputedFieldsOptions; } : {}) & + (HasVirtualFields extends true + ? { + /** + * Virtual field definitions (computed at runtime in JavaScript). + */ + virtualFields: VirtualFieldsOptions; + } + : {}) & (HasProcedures extends true ? { /** @@ -131,6 +139,33 @@ export type ComputedFieldsOptions = { export type HasComputedFields = string extends GetModels ? false : keyof ComputedFieldsOptions extends never ? false : true; +/** + * Context passed to virtual field functions. + */ +export type VirtualFieldContext = { + /** + * The current authenticated user, if set via `$setAuth()`. + */ + auth: AuthType | undefined; +}; + +/** + * Function that computes a virtual field value at runtime. + */ +export type VirtualFieldFunction = ( + row: Record, + context: VirtualFieldContext, +) => unknown | Promise; + +export type VirtualFieldsOptions = { + [Model in GetModels as 'virtualFields' extends keyof GetModel ? Model : never]: { + [Field in keyof Schema['models'][Model]['virtualFields']]: VirtualFieldFunction; + }; +}; + +export type HasVirtualFields = + string extends GetModels ? false : keyof VirtualFieldsOptions extends never ? false : true; + export type ProceduresOptions = Schema extends { procedures: Record; } From 3838b367935bc6f171f82cd85b80b7604ca085fa Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 14:48:46 -0500 Subject: [PATCH 08/39] feat: enhance ResultProcessor to support async processing of virtual fields --- packages/orm/src/client/result-processor.ts | 68 +++++++++++++++++---- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index fc8ae1938..73d909ed7 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -1,36 +1,44 @@ import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from './constants'; +import type { AuthType } from './contract'; import { getCrudDialect } from './crud/dialects'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; -import type { ClientOptions } from './options'; +import type { ClientOptions, VirtualFieldContext, VirtualFieldFunction } from './options'; import { ensureArray, getField, getIdValues } from './query-utils'; export class ResultProcessor { private dialect: BaseCrudDialect; + private readonly virtualFieldsOptions: Record> | undefined; + constructor( private readonly schema: Schema, options: ClientOptions, ) { this.dialect = getCrudDialect(schema, options); + this.virtualFieldsOptions = (options as any).virtualFields; } - processResult(data: any, model: GetModels, args?: any) { - const result = this.doProcessResult(data, model); + async processResult(data: any, model: GetModels, args?: any, auth?: AuthType) { + const result = await this.doProcessResult(data, model, args, auth); // deal with correcting the reversed order due to negative take this.fixReversedResult(result, model, args); return result; } - private doProcessResult(data: any, model: GetModels) { + private async doProcessResult(data: any, model: GetModels, args?: any, auth?: AuthType) { if (Array.isArray(data)) { - data.forEach((row, i) => (data[i] = this.processRow(row, model))); + await Promise.all( + data.map(async (row, i) => { + data[i] = await this.processRow(row, model, args, auth); + }), + ); return data; } else { - return this.processRow(data, model); + return this.processRow(data, model, args, auth); } } - private processRow(data: any, model: GetModels) { + private async processRow(data: any, model: GetModels, args?: any, auth?: AuthType) { if (!data || typeof data !== 'object') { return data; } @@ -59,7 +67,7 @@ export class ResultProcessor { delete data[key]; continue; } - const processedSubRow = this.processRow(subRow, subModel); + const processedSubRow = await this.processRow(subRow, subModel, args, auth); // merge the sub-row into the main row Object.assign(data, processedSubRow); @@ -82,11 +90,14 @@ export class ResultProcessor { } if (fieldDef.relation) { - data[key] = this.processRelation(value, fieldDef); + data[key] = await this.processRelation(value, fieldDef, args, auth); } else { data[key] = this.processFieldValue(value, fieldDef); } } + + await this.applyVirtualFields(data, model, args, auth); + return data; } @@ -100,7 +111,7 @@ export class ResultProcessor { } } - private processRelation(value: unknown, fieldDef: FieldDef) { + private async processRelation(value: unknown, fieldDef: FieldDef, args?: any, auth?: AuthType) { let relationData = value; if (typeof value === 'string') { // relation can be returned as a JSON string @@ -110,7 +121,42 @@ export class ResultProcessor { return value; } } - return this.doProcessResult(relationData, fieldDef.type as GetModels); + return this.doProcessResult(relationData, fieldDef.type as GetModels, args, auth); + } + + private async applyVirtualFields(data: any, model: GetModels, args?: any, auth?: AuthType) { + if (!data || typeof data !== 'object') { + return; + } + + const modelDef = this.schema.models[model as string]; + if (!modelDef?.virtualFields || !this.virtualFieldsOptions) { + return; + } + + const modelVirtualFieldOptions = this.virtualFieldsOptions[model as string]; + if (!modelVirtualFieldOptions) { + return; + } + + const virtualFieldNames = Object.keys(modelDef.virtualFields); + const selectClause = args?.select; + + // Build the context once for all virtual fields + const context: VirtualFieldContext = { auth }; + + await Promise.all( + virtualFieldNames.map(async (fieldName) => { + // Skip if select clause exists and doesn't include this virtual field + if (selectClause && !selectClause[fieldName]) { + return; + } + + const virtualFn = modelVirtualFieldOptions[fieldName]!; + + data[fieldName] = await virtualFn({ ...data }, context); + }), + ); } private fixReversedResult(data: any, model: GetModels, args: any) { From e5bfd33302bc08e04f74889946a7c98fd5835d7e Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 14:49:08 -0500 Subject: [PATCH 09/39] feat: make result processing in createModelCrudHandler asynchronous --- packages/orm/src/client/client-impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 96cbee3b6..626bb0a79 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -456,7 +456,7 @@ function createModelCrudHandler( } let result: unknown; if (r && postProcess) { - result = resultProcessor.processResult(r, model, args, client.$auth); + result = await resultProcessor.processResult(r, model, args, client.$auth); } else { result = r ?? null; } From 578bb1d9bcb699cccf63e421cebe4af6f105e86e Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 14:51:03 -0500 Subject: [PATCH 10/39] feat: allow exclusion of virtual fields in InputValidator --- packages/orm/src/client/crud/validator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 8cad792e9..dacef399a 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -1236,7 +1236,7 @@ export class InputValidator { return; } const fieldDef = requireField(this.schema, model, field); - if (fieldDef.computed) { + if (fieldDef.computed || fieldDef.virtual) { return; } From 05fbe12a59909dc03d76acf860487fe9c7d6f6ea Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 14:54:21 -0500 Subject: [PATCH 11/39] feat: add support for virtual fields in ModelDef and FieldDef types --- packages/schema/src/schema.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index d98b86f01..dc155177b 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -32,6 +32,7 @@ export type ModelDef = { >; idFields: readonly string[]; computedFields?: Record; + virtualFields?: Record; isDelegate?: boolean; subModels?: readonly string[]; isView?: boolean; @@ -77,6 +78,7 @@ export type FieldDef = { relation?: RelationInfo; foreignKeyFor?: readonly string[]; computed?: boolean; + virtual?: boolean; originModel?: string; isDiscriminator?: boolean; }; @@ -205,7 +207,9 @@ export type ScalarFields< ? Key : FieldIsComputed extends true ? never - : Key]: Key; + : FieldIsVirtual extends true + ? never + : Key]: Key; }; export type ForeignKeyFields> = keyof { @@ -230,6 +234,20 @@ export type RelationFields> = keyof { + [Key in GetModelFields as GetModelField['virtual'] extends true + ? never + : Key]: Key; +}; + +export type NonVirtualNonRelationFields> = keyof { + [Key in GetModelFields as GetModelField['virtual'] extends true + ? never + : GetModelField['relation'] extends object + ? never + : Key]: Key; +}; + export type FieldType< Schema extends SchemaDef, Model extends GetModels, @@ -281,6 +299,12 @@ export type FieldIsComputed< Field extends GetModelFields, > = GetModelField['computed'] extends true ? true : false; +export type FieldIsVirtual< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = GetModelField['virtual'] extends true ? true : false; + export type FieldHasDefault< Schema extends SchemaDef, Model extends GetModels, From 95bf5fb33f1a36050292d33f6af3d000d8ea5bef Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:01:45 -0500 Subject: [PATCH 12/39] fix: update filter logic to use typed whereRecord for id field values --- packages/orm/src/client/crud/operations/base.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index fc75cac9d..91f1ae9f2 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -1306,9 +1306,10 @@ export abstract class BaseOperationHandler { // collect id field/values from the original filter const idFields = requireIdFields(this.schema, model); const filterIdValues: any = {}; + const whereRecord = combinedWhere as Record; for (const key of idFields) { - if (combinedWhere[key] !== undefined && typeof combinedWhere[key] !== 'object') { - filterIdValues[key] = combinedWhere[key]; + if (whereRecord[key] !== undefined && typeof whereRecord[key] !== 'object') { + filterIdValues[key] = whereRecord[key]; } } From c2aad292666d40496ef62f2c98a2a584180ab715 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:04:59 -0500 Subject: [PATCH 13/39] feat: exclude virtual fields from model field creation in SchemaDbPusher --- packages/orm/src/client/helpers/schema-db-pusher.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/helpers/schema-db-pusher.ts b/packages/orm/src/client/helpers/schema-db-pusher.ts index 28701ca1b..7ca8fa192 100644 --- a/packages/orm/src/client/helpers/schema-db-pusher.ts +++ b/packages/orm/src/client/helpers/schema-db-pusher.ts @@ -115,7 +115,7 @@ export class SchemaDbPusher { if (fieldDef.relation) { table = this.addForeignKeyConstraint(table, modelDef.name, fieldName, fieldDef); - } else if (!this.isComputedField(fieldDef)) { + } else if (!this.isComputedField(fieldDef) && !this.isVirtualField(fieldDef)) { table = this.createModelField(table, fieldDef, modelDef); } } @@ -175,6 +175,10 @@ export class SchemaDbPusher { return fieldDef.attributes?.some((a) => a.name === '@computed'); } + private isVirtualField(fieldDef: FieldDef) { + return fieldDef.attributes?.some((a) => a.name === '@virtual'); + } + private addPrimaryKeyConstraint(table: CreateTableBuilder, modelDef: ModelDef) { if (modelDef.idFields.length === 1) { if (Object.values(modelDef.fields).some((f) => f.id)) { From 4d918758526c574ddf9de5fe161f34035beb1701 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:06:07 -0500 Subject: [PATCH 14/39] feat: add support for creating virtual fields in TsSchemaGenerator --- packages/sdk/src/ts-schema-generator.ts | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 413131269..c7c548b50 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -427,6 +427,14 @@ export class TsSchemaGenerator { ); } + const virtualFields = dm.fields.filter((f) => hasAttribute(f, '@virtual')); + + if (virtualFields.length > 0) { + fields.push( + ts.factory.createPropertyAssignment('virtualFields', this.createVirtualFieldsObject(virtualFields)), + ); + } + return ts.factory.createObjectLiteralExpression(fields, true); } @@ -522,6 +530,52 @@ export class TsSchemaGenerator { ); } + private createVirtualFieldsObject(fields: DataField[]) { + return ts.factory.createObjectLiteralExpression( + fields.map((field) => + ts.factory.createMethodDeclaration( + undefined, + undefined, + field.name, + undefined, + undefined, + [ + // parameter: `_row: Record` + ts.factory.createParameterDeclaration( + undefined, + undefined, + '_row', + undefined, + ts.factory.createTypeReferenceNode('Record', [ + ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), + ]), + undefined, + ), + ], + // Return type: T | Promise + ts.factory.createUnionTypeNode([ + ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)), + ts.factory.createTypeReferenceNode('Promise', [ + ts.factory.createTypeReferenceNode(this.mapFieldTypeToTSType(field.type)), + ]), + ]), + ts.factory.createBlock( + [ + ts.factory.createThrowStatement( + ts.factory.createNewExpression(ts.factory.createIdentifier('Error'), undefined, [ + ts.factory.createStringLiteral('This is a stub for virtual field'), + ]), + ), + ], + true, + ), + ), + ), + true, + ); + } + private createUpdatedAtObject(ignoreArg: AttributeArg) { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment('ignore', ts.factory.createArrayLiteralExpression( @@ -684,6 +738,10 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('computed', ts.factory.createTrue())); } + if (hasAttribute(field, '@virtual')) { + objectFields.push(ts.factory.createPropertyAssignment('virtual', ts.factory.createTrue())); + } + if (isDataModel(field.type.reference?.ref)) { objectFields.push(ts.factory.createPropertyAssignment('relation', this.createRelationObject(field))); } From a850776343417dbe623eee3f16323a9a98d50fe1 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:06:31 -0500 Subject: [PATCH 15/39] feat: skip virtual fields in model generation in PrismaSchemaGenerator --- packages/sdk/src/prisma/prisma-schema-generator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 553658add..b5e94c75d 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -187,8 +187,8 @@ export class PrismaSchemaGenerator { const model = decl.isView ? prisma.addView(decl.name) : prisma.addModel(decl.name); const allFields = getAllFields(decl, true); for (const field of allFields) { - if (ModelUtils.hasAttribute(field, '@computed')) { - continue; // skip computed fields + if (ModelUtils.hasAttribute(field, '@computed') || ModelUtils.hasAttribute(field, '@virtual')) { + continue; // skip computed and virtual fields } // exclude non-id fields inherited from delegate if (ModelUtils.isIdField(field, decl) || !this.isInheritedFromDelegate(field, decl)) { From 019619d06b9c086b2e4d29a28510c769d65afe54 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:07:22 -0500 Subject: [PATCH 16/39] feat: add comprehensive tests for virtual fields functionality --- .../e2e/orm/client-api/virtual-fields.test.ts | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 tests/e2e/orm/client-api/virtual-fields.test.ts diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts new file mode 100644 index 000000000..0827c6077 --- /dev/null +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -0,0 +1,503 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Virtual fields tests', () => { + it('works with sync virtual fields', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + }), + ).resolves.toMatchObject({ + fullName: 'Alex Smith', + }); + + await expect( + db.user.findUnique({ + where: { id: 1 }, + }), + ).resolves.toMatchObject({ + fullName: 'Alex Smith', + }); + + await expect( + db.user.findMany(), + ).resolves.toEqual([ + expect.objectContaining({ + fullName: 'Alex Smith', + }), + ]); + }); + + it('works with async virtual fields', async () => { + const db = await createTestClient( + ` +model Blob { + id Int @id @default(autoincrement()) + blobName String + sasUrl String @virtual +} +`, + { + virtualFields: { + Blob: { + sasUrl: async (row: any) => { + // Simulate async operation (e.g., generating SAS token) + await new Promise((resolve) => setTimeout(resolve, 10)); + return `https://storage.example.com/${row.blobName}?sas=token123`; + }, + }, + }, + } as any, + ); + + await expect( + db.blob.create({ + data: { id: 1, blobName: 'my-file.pdf' }, + }), + ).resolves.toMatchObject({ + sasUrl: 'https://storage.example.com/my-file.pdf?sas=token123', + }); + }); + + it('respects select clause - includes virtual field when selected', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + }); + + // When selecting the virtual field explicitly, it should be computed + await expect( + db.user.findUnique({ + where: { id: 1 }, + select: { id: true, fullName: true }, + }), + ).resolves.toMatchObject({ + id: 1, + fullName: 'Alex Smith', + }); + }); + + it('respects select clause - skips virtual field when not selected', async () => { + let virtualFieldCalled = false; + + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => { + virtualFieldCalled = true; + return `${row.firstName} ${row.lastName}`; + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + }); + + virtualFieldCalled = false; + + // When NOT selecting the virtual field, it should NOT be computed + const result = await db.user.findUnique({ + where: { id: 1 }, + select: { id: true, firstName: true }, + }); + + expect(result).toMatchObject({ + id: 1, + firstName: 'Alex', + }); + expect(result).not.toHaveProperty('fullName'); + expect(virtualFieldCalled).toBe(false); + }); + + it('works with optional virtual fields', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + computedValue String? @virtual +} +`, + { + virtualFields: { + User: { + computedValue: () => null, + }, + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, name: 'Alex' }, + }), + ).resolves.toMatchObject({ + computedValue: null, + }); + }); + + it('works with relations - virtual field in nested result', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + virtualFields: { + User: { + displayName: (row: any) => `@${row.name}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, + }); + + await expect( + db.post.findFirst({ + include: { author: true }, + }), + ).resolves.toMatchObject({ + author: expect.objectContaining({ displayName: '@alex' }), + }); + }); + + it('is typed correctly for non-optional fields', async () => { + await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual +} +`, + { + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/orm'; +import { schema } from './schema'; + +async function main() { + const client = new ZenStackClient(schema, { + dialect: {} as any, + virtualFields: { + User: { + displayName: (row) => \`@\${row.name}\`, + }, + } + }); + + const user = await client.user.create({ + data: { id: 1, name: 'Alex' } + }); + console.log(user.displayName); + // @ts-expect-error - virtual field should not be nullable + user.displayName = null; +} + +main(); +`, + }, + }, + ); + }); + + it('virtual fields are excluded from where and orderBy types', async () => { + await createTestClient( + ` +model Post { + id Int @id @default(autoincrement()) + title String + canEdit Boolean @virtual +} +`, + { + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/orm'; +import { schema } from './schema'; + +async function main() { + const client = new ZenStackClient(schema, { + dialect: {} as any, + virtualFields: { + Post: { + canEdit: () => true, + }, + } + }); + + // Virtual fields should be in the result type + const post = await client.post.findFirst(); + const canEdit: boolean | undefined = post?.canEdit; + + // @ts-expect-error - virtual field should not be allowed in where + await client.post.findMany({ where: { canEdit: true } }); + + // @ts-expect-error - virtual field should not be allowed in orderBy + await client.post.findMany({ orderBy: { canEdit: 'asc' } }); + + // Regular fields should still work + await client.post.findMany({ where: { title: 'test' } }); + await client.post.findMany({ orderBy: { title: 'asc' } }); +} + +main(); +`, + }, + }, + ); + }); + + it('receives auth context in virtual field function', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String +} + +model Post { + id Int @id @default(autoincrement()) + title String + authorId Int + canEdit Boolean @virtual +} +`, + { + virtualFields: { + Post: { + canEdit: (row: any, { auth }: any) => { + // User can edit if they are the author + return auth?.id === row.authorId; + }, + }, + }, + } as any, + ); + + // Create a post + await db.post.create({ + data: { id: 1, title: 'My Post', authorId: 1 }, + }); + + // Without auth, canEdit should be false + const postWithoutAuth = await db.post.findUnique({ where: { id: 1 } }); + expect(postWithoutAuth?.canEdit).toBe(false); + + // With auth as the author, canEdit should be true + const dbWithAuth = db.$setAuth({ id: 1 }); + const postWithAuth = await dbWithAuth.post.findUnique({ where: { id: 1 } }); + expect(postWithAuth?.canEdit).toBe(true); + + // With auth as different user, canEdit should be false + const dbWithOtherAuth = db.$setAuth({ id: 2 }); + const postWithOtherAuth = await dbWithOtherAuth.post.findUnique({ where: { id: 1 } }); + expect(postWithOtherAuth?.canEdit).toBe(false); + }); + + it('auth context works with nested relations', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int + isOwnPost Boolean @virtual +} +`, + { + virtualFields: { + Post: { + isOwnPost: (row: any, { auth }: any) => auth?.id === row.authorId, + }, + }, + } as any, + ); + + await db.user.create({ + data: { + id: 1, + name: 'Alex', + posts: { create: { id: 1, title: 'Post1' } }, + }, + }); + + // Query posts through user relation with auth set + const dbWithAuth = db.$setAuth({ id: 1 }); + const user = await dbWithAuth.user.findUnique({ + where: { id: 1 }, + include: { posts: true }, + }); + + expect(user?.posts[0]?.isOwnPost).toBe(true); + + // With different auth + const dbWithOtherAuth = db.$setAuth({ id: 2 }); + const userOther = await dbWithOtherAuth.user.findUnique({ + where: { id: 1 }, + include: { posts: true }, + }); + + expect(userOther?.posts[0]?.isOwnPost).toBe(false); + }); + + it('works with relations and virtual fields on PostgreSQL (lateral join dialect)', async () => { + // This test specifically targets the lateral join dialect used by PostgreSQL + // to ensure virtual fields are properly excluded from SQL queries when + // including relations with default select (no explicit select clause) + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + provider: 'postgresql', + virtualFields: { + User: { + displayName: (row: any) => `@${row.name}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, + }); + + // Include relation with default select - this triggers buildRelationObjectArgs + // in the lateral join dialect which must properly exclude virtual fields + await expect( + db.post.findFirst({ + include: { author: true }, + }), + ).resolves.toMatchObject({ + title: 'Post1', + author: expect.objectContaining({ + name: 'alex', + displayName: '@alex', + }), + }); + }); + + it('works with virtual fields in delegate sub-models', async () => { + // This test ensures virtual fields are properly excluded when building + // delegate descendant JSON objects in buildSelectAllFields + const db = await createTestClient( + ` +model Content { + id Int @id @default(autoincrement()) + title String + contentType String + @@delegate(contentType) +} + +model Post extends Content { + body String + preview String @virtual +} +`, + { + virtualFields: { + Post: { + preview: (row: any) => row.body?.substring(0, 50) ?? '', + }, + }, + } as any, + ); + + await db.post.create({ + data: { id: 1, title: 'My Post', body: 'This is the full body content of the post' }, + }); + + // Query the base Content model - this triggers buildSelectAllFields which + // builds JSON for delegate descendants (Post) and must exclude virtual fields + await expect( + db.content.findFirst({ + where: { id: 1 }, + }), + ).resolves.toMatchObject({ + title: 'My Post', + body: 'This is the full body content of the post', + preview: 'This is the full body content of the post', + }); + }); +}); From 9bbd3652e367c01aa901b6af9eb8b08e5c3e6300 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:13:28 -0500 Subject: [PATCH 17/39] feat: add omit clause support for virtual fields in ResultProcessor --- packages/orm/src/client/result-processor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 73d909ed7..f20789d07 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -141,6 +141,7 @@ export class ResultProcessor { const virtualFieldNames = Object.keys(modelDef.virtualFields); const selectClause = args?.select; + const omitClause = args?.omit; // Build the context once for all virtual fields const context: VirtualFieldContext = { auth }; @@ -152,6 +153,11 @@ export class ResultProcessor { return; } + // Skip if omit clause includes this virtual field + if (omitClause?.[fieldName]) { + return; + } + const virtualFn = modelVirtualFieldOptions[fieldName]!; data[fieldName] = await virtualFn({ ...data }, context); From fd1d9868a8bb32fe6992b3befd6504c49aba6615 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 15:14:28 -0500 Subject: [PATCH 18/39] feat: add tests for update, upsert, and multiple virtual fields functionality --- .../e2e/orm/client-api/virtual-fields.test.ts | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 0827c6077..2f5598e75 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -500,4 +500,269 @@ model Post extends Content { preview: 'This is the full body content of the post', }); }); + + it('works with update operations', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + }); + + // Update should return the virtual field + const updated = await db.user.update({ + where: { id: 1 }, + data: { firstName: 'John' }, + }); + + expect(updated.fullName).toBe('John Smith'); + }); + + it('works with upsert operations', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + // Upsert create path + const created = await db.user.upsert({ + where: { id: 1 }, + create: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + update: { firstName: 'John' }, + }); + + expect(created.fullName).toBe('Alex Smith'); + + // Upsert update path + const updated = await db.user.upsert({ + where: { id: 1 }, + create: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + update: { firstName: 'John' }, + }); + + expect(updated.fullName).toBe('John Smith'); + }); + + it('works with multiple virtual fields on same model', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String + fullName String @virtual + displayEmail String @virtual + initials String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => `${row.firstName} ${row.lastName}`, + displayEmail: (row: any) => row.email.toLowerCase(), + initials: (row: any) => `${row.firstName[0]}${row.lastName[0]}`.toUpperCase(), + }, + }, + } as any, + ); + + const user = await db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith', email: 'ALEX@EXAMPLE.COM' }, + }); + + expect(user.fullName).toBe('Alex Smith'); + expect(user.displayEmail).toBe('alex@example.com'); + expect(user.initials).toBe('AS'); + }); + + it('works with relations and virtual fields on MySQL (lateral join dialect)', async () => { + // This test targets the lateral join dialect used by MySQL + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + provider: 'mysql', + virtualFields: { + User: { + displayName: (row: any) => `@${row.name}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, + }); + + await expect( + db.post.findFirst({ + include: { author: true }, + }), + ).resolves.toMatchObject({ + title: 'Post1', + author: expect.objectContaining({ + name: 'alex', + displayName: '@alex', + }), + }); + }); + + it('virtual field can access included relation data', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int + authorDisplay String @virtual +} +`, + { + virtualFields: { + Post: { + authorDisplay: (row: any) => { + // Virtual field can access included relation data + if (row.author) { + return `by ${row.author.name}`; + } + return `by user #${row.authorId}`; + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'Alex', posts: { create: { title: 'My Post' } } }, + }); + + // Without including author + const postWithoutAuthor = await db.post.findFirst(); + expect(postWithoutAuthor?.authorDisplay).toBe('by user #1'); + + // With including author + const postWithAuthor = await db.post.findFirst({ + include: { author: true }, + }); + expect(postWithAuthor?.authorDisplay).toBe('by Alex'); + }); + + it('respects omit clause - skips virtual field computation', async () => { + let virtualFieldCalled = false; + + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual +} +`, + { + virtualFields: { + User: { + displayName: (row: any) => { + virtualFieldCalled = true; + return `@${row.name}`; + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'Alex' }, + }); + + virtualFieldCalled = false; + + // When omitting the virtual field, it should NOT be computed + const result = await db.user.findUnique({ + where: { id: 1 }, + omit: { displayName: true }, + }); + + expect(result).toMatchObject({ id: 1, name: 'Alex' }); + expect(result).not.toHaveProperty('displayName'); + expect(virtualFieldCalled).toBe(false); + }); + + it('propagates errors from virtual field functions', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + problematic String @virtual +} +`, + { + virtualFields: { + User: { + problematic: () => { + throw new Error('Virtual field computation failed'); + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'Alex' }, + }); + + // The error should propagate + await expect(db.user.findUnique({ where: { id: 1 } })).rejects.toThrow( + 'Virtual field computation failed', + ); + }); }); From fcbcd1d773774ec65ceb2ba372cfbc846584b302 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:12:05 -0500 Subject: [PATCH 19/39] feat: add support for virtual fields in BaseOperationHandler --- packages/orm/src/client/crud/operations/base.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 91f1ae9f2..7e3376010 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -2555,6 +2555,9 @@ export abstract class BaseOperationHandler { const computedFields = Object.values(modelDef.fields) .filter((f) => f.computed) .map((f) => f.name); + const virtualFields = Object.values(modelDef.fields) + .filter((f) => f.virtual) + .map((f) => f.name); const allFieldsSelected: string[] = []; @@ -2574,8 +2577,12 @@ export abstract class BaseOperationHandler { ); } - if (allFieldsSelected.some((f) => relationFields.includes(f) || computedFields.includes(f))) { - // relation or computed field selected, need read back + if ( + allFieldsSelected.some( + (f) => relationFields.includes(f) || computedFields.includes(f) || virtualFields.includes(f), + ) + ) { + // relation, computed, or virtual field selected - need read back return { needReadBack: true, selectedFields: undefined }; } else { return { needReadBack: false, selectedFields: allFieldsSelected }; From a3515584574bd4f58f64051fe21d3409e55e2927 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:22:05 -0500 Subject: [PATCH 20/39] feat: skip virtual fields in scalar field selection within BaseOperationHandler --- packages/orm/src/client/crud/operations/base.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 7e3376010..293d19bf2 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -335,8 +335,10 @@ export abstract class BaseOperationHandler { const fieldDef = this.requireField(model, field); if (!fieldDef.relation) { - // scalar field - result = this.dialect.buildSelectField(result, model, parentAlias, field); + // scalar field - skip virtual fields as they're computed at runtime + if (!fieldDef.virtual) { + result = this.dialect.buildSelectField(result, model, parentAlias, field); + } } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw createInternalError(`Field "${field}" does not support filtering`, model); From 612ca5847b4baeaadde071a0a6e4b885bb6d2a8b Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:22:18 -0500 Subject: [PATCH 21/39] feat: extract relation-specific args for nested processing in ResultProcessor --- packages/orm/src/client/result-processor.ts | 24 ++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index f20789d07..3687bda90 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -90,7 +90,9 @@ export class ResultProcessor { } if (fieldDef.relation) { - data[key] = await this.processRelation(value, fieldDef, args, auth); + // Extract relation-specific args (select/omit/include) for nested processing + const relationArgs = this.getRelationArgs(args, key); + data[key] = await this.processRelation(value, fieldDef, relationArgs, auth); } else { data[key] = this.processFieldValue(value, fieldDef); } @@ -111,6 +113,26 @@ export class ResultProcessor { } } + private getRelationArgs(args: any, relationField: string): any { + if (!args) { + return undefined; + } + + // Check include clause for relation-specific args + const includeArgs = args.include?.[relationField]; + if (includeArgs && typeof includeArgs === 'object') { + return includeArgs; + } + + // Check select clause for relation-specific args + const selectArgs = args.select?.[relationField]; + if (selectArgs && typeof selectArgs === 'object') { + return selectArgs; + } + + return undefined; + } + private async processRelation(value: unknown, fieldDef: FieldDef, args?: any, auth?: AuthType) { let relationData = value; if (typeof value === 'string') { From 20a69315b114506417dbda86822f708f45981bd0 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:22:24 -0500 Subject: [PATCH 22/39] feat: enhance virtual fields tests for nested select and omit clauses --- .../e2e/orm/client-api/virtual-fields.test.ts | 154 ++++++++++++------ 1 file changed, 108 insertions(+), 46 deletions(-) diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 2f5598e75..0569d6445 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -101,10 +101,11 @@ model User { }); // When selecting the virtual field explicitly, it should be computed + // Note: User must select the fields that the virtual field depends on await expect( db.user.findUnique({ where: { id: 1 }, - select: { id: true, fullName: true }, + select: { id: true, firstName: true, lastName: true, fullName: true }, }), ).resolves.toMatchObject({ id: 1, @@ -604,50 +605,8 @@ model User { expect(user.initials).toBe('AS'); }); - it('works with relations and virtual fields on MySQL (lateral join dialect)', async () => { - // This test targets the lateral join dialect used by MySQL - const db = await createTestClient( - ` -model User { - id Int @id @default(autoincrement()) - name String - displayName String @virtual - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int -} -`, - { - provider: 'mysql', - virtualFields: { - User: { - displayName: (row: any) => `@${row.name}`, - }, - }, - } as any, - ); - - await db.user.create({ - data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, - }); - - await expect( - db.post.findFirst({ - include: { author: true }, - }), - ).resolves.toMatchObject({ - title: 'Post1', - author: expect.objectContaining({ - name: 'alex', - displayName: '@alex', - }), - }); - }); + // Note: MySQL lateral join dialect is tested via PostgreSQL test since both use the same + // lateral join implementation. The PostgreSQL test covers the lateral join dialect behavior. it('virtual field can access included relation data', async () => { const db = await createTestClient( @@ -756,13 +715,116 @@ model User { } as any, ); + // Create without selecting the virtual field (to avoid triggering error during create) await db.user.create({ data: { id: 1, name: 'Alex' }, + select: { id: true }, }); - // The error should propagate + // The error should propagate during read when virtual field is computed await expect(db.user.findUnique({ where: { id: 1 } })).rejects.toThrow( 'Virtual field computation failed', ); }); + + it('respects nested select clause for virtual fields in relations', async () => { + let virtualFieldCalled = false; + + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + virtualFields: { + User: { + displayName: (row: any) => { + virtualFieldCalled = true; + return `@${row.name}`; + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, + }); + + virtualFieldCalled = false; + + // When nested select includes the virtual field, it should be computed + const post = await db.post.findFirst({ + select: { + title: true, + author: { + select: { name: true, displayName: true }, + }, + }, + }); + + expect(post?.author?.displayName).toBe('@alex'); + expect(virtualFieldCalled).toBe(true); + }); + + it('respects nested omit clause for virtual fields in relations', async () => { + let virtualFieldCalled = false; + + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} +`, + { + virtualFields: { + User: { + displayName: (row: any) => { + virtualFieldCalled = true; + return `@${row.name}`; + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, + }); + + virtualFieldCalled = false; + + // When nested omit excludes the virtual field, it should NOT be computed + const post = await db.post.findFirst({ + include: { + author: { + omit: { displayName: true }, + }, + }, + }); + + expect(post?.author).not.toHaveProperty('displayName'); + expect(virtualFieldCalled).toBe(false); + }); }); From b8a64fb712d7add0b77f253b04fe996dd33339f4 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:28:40 -0500 Subject: [PATCH 23/39] feat: reject virtual fields in update data within InputValidator --- .../orm/src/client/crud/validator/index.ts | 3 ++ .../e2e/orm/client-api/virtual-fields.test.ts | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index dacef399a..72412c42b 100644 --- a/packages/orm/src/client/crud/validator/index.ts +++ b/packages/orm/src/client/crud/validator/index.ts @@ -1557,6 +1557,9 @@ export class InputValidator { return; } const fieldDef = requireField(this.schema, model, field); + if (fieldDef.computed || fieldDef.virtual) { + return; + } if (fieldDef.relation) { if (withoutRelationFields) { diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 0569d6445..9db0bb8ea 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -827,4 +827,37 @@ model Post { expect(post?.author).not.toHaveProperty('displayName'); expect(virtualFieldCalled).toBe(false); }); + + it('rejects virtual fields in update data', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + displayName String @virtual +} +`, + { + virtualFields: { + User: { + displayName: (row: any) => `@${row.name}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'Alex' }, + select: { id: true }, + }); + + // Virtual fields should not be allowed in update data + // The validator should reject it as an unrecognized key + await expect( + db.user.update({ + where: { id: 1 }, + data: { displayName: 'should fail' } as any, + }), + ).rejects.toThrow(/unrecognized.*key|displayName/i); + }); }); From f70337384d766892c25bbf0d44b63d3fea71a79c Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:31:12 -0500 Subject: [PATCH 24/39] feat: add real-world e-commerce schema tests for virtual fields --- .../e2e/orm/client-api/virtual-fields.test.ts | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 9db0bb8ea..a8f5a7846 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -860,4 +860,158 @@ model User { }), ).rejects.toThrow(/unrecognized.*key|displayName/i); }); + + // Real-world schema test: E-commerce-like schema with practical virtual fields + it('works with real-world e-commerce schema', async () => { + const db = await createTestClient( + ` +// Represents a typical e-commerce application schema +model User { + id String @id @default(cuid()) + email String @unique + firstName String? + lastName String? + createdAt DateTime @default(now()) + orders Order[] + + // Virtual: computed display name for UI + displayName String @virtual +} + +model Product { + id String @id @default(cuid()) + name String + description String? + priceInCents Int + currency String @default("USD") + inStock Boolean @default(true) + orderItems OrderItem[] + + // Virtual: formatted price for display (e.g., "$19.99") + formattedPrice String @virtual +} + +model Order { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id]) + status String @default("pending") + createdAt DateTime @default(now()) + items OrderItem[] + + // Virtual: order summary for listing views + orderSummary String @virtual +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id]) + productId String + product Product @relation(fields: [productId], references: [id]) + quantity Int +} +`, + { + virtualFields: { + User: { + displayName: (row: any) => { + if (row.firstName && row.lastName) { + return `${row.firstName} ${row.lastName}`; + } + if (row.firstName) return row.firstName; + return row.email?.split('@')[0] ?? 'Anonymous'; + }, + }, + Product: { + formattedPrice: (row: any) => { + const dollars = (row.priceInCents / 100).toFixed(2); + const symbol = row.currency === 'EUR' ? '€' : '$'; + return `${symbol}${dollars}`; + }, + }, + Order: { + orderSummary: (row: any) => { + const itemCount = row.items?.length ?? 0; + const statusLabel = row.status === 'pending' ? 'Pending' : 'Completed'; + return `${statusLabel} - ${itemCount} item(s)`; + }, + }, + }, + } as any, + ); + + // Create test data + const user = await db.user.create({ + data: { + id: 'user-1', + email: 'john.doe@example.com', + firstName: 'John', + lastName: 'Doe', + }, + }); + expect(user.displayName).toBe('John Doe'); + + const product = await db.product.create({ + data: { + id: 'prod-1', + name: 'TypeScript Handbook', + priceInCents: 2999, + currency: 'USD', + }, + }); + expect(product.formattedPrice).toBe('$29.99'); + + // Create order with items and verify virtual field with relation data + await db.order.create({ + data: { + id: 'order-1', + userId: 'user-1', + status: 'pending', + items: { + create: [{ id: 'item-1', productId: 'prod-1', quantity: 2 }], + }, + }, + select: { id: true }, + }); + + // Query order with items included - virtual field should use relation data + const order = await db.order.findUnique({ + where: { id: 'order-1' }, + include: { items: true }, + }); + expect(order?.orderSummary).toBe('Pending - 1 item(s)'); + + // Query user with orders - nested virtual fields should work + const userWithOrders = await db.user.findUnique({ + where: { id: 'user-1' }, + include: { + orders: { + include: { items: true }, + }, + }, + }); + expect(userWithOrders?.displayName).toBe('John Doe'); + expect(userWithOrders?.orders[0]?.orderSummary).toBe('Pending - 1 item(s)'); + + // Test user with only email (no name) - fallback logic + const userEmailOnly = await db.user.create({ + data: { + id: 'user-2', + email: 'anonymous@example.com', + }, + }); + expect(userEmailOnly.displayName).toBe('anonymous'); + + // Test product with EUR currency + const euroProduct = await db.product.create({ + data: { + id: 'prod-2', + name: 'Euro Product', + priceInCents: 1999, + currency: 'EUR', + }, + }); + expect(euroProduct.formattedPrice).toBe('€19.99'); + }); }); From d735ecd7884a96158baf466f34b00e9d82a8aa59 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 16:37:13 -0500 Subject: [PATCH 25/39] chore: add function comments --- packages/orm/src/client/result-processor.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 3687bda90..203953216 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -113,6 +113,9 @@ export class ResultProcessor { } } + /** + * Extracts relation-specific args (select/omit/include) for nested processing. + * */ private getRelationArgs(args: any, relationField: string): any { if (!args) { return undefined; @@ -146,6 +149,9 @@ export class ResultProcessor { return this.doProcessResult(relationData, fieldDef.type as GetModels, args, auth); } + /** + * Computes virtual fields at runtime using functions from client options. + * */ private async applyVirtualFields(data: any, model: GetModels, args?: any, auth?: AuthType) { if (!data || typeof data !== 'object') { return; From 5d5b784e1c54a10a44f703d0147f958ec1ded014 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 17:44:17 -0500 Subject: [PATCH 26/39] feat: enforce error when selecting only virtual fields in query --- .../orm/src/client/crud/operations/base.ts | 8 +++++ .../e2e/orm/client-api/virtual-fields.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 293d19bf2..647ec92cf 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -322,6 +322,7 @@ export abstract class BaseOperationHandler { parentAlias: string, ) { let result = query; + let hasNonVirtualField = false; for (const [field, payload] of Object.entries(selectOrInclude)) { if (!payload) { @@ -330,6 +331,7 @@ export abstract class BaseOperationHandler { if (field === '_count') { result = this.buildCountSelection(result, model, parentAlias, payload); + hasNonVirtualField = true; continue; } @@ -338,6 +340,7 @@ export abstract class BaseOperationHandler { // scalar field - skip virtual fields as they're computed at runtime if (!fieldDef.virtual) { result = this.dialect.buildSelectField(result, model, parentAlias, field); + hasNonVirtualField = true; } } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { @@ -355,9 +358,14 @@ export abstract class BaseOperationHandler { // regular relation result = this.dialect.buildRelationSelection(result, model, field, parentAlias, payload); } + hasNonVirtualField = true; } } + if (!hasNonVirtualField) { + throw createInternalError('Cannot select only virtual fields', model); + } + return result; } diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index a8f5a7846..315c721b0 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -157,6 +157,39 @@ model User { expect(virtualFieldCalled).toBe(false); }); + it('throws error when selecting only virtual fields', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: (row: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + select: { id: true }, + }); + + // Selecting only virtual fields should throw a clear error + await expect( + db.user.findUnique({ + where: { id: 1 }, + select: { fullName: true }, + }), + ).rejects.toThrow(/cannot select only virtual fields/i); + }); + it('works with optional virtual fields', async () => { const db = await createTestClient( ` From 09a93fb603dc495258f8d18f8fe1030e692183f1 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 21:29:19 -0500 Subject: [PATCH 27/39] feat: replace internal error with invalid input error for selecting only virtual fields --- packages/orm/src/client/crud/operations/base.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 647ec92cf..70f0b7618 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -363,7 +363,7 @@ export abstract class BaseOperationHandler { } if (!hasNonVirtualField) { - throw createInternalError('Cannot select only virtual fields', model); + throw createInvalidInputError('Cannot select only virtual fields', model); } return result; From 2dfad55437df0360071ee26c4c7cbcb60f0c0ca9 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 21:35:34 -0500 Subject: [PATCH 28/39] feat: exclude virtual fields from groupBy and aggregate types in client API --- packages/orm/src/client/crud-types.ts | 9 ++-- .../e2e/orm/client-api/virtual-fields.test.ts | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 0bbe628a2..338d8114a 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -8,6 +8,7 @@ import type { FieldIsDelegateDiscriminator, FieldIsDelegateRelation, FieldIsRelation, + FieldIsVirtual, FieldType, ForeignKeyFields, GetEnum, @@ -1591,7 +1592,7 @@ export type CountArgs> }; type CountAggregateInput> = { - [Key in NonRelationFields]?: true; + [Key in NonVirtualNonRelationFields]?: true; } & { _all?: true }; export type CountResult, Args> = Args extends { @@ -1678,7 +1679,9 @@ type MinMaxInput, Valu ? never : FieldIsRelation extends true ? never - : Key]?: ValueType; + : FieldIsVirtual extends true + ? never + : Key]?: ValueType; }; export type AggregateResult, Args> = (Args extends { @@ -1755,7 +1758,7 @@ export type GroupByArgs | NonEmptyArray>; + by: NonVirtualNonRelationFields | NonEmptyArray>; /** * Filter conditions for the grouped records diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 315c721b0..19cf100fc 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -336,6 +336,57 @@ async function main() { await client.post.findMany({ orderBy: { title: 'asc' } }); } +main(); +`, + }, + }, + ); + }); + + it('virtual fields are excluded from groupBy and aggregate types', async () => { + await createTestClient( + ` +model Post { + id Int @id @default(autoincrement()) + title String + views Int + computedScore Int @virtual +} +`, + { + extraSourceFiles: { + main: ` +import { ZenStackClient } from '@zenstackhq/orm'; +import { schema } from './schema'; + +async function main() { + const client = new ZenStackClient(schema, { + dialect: {} as any, + virtualFields: { + Post: { + computedScore: () => 100, + }, + } + }); + + // @ts-expect-error - virtual field should not be allowed in groupBy.by + await client.post.groupBy({ by: ['computedScore'] }); + + // @ts-expect-error - virtual field should not be allowed in _count select + await client.post.count({ select: { computedScore: true } }); + + // @ts-expect-error - virtual field should not be allowed in _min + await client.post.aggregate({ _min: { computedScore: true } }); + + // @ts-expect-error - virtual field should not be allowed in _max + await client.post.aggregate({ _max: { computedScore: true } }); + + // Regular fields should still work in all these operations + await client.post.groupBy({ by: ['title'] }); + await client.post.count({ select: { title: true } }); + await client.post.aggregate({ _min: { views: true }, _max: { views: true } }); +} + main(); `, }, From c4d5883eee02eb02e64cd7ea13f37b1eab283a85 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 21:59:27 -0500 Subject: [PATCH 29/39] feat: exclude virtual fields from aggregate operations in client API --- packages/orm/src/client/crud-types.ts | 4 +++- tests/e2e/orm/client-api/virtual-fields.test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 338d8114a..6ee1a61f9 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1666,7 +1666,9 @@ type NumericFields> = | 'Decimal' ? FieldIsArray extends true ? never - : Key + : FieldIsVirtual extends true + ? never + : Key : never]: GetModelField; }; diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 19cf100fc..afae15397 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -381,10 +381,16 @@ async function main() { // @ts-expect-error - virtual field should not be allowed in _max await client.post.aggregate({ _max: { computedScore: true } }); + // @ts-expect-error - virtual field should not be allowed in _sum + await client.post.aggregate({ _sum: { computedScore: true } }); + + // @ts-expect-error - virtual field should not be allowed in _avg + await client.post.aggregate({ _avg: { computedScore: true } }); + // Regular fields should still work in all these operations await client.post.groupBy({ by: ['title'] }); await client.post.count({ select: { title: true } }); - await client.post.aggregate({ _min: { views: true }, _max: { views: true } }); + await client.post.aggregate({ _min: { views: true }, _max: { views: true }, _sum: { views: true }, _avg: { views: true } }); } main(); From c3787f4eb6c5b08d1818a1ceecf77ea3d6447549 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Wed, 4 Feb 2026 22:16:22 -0500 Subject: [PATCH 30/39] feat: prevent virtual fields from being used in create and update operations --- packages/orm/src/client/crud-types.ts | 4 ++-- tests/e2e/orm/client-api/virtual-fields.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 6ee1a61f9..d536b49e2 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -1359,7 +1359,7 @@ type UpdateScalarInput< Without extends string = never, > = Omit< { - [Key in NonRelationFields as FieldIsDelegateDiscriminator extends true + [Key in NonVirtualNonRelationFields as FieldIsDelegateDiscriminator extends true ? // discriminator fields cannot be assigned never : Key]?: ScalarUpdatePayload; @@ -1370,7 +1370,7 @@ type UpdateScalarInput< type ScalarUpdatePayload< Schema extends SchemaDef, Model extends GetModels, - Field extends NonRelationFields, + Field extends NonVirtualNonRelationFields, > = | ScalarFieldMutationPayload | (Field extends NumericFields diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index afae15397..d642efd4c 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -331,9 +331,17 @@ async function main() { // @ts-expect-error - virtual field should not be allowed in orderBy await client.post.findMany({ orderBy: { canEdit: 'asc' } }); + // @ts-expect-error - virtual field should not be allowed in create data + await client.post.create({ data: { title: 'test', canEdit: true } }); + + // @ts-expect-error - virtual field should not be allowed in update data + await client.post.update({ where: { id: 1 }, data: { canEdit: false } }); + // Regular fields should still work await client.post.findMany({ where: { title: 'test' } }); await client.post.findMany({ orderBy: { title: 'asc' } }); + await client.post.create({ data: { title: 'test' } }); + await client.post.update({ where: { id: 1 }, data: { title: 'updated' } }); } main(); From dbe9e06e6063989b18d924e61048bf791cb90139 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 20:36:41 -0500 Subject: [PATCH 31/39] feat: use context --- packages/orm/src/client/client-impl.ts | 2 +- packages/orm/src/client/options.ts | 12 ++++--- packages/orm/src/client/result-processor.ts | 37 +++++++++++---------- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 626bb0a79..054f9b47c 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -456,7 +456,7 @@ function createModelCrudHandler( } let result: unknown; if (r && postProcess) { - result = await resultProcessor.processResult(r, model, args, client.$auth); + result = await resultProcessor.processResult(r, model, args, client); } else { result = r ?? null; } diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index ba5628dc1..36bd80353 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,7 +1,7 @@ import type { Dialect, Expression, ExpressionBuilder, KyselyConfig } from 'kysely'; import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, SchemaDef } from '../schema'; import type { PrependParameter } from '../utils/type-utils'; -import type { AuthType, ClientContract, CRUD_EXT } from './contract'; +import type { ClientContract, CRUD_EXT } from './contract'; import type { GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { AnyPlugin } from './plugin'; @@ -144,16 +144,20 @@ export type HasComputedFields = */ export type VirtualFieldContext = { /** - * The current authenticated user, if set via `$setAuth()`. + * The database row data (only contains fields that were selected in the query). */ - auth: AuthType | undefined; + row: Record; + + /** + * The ZenStack client instance. + */ + client: ClientContract; }; /** * Function that computes a virtual field value at runtime. */ export type VirtualFieldFunction = ( - row: Record, context: VirtualFieldContext, ) => unknown | Promise; diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 203953216..54a2e80e2 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -1,6 +1,6 @@ import type { BuiltinType, FieldDef, GetModels, SchemaDef } from '../schema'; import { DELEGATE_JOINED_FIELD_PREFIX } from './constants'; -import type { AuthType } from './contract'; +import type { ClientContract } from './contract'; import { getCrudDialect } from './crud/dialects'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { ClientOptions, VirtualFieldContext, VirtualFieldFunction } from './options'; @@ -8,7 +8,7 @@ import { ensureArray, getField, getIdValues } from './query-utils'; export class ResultProcessor { private dialect: BaseCrudDialect; - private readonly virtualFieldsOptions: Record> | undefined; + private readonly virtualFieldsOptions: Record>> | undefined; constructor( private readonly schema: Schema, @@ -18,27 +18,27 @@ export class ResultProcessor { this.virtualFieldsOptions = (options as any).virtualFields; } - async processResult(data: any, model: GetModels, args?: any, auth?: AuthType) { - const result = await this.doProcessResult(data, model, args, auth); + async processResult(data: any, model: GetModels, args?: any, client?: ClientContract) { + const result = await this.doProcessResult(data, model, args, client); // deal with correcting the reversed order due to negative take this.fixReversedResult(result, model, args); return result; } - private async doProcessResult(data: any, model: GetModels, args?: any, auth?: AuthType) { + private async doProcessResult(data: any, model: GetModels, args?: any, client?: ClientContract) { if (Array.isArray(data)) { await Promise.all( data.map(async (row, i) => { - data[i] = await this.processRow(row, model, args, auth); + data[i] = await this.processRow(row, model, args, client); }), ); return data; } else { - return this.processRow(data, model, args, auth); + return this.processRow(data, model, args, client); } } - private async processRow(data: any, model: GetModels, args?: any, auth?: AuthType) { + private async processRow(data: any, model: GetModels, args?: any, client?: ClientContract) { if (!data || typeof data !== 'object') { return data; } @@ -67,7 +67,7 @@ export class ResultProcessor { delete data[key]; continue; } - const processedSubRow = await this.processRow(subRow, subModel, args, auth); + const processedSubRow = await this.processRow(subRow, subModel, args, client); // merge the sub-row into the main row Object.assign(data, processedSubRow); @@ -92,13 +92,13 @@ export class ResultProcessor { if (fieldDef.relation) { // Extract relation-specific args (select/omit/include) for nested processing const relationArgs = this.getRelationArgs(args, key); - data[key] = await this.processRelation(value, fieldDef, relationArgs, auth); + data[key] = await this.processRelation(value, fieldDef, relationArgs, client); } else { data[key] = this.processFieldValue(value, fieldDef); } } - await this.applyVirtualFields(data, model, args, auth); + await this.applyVirtualFields(data, model, args, client); return data; } @@ -136,7 +136,7 @@ export class ResultProcessor { return undefined; } - private async processRelation(value: unknown, fieldDef: FieldDef, args?: any, auth?: AuthType) { + private async processRelation(value: unknown, fieldDef: FieldDef, args?: any, client?: ClientContract) { let relationData = value; if (typeof value === 'string') { // relation can be returned as a JSON string @@ -146,13 +146,13 @@ export class ResultProcessor { return value; } } - return this.doProcessResult(relationData, fieldDef.type as GetModels, args, auth); + return this.doProcessResult(relationData, fieldDef.type as GetModels, args, client); } /** * Computes virtual fields at runtime using functions from client options. * */ - private async applyVirtualFields(data: any, model: GetModels, args?: any, auth?: AuthType) { + private async applyVirtualFields(data: any, model: GetModels, args?: any, client?: ClientContract) { if (!data || typeof data !== 'object') { return; } @@ -171,8 +171,10 @@ export class ResultProcessor { const selectClause = args?.select; const omitClause = args?.omit; - // Build the context once for all virtual fields - const context: VirtualFieldContext = { auth }; + const context: VirtualFieldContext = { + row: { ...data }, + client: client!, + }; await Promise.all( virtualFieldNames.map(async (fieldName) => { @@ -187,8 +189,7 @@ export class ResultProcessor { } const virtualFn = modelVirtualFieldOptions[fieldName]!; - - data[fieldName] = await virtualFn({ ...data }, context); + data[fieldName] = await virtualFn(context); }), ); } From 5389bfd3dde84660e0ba00d9cb3cc38e33c6d298 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 20:37:01 -0500 Subject: [PATCH 32/39] feat: optimize tests --- .../e2e/orm/client-api/virtual-fields.test.ts | 423 ++++-------------- 1 file changed, 85 insertions(+), 338 deletions(-) diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index d642efd4c..531fe18d3 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -10,12 +10,14 @@ model User { firstName String lastName String fullName String @virtual + initials String @virtual } `, { virtualFields: { User: { - fullName: (row: any) => `${row.firstName} ${row.lastName}`, + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, + initials: ({ row }: any) => `${row.firstName[0]}${row.lastName[0]}`.toUpperCase(), }, }, } as any, @@ -27,6 +29,7 @@ model User { }), ).resolves.toMatchObject({ fullName: 'Alex Smith', + initials: 'AS', }); await expect( @@ -35,6 +38,7 @@ model User { }), ).resolves.toMatchObject({ fullName: 'Alex Smith', + initials: 'AS', }); await expect( @@ -42,6 +46,7 @@ model User { ).resolves.toEqual([ expect.objectContaining({ fullName: 'Alex Smith', + initials: 'AS', }), ]); }); @@ -58,7 +63,7 @@ model Blob { { virtualFields: { Blob: { - sasUrl: async (row: any) => { + sasUrl: async ({ row }: any) => { // Simulate async operation (e.g., generating SAS token) await new Promise((resolve) => setTimeout(resolve, 10)); return `https://storage.example.com/${row.blobName}?sas=token123`; @@ -85,23 +90,30 @@ model User { firstName String lastName String fullName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int } `, { virtualFields: { User: { - fullName: (row: any) => `${row.firstName} ${row.lastName}`, + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, }, }, } as any, ); await db.user.create({ - data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + data: { id: 1, firstName: 'Alex', lastName: 'Smith', posts: { create: { title: 'Post1' } } }, }); - // When selecting the virtual field explicitly, it should be computed - // Note: User must select the fields that the virtual field depends on + // Top-level select includes virtual field await expect( db.user.findUnique({ where: { id: 1 }, @@ -111,6 +123,17 @@ model User { id: 1, fullName: 'Alex Smith', }); + + // Nested select includes virtual field in relation + const post = await db.post.findFirst({ + select: { + title: true, + author: { + select: { firstName: true, lastName: true, fullName: true }, + }, + }, + }); + expect(post?.author?.fullName).toBe('Alex Smith'); }); it('respects select clause - skips virtual field when not selected', async () => { @@ -128,7 +151,7 @@ model User { { virtualFields: { User: { - fullName: (row: any) => { + fullName: ({ row }: any) => { virtualFieldCalled = true; return `${row.firstName} ${row.lastName}`; }, @@ -170,7 +193,7 @@ model User { { virtualFields: { User: { - fullName: (row: any) => `${row.firstName} ${row.lastName}`, + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, }, }, } as any, @@ -237,7 +260,7 @@ model Post { { virtualFields: { User: { - displayName: (row: any) => `@${row.name}`, + displayName: ({ row }: any) => `@${row.name}`, }, }, } as any, @@ -276,7 +299,7 @@ async function main() { dialect: {} as any, virtualFields: { User: { - displayName: (row) => \`@\${row.name}\`, + displayName: ({ row }) => \`@\${row.name}\`, }, } }); @@ -414,11 +437,13 @@ main(); model User { id Int @id @default(autoincrement()) name String + posts Post[] } model Post { id Int @id @default(autoincrement()) title String + author User @relation(fields: [authorId], references: [id]) authorId Int canEdit Boolean @virtual } @@ -426,18 +451,17 @@ model Post { { virtualFields: { Post: { - canEdit: (row: any, { auth }: any) => { + canEdit: ({ row, client }: any) => { // User can edit if they are the author - return auth?.id === row.authorId; + return client?.$auth?.id === row.authorId; }, }, }, } as any, ); - // Create a post - await db.post.create({ - data: { id: 1, title: 'My Post', authorId: 1 }, + await db.user.create({ + data: { id: 1, name: 'Alex', posts: { create: { id: 1, title: 'My Post' } } }, }); // Without auth, canEdit should be false @@ -453,59 +477,19 @@ model Post { const dbWithOtherAuth = db.$setAuth({ id: 2 }); const postWithOtherAuth = await dbWithOtherAuth.post.findUnique({ where: { id: 1 } }); expect(postWithOtherAuth?.canEdit).toBe(false); - }); - - it('auth context works with nested relations', async () => { - const db = await createTestClient( - ` -model User { - id Int @id @default(autoincrement()) - name String - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int - isOwnPost Boolean @virtual -} -`, - { - virtualFields: { - Post: { - isOwnPost: (row: any, { auth }: any) => auth?.id === row.authorId, - }, - }, - } as any, - ); - - await db.user.create({ - data: { - id: 1, - name: 'Alex', - posts: { create: { id: 1, title: 'Post1' } }, - }, - }); - // Query posts through user relation with auth set - const dbWithAuth = db.$setAuth({ id: 1 }); + // Auth context works through nested relations const user = await dbWithAuth.user.findUnique({ where: { id: 1 }, include: { posts: true }, }); + expect(user?.posts[0]?.canEdit).toBe(true); - expect(user?.posts[0]?.isOwnPost).toBe(true); - - // With different auth - const dbWithOtherAuth = db.$setAuth({ id: 2 }); const userOther = await dbWithOtherAuth.user.findUnique({ where: { id: 1 }, include: { posts: true }, }); - - expect(userOther?.posts[0]?.isOwnPost).toBe(false); + expect(userOther?.posts[0]?.canEdit).toBe(false); }); it('works with relations and virtual fields on PostgreSQL (lateral join dialect)', async () => { @@ -532,7 +516,7 @@ model Post { provider: 'postgresql', virtualFields: { User: { - displayName: (row: any) => `@${row.name}`, + displayName: ({ row }: any) => `@${row.name}`, }, }, } as any, @@ -577,7 +561,7 @@ model Post extends Content { { virtualFields: { Post: { - preview: (row: any) => row.body?.substring(0, 50) ?? '', + preview: ({ row }: any) => row.body?.substring(0, 50) ?? '', }, }, } as any, @@ -613,7 +597,7 @@ model User { { virtualFields: { User: { - fullName: (row: any) => `${row.firstName} ${row.lastName}`, + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, }, }, } as any, @@ -645,7 +629,7 @@ model User { { virtualFields: { User: { - fullName: (row: any) => `${row.firstName} ${row.lastName}`, + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, }, }, } as any, @@ -670,39 +654,6 @@ model User { expect(updated.fullName).toBe('John Smith'); }); - it('works with multiple virtual fields on same model', async () => { - const db = await createTestClient( - ` -model User { - id Int @id @default(autoincrement()) - firstName String - lastName String - email String - fullName String @virtual - displayEmail String @virtual - initials String @virtual -} -`, - { - virtualFields: { - User: { - fullName: (row: any) => `${row.firstName} ${row.lastName}`, - displayEmail: (row: any) => row.email.toLowerCase(), - initials: (row: any) => `${row.firstName[0]}${row.lastName[0]}`.toUpperCase(), - }, - }, - } as any, - ); - - const user = await db.user.create({ - data: { id: 1, firstName: 'Alex', lastName: 'Smith', email: 'ALEX@EXAMPLE.COM' }, - }); - - expect(user.fullName).toBe('Alex Smith'); - expect(user.displayEmail).toBe('alex@example.com'); - expect(user.initials).toBe('AS'); - }); - // Note: MySQL lateral join dialect is tested via PostgreSQL test since both use the same // lateral join implementation. The PostgreSQL test covers the lateral join dialect behavior. @@ -726,7 +677,7 @@ model Post { { virtualFields: { Post: { - authorDisplay: (row: any) => { + authorDisplay: ({ row }: any) => { // Virtual field can access included relation data if (row.author) { return `by ${row.author.name}`; @@ -762,12 +713,20 @@ model User { id Int @id @default(autoincrement()) name String displayName String @virtual + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int } `, { virtualFields: { User: { - displayName: (row: any) => { + displayName: ({ row }: any) => { virtualFieldCalled = true; return `@${row.name}`; }, @@ -777,12 +736,12 @@ model User { ); await db.user.create({ - data: { id: 1, name: 'Alex' }, + data: { id: 1, name: 'Alex', posts: { create: { title: 'Post1' } } }, }); virtualFieldCalled = false; - // When omitting the virtual field, it should NOT be computed + // Top-level omit skips virtual field const result = await db.user.findUnique({ where: { id: 1 }, omit: { displayName: true }, @@ -791,6 +750,18 @@ model User { expect(result).toMatchObject({ id: 1, name: 'Alex' }); expect(result).not.toHaveProperty('displayName'); expect(virtualFieldCalled).toBe(false); + + // Nested omit skips virtual field in relation + const post = await db.post.findFirst({ + include: { + author: { + omit: { displayName: true }, + }, + }, + }); + + expect(post?.author).not.toHaveProperty('displayName'); + expect(virtualFieldCalled).toBe(false); }); it('propagates errors from virtual field functions', async () => { @@ -825,107 +796,6 @@ model User { ); }); - it('respects nested select clause for virtual fields in relations', async () => { - let virtualFieldCalled = false; - - const db = await createTestClient( - ` -model User { - id Int @id @default(autoincrement()) - name String - displayName String @virtual - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int -} -`, - { - virtualFields: { - User: { - displayName: (row: any) => { - virtualFieldCalled = true; - return `@${row.name}`; - }, - }, - }, - } as any, - ); - - await db.user.create({ - data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, - }); - - virtualFieldCalled = false; - - // When nested select includes the virtual field, it should be computed - const post = await db.post.findFirst({ - select: { - title: true, - author: { - select: { name: true, displayName: true }, - }, - }, - }); - - expect(post?.author?.displayName).toBe('@alex'); - expect(virtualFieldCalled).toBe(true); - }); - - it('respects nested omit clause for virtual fields in relations', async () => { - let virtualFieldCalled = false; - - const db = await createTestClient( - ` -model User { - id Int @id @default(autoincrement()) - name String - displayName String @virtual - posts Post[] -} - -model Post { - id Int @id @default(autoincrement()) - title String - author User @relation(fields: [authorId], references: [id]) - authorId Int -} -`, - { - virtualFields: { - User: { - displayName: (row: any) => { - virtualFieldCalled = true; - return `@${row.name}`; - }, - }, - }, - } as any, - ); - - await db.user.create({ - data: { id: 1, name: 'alex', posts: { create: { title: 'Post1' } } }, - }); - - virtualFieldCalled = false; - - // When nested omit excludes the virtual field, it should NOT be computed - const post = await db.post.findFirst({ - include: { - author: { - omit: { displayName: true }, - }, - }, - }); - - expect(post?.author).not.toHaveProperty('displayName'); - expect(virtualFieldCalled).toBe(false); - }); - it('rejects virtual fields in update data', async () => { const db = await createTestClient( ` @@ -938,7 +808,7 @@ model User { { virtualFields: { User: { - displayName: (row: any) => `@${row.name}`, + displayName: ({ row }: any) => `@${row.name}`, }, }, } as any, @@ -959,157 +829,34 @@ model User { ).rejects.toThrow(/unrecognized.*key|displayName/i); }); - // Real-world schema test: E-commerce-like schema with practical virtual fields - it('works with real-world e-commerce schema', async () => { + it('works with findMany returning multiple rows', async () => { const db = await createTestClient( ` -// Represents a typical e-commerce application schema model User { - id String @id @default(cuid()) - email String @unique - firstName String? - lastName String? - createdAt DateTime @default(now()) - orders Order[] - - // Virtual: computed display name for UI - displayName String @virtual -} - -model Product { - id String @id @default(cuid()) - name String - description String? - priceInCents Int - currency String @default("USD") - inStock Boolean @default(true) - orderItems OrderItem[] - - // Virtual: formatted price for display (e.g., "$19.99") - formattedPrice String @virtual -} - -model Order { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id]) - status String @default("pending") - createdAt DateTime @default(now()) - items OrderItem[] - - // Virtual: order summary for listing views - orderSummary String @virtual -} - -model OrderItem { - id String @id @default(cuid()) - orderId String - order Order @relation(fields: [orderId], references: [id]) - productId String - product Product @relation(fields: [productId], references: [id]) - quantity Int + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual } `, { virtualFields: { User: { - displayName: (row: any) => { - if (row.firstName && row.lastName) { - return `${row.firstName} ${row.lastName}`; - } - if (row.firstName) return row.firstName; - return row.email?.split('@')[0] ?? 'Anonymous'; - }, - }, - Product: { - formattedPrice: (row: any) => { - const dollars = (row.priceInCents / 100).toFixed(2); - const symbol = row.currency === 'EUR' ? '€' : '$'; - return `${symbol}${dollars}`; - }, - }, - Order: { - orderSummary: (row: any) => { - const itemCount = row.items?.length ?? 0; - const statusLabel = row.status === 'pending' ? 'Pending' : 'Completed'; - return `${statusLabel} - ${itemCount} item(s)`; - }, + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, }, }, } as any, ); - // Create test data - const user = await db.user.create({ - data: { - id: 'user-1', - email: 'john.doe@example.com', - firstName: 'John', - lastName: 'Doe', - }, - }); - expect(user.displayName).toBe('John Doe'); - - const product = await db.product.create({ - data: { - id: 'prod-1', - name: 'TypeScript Handbook', - priceInCents: 2999, - currency: 'USD', - }, - }); - expect(product.formattedPrice).toBe('$29.99'); - - // Create order with items and verify virtual field with relation data - await db.order.create({ - data: { - id: 'order-1', - userId: 'user-1', - status: 'pending', - items: { - create: [{ id: 'item-1', productId: 'prod-1', quantity: 2 }], - }, - }, - select: { id: true }, - }); + await db.user.create({ data: { id: 1, firstName: 'Alex', lastName: 'Smith' } }); + await db.user.create({ data: { id: 2, firstName: 'Jane', lastName: 'Doe' } }); + await db.user.create({ data: { id: 3, firstName: 'Bob', lastName: 'Jones' } }); - // Query order with items included - virtual field should use relation data - const order = await db.order.findUnique({ - where: { id: 'order-1' }, - include: { items: true }, - }); - expect(order?.orderSummary).toBe('Pending - 1 item(s)'); + const users = await db.user.findMany({ orderBy: { id: 'asc' } }); - // Query user with orders - nested virtual fields should work - const userWithOrders = await db.user.findUnique({ - where: { id: 'user-1' }, - include: { - orders: { - include: { items: true }, - }, - }, - }); - expect(userWithOrders?.displayName).toBe('John Doe'); - expect(userWithOrders?.orders[0]?.orderSummary).toBe('Pending - 1 item(s)'); - - // Test user with only email (no name) - fallback logic - const userEmailOnly = await db.user.create({ - data: { - id: 'user-2', - email: 'anonymous@example.com', - }, - }); - expect(userEmailOnly.displayName).toBe('anonymous'); - - // Test product with EUR currency - const euroProduct = await db.product.create({ - data: { - id: 'prod-2', - name: 'Euro Product', - priceInCents: 1999, - currency: 'EUR', - }, - }); - expect(euroProduct.formattedPrice).toBe('€19.99'); + expect(users).toHaveLength(3); + expect(users[0].fullName).toBe('Alex Smith'); + expect(users[1].fullName).toBe('Jane Doe'); + expect(users[2].fullName).toBe('Bob Jones'); }); }); From 815befe4172b6fdf887e0f8b69903da65a44c8c3 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 20:50:13 -0500 Subject: [PATCH 33/39] feat: use transaction client in result processing for model CRUD handler --- packages/orm/src/client/client-impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 054f9b47c..94713d1b0 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -456,7 +456,7 @@ function createModelCrudHandler( } let result: unknown; if (r && postProcess) { - result = await resultProcessor.processResult(r, model, args, client); + result = await resultProcessor.processResult(r, model, args, txClient ?? client); } else { result = r ?? null; } From 2a4ff12d63dd5e4e9904a8a6c20f8d07fb9a1637 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 20:52:22 -0500 Subject: [PATCH 34/39] feat: update return type of VirtualFieldFunction to support Promise --- packages/orm/src/client/options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 36bd80353..8de757d5d 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -159,7 +159,7 @@ export type VirtualFieldContext = { */ export type VirtualFieldFunction = ( context: VirtualFieldContext, -) => unknown | Promise; +) => Promise | unknown; export type VirtualFieldsOptions = { [Model in GetModels as 'virtualFields' extends keyof GetModel ? Model : never]: { From d7b8f1d885845e4b608b4903112250bdcfdeb4c4 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 21:08:22 -0500 Subject: [PATCH 35/39] feat: enforce required args in processResult and related methods --- packages/orm/src/client/result-processor.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 54a2e80e2..58e8c43bf 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -18,14 +18,14 @@ export class ResultProcessor { this.virtualFieldsOptions = (options as any).virtualFields; } - async processResult(data: any, model: GetModels, args?: any, client?: ClientContract) { + async processResult(data: any, model: GetModels, args: any, client: ClientContract) { const result = await this.doProcessResult(data, model, args, client); // deal with correcting the reversed order due to negative take this.fixReversedResult(result, model, args); return result; } - private async doProcessResult(data: any, model: GetModels, args?: any, client?: ClientContract) { + private async doProcessResult(data: any, model: GetModels, args: any, client: ClientContract) { if (Array.isArray(data)) { await Promise.all( data.map(async (row, i) => { @@ -38,7 +38,7 @@ export class ResultProcessor { } } - private async processRow(data: any, model: GetModels, args?: any, client?: ClientContract) { + private async processRow(data: any, model: GetModels, args: any, client: ClientContract) { if (!data || typeof data !== 'object') { return data; } @@ -136,7 +136,7 @@ export class ResultProcessor { return undefined; } - private async processRelation(value: unknown, fieldDef: FieldDef, args?: any, client?: ClientContract) { + private async processRelation(value: unknown, fieldDef: FieldDef, args: any, client: ClientContract) { let relationData = value; if (typeof value === 'string') { // relation can be returned as a JSON string @@ -152,7 +152,7 @@ export class ResultProcessor { /** * Computes virtual fields at runtime using functions from client options. * */ - private async applyVirtualFields(data: any, model: GetModels, args?: any, client?: ClientContract) { + private async applyVirtualFields(data: any, model: GetModels, args: any, client: ClientContract) { if (!data || typeof data !== 'object') { return; } @@ -173,7 +173,7 @@ export class ResultProcessor { const context: VirtualFieldContext = { row: { ...data }, - client: client!, + client, }; await Promise.all( From 067700a2a5b6958b8ef97865e0670e668addf5cc Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 21:12:47 -0500 Subject: [PATCH 36/39] feat: add null check for virtual field function in applyVirtualFields --- packages/orm/src/client/result-processor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 58e8c43bf..8ebbb1984 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -188,7 +188,10 @@ export class ResultProcessor { return; } - const virtualFn = modelVirtualFieldOptions[fieldName]!; + const virtualFn = modelVirtualFieldOptions[fieldName]; + if (!virtualFn) { + return; + } data[fieldName] = await virtualFn(context); }), ); From 79adc3de6681abd9ac257875ee93897797ad7b7c Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Thu, 5 Feb 2026 21:14:48 -0500 Subject: [PATCH 37/39] feat: simplify virtual field function retrieval in applyVirtualFields --- packages/orm/src/client/result-processor.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 8ebbb1984..58e8c43bf 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -188,10 +188,7 @@ export class ResultProcessor { return; } - const virtualFn = modelVirtualFieldOptions[fieldName]; - if (!virtualFn) { - return; - } + const virtualFn = modelVirtualFieldOptions[fieldName]!; data[fieldName] = await virtualFn(context); }), ); From 1cac24e024866f66999b7209e82a7ad8c8e378e0 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 9 Feb 2026 09:41:26 -0500 Subject: [PATCH 38/39] feat: enhance virtual fields to support dependency injection --- packages/language/res/stdlib.zmodel | 8 +- .../src/client/crud/dialects/base-dialect.ts | 22 +++- .../orm/src/client/crud/operations/base.ts | 22 ++++ packages/orm/src/client/result-processor.ts | 24 ++++ packages/schema/src/schema.ts | 16 ++- packages/sdk/src/ts-schema-generator.ts | 24 +++- .../e2e/orm/client-api/virtual-fields.test.ts | 124 ++++++++++++++++++ 7 files changed, 231 insertions(+), 9 deletions(-) diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 425448529..38680b04e 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -644,9 +644,13 @@ attribute @json() @@@targetField([TypeDefField]) attribute @computed() /** - * Marks a field to be virtual + * Marks a field to be virtual (computed at runtime in JavaScript). + * + * @param dependencies: A list of fields that this virtual field depends on. When the virtual field + * is selected, these fields will be automatically included in the database query even if not + * explicitly selected by the user, and will be stripped from the result after computation. */ -attribute @virtual() +attribute @virtual(dependencies: FieldReference[]?) /** * Gets the current login user. diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index edb01a2eb..00f3ee50c 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -1126,11 +1126,31 @@ export abstract class BaseCrudDialect { const modelDef = requireModel(this.schema, model); let result = query; + // Collect dependency fields needed by non-omitted virtual fields + // so they're fetched even if the user omits them + const depsNeeded = new Set(); + if (omit && modelDef.virtualFields) { + for (const vFieldName of Object.keys(modelDef.virtualFields)) { + if (!this.shouldOmitField(omit, model, vFieldName)) { + const vFieldDef = modelDef.fields[vFieldName]; + const deps = + typeof vFieldDef?.virtual === 'object' + ? vFieldDef.virtual.dependencies + : undefined; + if (deps) { + for (const dep of deps) { + depsNeeded.add(dep); + } + } + } + } + } + for (const field of Object.keys(modelDef.fields)) { if (isRelationField(this.schema, model, field)) { continue; } - if (this.shouldOmitField(omit, model, field)) { + if (this.shouldOmitField(omit, model, field) && !depsNeeded.has(field)) { continue; } // virtual fields don't exist in the database, skip them diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 70f0b7618..d5c662621 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -324,6 +324,22 @@ export abstract class BaseOperationHandler { let result = query; let hasNonVirtualField = false; + // Collect dependency fields needed by selected virtual fields + const injectedDependencies = new Set(); + for (const [field, payload] of Object.entries(selectOrInclude)) { + if (!payload) continue; + const fieldDef = this.requireModel(model).fields[field]; + const deps = + typeof fieldDef?.virtual === 'object' ? fieldDef.virtual.dependencies : undefined; + if (deps) { + for (const dep of deps) { + if (!selectOrInclude[dep]) { + injectedDependencies.add(dep); + } + } + } + } + for (const [field, payload] of Object.entries(selectOrInclude)) { if (!payload) { continue; @@ -362,6 +378,12 @@ export abstract class BaseOperationHandler { } } + // Add injected dependency fields to the SQL SELECT + for (const dep of injectedDependencies) { + result = this.dialect.buildSelectField(result, model, parentAlias, dep); + hasNonVirtualField = true; + } + if (!hasNonVirtualField) { throw createInvalidInputError('Cannot select only virtual fields', model); } diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index 58e8c43bf..f0cb9a166 100644 --- a/packages/orm/src/client/result-processor.ts +++ b/packages/orm/src/client/result-processor.ts @@ -176,6 +176,9 @@ export class ResultProcessor { client, }; + // Track dependency fields that were injected (not in original select/omit) + const injectedDeps = new Set(); + await Promise.all( virtualFieldNames.map(async (fieldName) => { // Skip if select clause exists and doesn't include this virtual field @@ -188,10 +191,31 @@ export class ResultProcessor { return; } + // Collect injected dependencies for this active virtual field + const fieldDef = modelDef.fields[fieldName]; + const deps = + typeof fieldDef?.virtual === 'object' + ? fieldDef.virtual.dependencies + : undefined; + if (deps) { + for (const dep of deps) { + if (selectClause && !selectClause[dep]) { + injectedDeps.add(dep); + } else if (omitClause?.[dep]) { + injectedDeps.add(dep); + } + } + } + const virtualFn = modelVirtualFieldOptions[fieldName]!; data[fieldName] = await virtualFn(context); }), ); + + // Strip dependency fields that were auto-injected and not originally requested + for (const dep of injectedDeps) { + delete data[dep]; + } } private fixReversedResult(data: any, model: GetModels, args: any) { diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index dc155177b..cb5afcea8 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -64,6 +64,10 @@ export type UpdatedAtInfo = { ignore?: readonly string[]; }; +export type VirtualFieldInfo = { + dependencies?: readonly string[]; +}; + export type FieldDef = { name: string; type: string; @@ -78,7 +82,7 @@ export type FieldDef = { relation?: RelationInfo; foreignKeyFor?: readonly string[]; computed?: boolean; - virtual?: boolean; + virtual?: boolean | VirtualFieldInfo; originModel?: string; isDiscriminator?: boolean; }; @@ -235,13 +239,17 @@ export type RelationFields> = keyof { - [Key in GetModelFields as GetModelField['virtual'] extends true + [Key in GetModelFields as GetModelField['virtual'] extends + | true + | VirtualFieldInfo ? never : Key]: Key; }; export type NonVirtualNonRelationFields> = keyof { - [Key in GetModelFields as GetModelField['virtual'] extends true + [Key in GetModelFields as GetModelField['virtual'] extends + | true + | VirtualFieldInfo ? never : GetModelField['relation'] extends object ? never @@ -303,7 +311,7 @@ export type FieldIsVirtual< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, -> = GetModelField['virtual'] extends true ? true : false; +> = GetModelField['virtual'] extends true | VirtualFieldInfo ? true : false; export type FieldHasDefault< Schema extends SchemaDef, diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index c7c548b50..8b030d8aa 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -738,8 +738,28 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('computed', ts.factory.createTrue())); } - if (hasAttribute(field, '@virtual')) { - objectFields.push(ts.factory.createPropertyAssignment('virtual', ts.factory.createTrue())); + const virtualAttrib = getAttribute(field, '@virtual') as DataFieldAttribute | undefined; + if (virtualAttrib) { + const depsArg = virtualAttrib.args.find((arg) => arg.$resolvedParam?.name === 'dependencies'); + if (depsArg) { + objectFields.push( + ts.factory.createPropertyAssignment( + 'virtual', + ts.factory.createObjectLiteralExpression([ + ts.factory.createPropertyAssignment( + 'dependencies', + ts.factory.createArrayLiteralExpression( + (depsArg.value as ArrayExpr).items.map((item) => + ts.factory.createStringLiteral((item as ReferenceExpr).target.$refText), + ), + ), + ), + ]), + ), + ); + } else { + objectFields.push(ts.factory.createPropertyAssignment('virtual', ts.factory.createTrue())); + } } if (isDataModel(field.type.reference?.ref)) { diff --git a/tests/e2e/orm/client-api/virtual-fields.test.ts b/tests/e2e/orm/client-api/virtual-fields.test.ts index 531fe18d3..ff2900411 100644 --- a/tests/e2e/orm/client-api/virtual-fields.test.ts +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -859,4 +859,128 @@ model User { expect(users[1].fullName).toBe('Jane Doe'); expect(users[2].fullName).toBe('Bob Jones'); }); + + it('auto-fetches dependencies when virtual field is selected', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual(dependencies: [firstName, lastName]) +} +`, + { + virtualFields: { + User: { + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ data: { id: 1, firstName: 'Alex', lastName: 'Smith' } }); + + // Select only id and fullName -- firstName and lastName should be auto-fetched + // but NOT appear in the result + const result = await db.user.findUnique({ + where: { id: 1 }, + select: { id: true, fullName: true }, + }); + + expect(result).toMatchObject({ id: 1, fullName: 'Alex Smith' }); + expect(result).not.toHaveProperty('firstName'); + expect(result).not.toHaveProperty('lastName'); + }); + + it('keeps dependency fields when explicitly selected alongside virtual field', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual(dependencies: [firstName, lastName]) +} +`, + { + virtualFields: { + User: { + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ data: { id: 1, firstName: 'Alex', lastName: 'Smith' } }); + + // firstName is explicitly selected, so it should remain in the result + const result = await db.user.findUnique({ + where: { id: 1 }, + select: { id: true, firstName: true, fullName: true }, + }); + + expect(result).toMatchObject({ id: 1, firstName: 'Alex', fullName: 'Alex Smith' }); + // lastName was not explicitly selected, so it should be stripped + expect(result).not.toHaveProperty('lastName'); + }); + + it('auto-fetches dependencies even when omitted', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual(dependencies: [firstName, lastName]) +} +`, + { + virtualFields: { + User: { + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ data: { id: 1, firstName: 'Alex', lastName: 'Smith' } }); + + // Omit the dependency fields -- virtual field should still work + // and the omitted deps should be stripped from the result + const result = await db.user.findUnique({ + where: { id: 1 }, + omit: { firstName: true, lastName: true }, + }); + + expect(result).toMatchObject({ id: 1, fullName: 'Alex Smith' }); + expect(result).not.toHaveProperty('firstName'); + expect(result).not.toHaveProperty('lastName'); + }); + + it('works without dependencies (backward compat)', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + fullName String @virtual +} +`, + { + virtualFields: { + User: { + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, + }, + }, + } as any, + ); + + await db.user.create({ data: { id: 1, firstName: 'Alex', lastName: 'Smith' } }); + + // Without dependencies declared, default select-all still works + const result = await db.user.findUnique({ where: { id: 1 } }); + expect(result).toMatchObject({ fullName: 'Alex Smith' }); + }); }); From cb2a27958de230ba414f449cff142bf633150ac2 Mon Sep 17 00:00:00 2001 From: Eugen Istoc Date: Mon, 9 Feb 2026 15:48:46 -0500 Subject: [PATCH 39/39] feat: add virtual field dependencies and enhance related types --- packages/orm/src/client/crud-types.ts | 2 +- packages/orm/src/client/options.ts | 38 ++++++++++++++++++++++++--- packages/schema/src/schema.ts | 8 ++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index d536b49e2..23456161f 100644 --- a/packages/orm/src/client/crud-types.ts +++ b/packages/orm/src/client/crud-types.ts @@ -973,7 +973,7 @@ type RelationFilter< //#region Field utils -type MapModelFieldType< +export type MapModelFieldType< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 8de757d5d..cad06ab6d 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -1,8 +1,17 @@ import type { Dialect, Expression, ExpressionBuilder, KyselyConfig } from 'kysely'; -import type { GetModel, GetModelFields, GetModels, ProcedureDef, ScalarFields, SchemaDef } from '../schema'; +import type { + GetModel, + GetModelFields, + GetModels, + GetVirtualFieldDependencies, + NonVirtualNonRelationFields, + ProcedureDef, + ScalarFields, + SchemaDef, +} from '../schema'; import type { PrependParameter } from '../utils/type-utils'; import type { ClientContract, CRUD_EXT } from './contract'; -import type { GetProcedureNames, ProcedureHandlerFunc } from './crud-types'; +import type { GetProcedureNames, MapModelFieldType, ProcedureHandlerFunc } from './crud-types'; import type { BaseCrudDialect } from './crud/dialects/base-dialect'; import type { AnyPlugin } from './plugin'; import type { ToKyselySchema } from './query-builder'; @@ -161,9 +170,32 @@ export type VirtualFieldFunction = ( context: VirtualFieldContext, ) => Promise | unknown; +/** + * Row data passed to a virtual field function. Dependency fields are required, + * all other non-virtual, non-relation fields are optional, and the virtual field + * being defined is absent. + */ +export type VirtualFieldRow< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = { + [Key in GetVirtualFieldDependencies]: MapModelFieldType; +} & { + [Key in Exclude< + NonVirtualNonRelationFields, + GetVirtualFieldDependencies + >]?: MapModelFieldType; +}; + export type VirtualFieldsOptions = { [Model in GetModels as 'virtualFields' extends keyof GetModel ? Model : never]: { - [Field in keyof Schema['models'][Model]['virtualFields']]: VirtualFieldFunction; + [Field in keyof Schema['models'][Model]['virtualFields']]: ( + context: { + row: VirtualFieldRow>; + client: ClientContract; + }, + ) => Promise | unknown; }; }; diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index cb5afcea8..0d71746de 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -313,6 +313,14 @@ export type FieldIsVirtual< Field extends GetModelFields, > = GetModelField['virtual'] extends true | VirtualFieldInfo ? true : false; +export type GetVirtualFieldDependencies< + Schema extends SchemaDef, + Model extends GetModels, + Field extends GetModelFields, +> = GetModelField['virtual'] extends { dependencies: readonly (infer D)[] } + ? D & GetModelFields + : never; + export type FieldHasDefault< Schema extends SchemaDef, Model extends GetModels,