diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 4f473ed78..38680b04e 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -643,6 +643,15 @@ attribute @json() @@@targetField([TypeDefField]) */ attribute @computed() +/** + * 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(dependencies: FieldReference[]?) + /** * 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'); } diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index fc8f92c7c..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 = resultProcessor.processResult(r, model, args); + result = await resultProcessor.processResult(r, model, args, txClient ?? client); } else { result = r ?? null; } diff --git a/packages/orm/src/client/crud-types.ts b/packages/orm/src/client/crud-types.ts index 62be0c199..23456161f 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, @@ -23,6 +24,8 @@ import type { GetTypeDefs, ModelFieldIsOptional, NonRelationFields, + NonVirtualFields, + NonVirtualNonRelationFields, ProcedureDef, RelationFields, RelationFieldType, @@ -281,7 +284,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 +759,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 | { @@ -968,7 +973,7 @@ type RelationFilter< //#region Field utils -type MapModelFieldType< +export type MapModelFieldType< Schema extends SchemaDef, Model extends GetModels, Field extends GetModelFields, @@ -1354,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; @@ -1365,7 +1370,7 @@ type UpdateScalarInput< type ScalarUpdatePayload< Schema extends SchemaDef, Model extends GetModels, - Field extends NonRelationFields, + Field extends NonVirtualNonRelationFields, > = | ScalarFieldMutationPayload | (Field extends NumericFields @@ -1587,7 +1592,7 @@ export type CountArgs> }; type CountAggregateInput> = { - [Key in NonRelationFields]?: true; + [Key in NonVirtualNonRelationFields]?: true; } & { _all?: true }; export type CountResult, Args> = Args extends { @@ -1661,7 +1666,9 @@ type NumericFields> = | 'Decimal' ? FieldIsArray extends true ? never - : Key + : FieldIsVirtual extends true + ? never + : Key : never]: GetModelField; }; @@ -1674,7 +1681,9 @@ type MinMaxInput, Valu ? never : FieldIsRelation extends true ? never - : Key]?: ValueType; + : FieldIsVirtual extends true + ? never + : Key]?: ValueType; }; export type AggregateResult, Args> = (Args extends { @@ -1751,7 +1760,7 @@ export type GroupByArgs | NonEmptyArray>; + by: NonVirtualNonRelationFields | NonEmptyArray>; /** * Filter conditions for the grouped records diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 91848d57a..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,36 @@ 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 + const fieldDef = modelDef.fields[field]; + if (fieldDef?.virtual) { continue; } result = this.buildSelectField(result, model, modelAlias, field); @@ -1143,9 +1168,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; } 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), ); } diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index fc75cac9d..d5c662621 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -322,6 +322,23 @@ export abstract class BaseOperationHandler { parentAlias: string, ) { 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) { @@ -330,13 +347,17 @@ export abstract class BaseOperationHandler { if (field === '_count') { result = this.buildCountSelection(result, model, parentAlias, payload); + hasNonVirtualField = true; continue; } 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); + hasNonVirtualField = true; + } } else { if (!fieldDef.array && !fieldDef.optional && payload.where) { throw createInternalError(`Field "${field}" does not support filtering`, model); @@ -353,9 +374,20 @@ export abstract class BaseOperationHandler { // regular relation result = this.dialect.buildRelationSelection(result, model, field, parentAlias, payload); } + hasNonVirtualField = true; } } + // 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); + } + return result; } @@ -1306,9 +1338,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]; } } @@ -2554,6 +2587,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[] = []; @@ -2573,8 +2609,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 }; diff --git a/packages/orm/src/client/crud/validator/index.ts b/packages/orm/src/client/crud/validator/index.ts index 8cad792e9..72412c42b 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; } @@ -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/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)) { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 6439e3996..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'; @@ -101,6 +110,14 @@ export type ClientOptions = { computedFields: ComputedFieldsOptions; } : {}) & + (HasVirtualFields extends true + ? { + /** + * Virtual field definitions (computed at runtime in JavaScript). + */ + virtualFields: VirtualFieldsOptions; + } + : {}) & (HasProcedures extends true ? { /** @@ -131,6 +148,60 @@ 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 database row data (only contains fields that were selected in the query). + */ + row: Record; + + /** + * The ZenStack client instance. + */ + client: ClientContract; +}; + +/** + * Function that computes a virtual field value at runtime. + */ +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']]: ( + context: { + row: VirtualFieldRow>; + client: ClientContract; + }, + ) => Promise | unknown; + }; +}; + +export type HasVirtualFields = + string extends GetModels ? false : keyof VirtualFieldsOptions extends never ? false : true; + export type ProceduresOptions = Schema extends { procedures: Record; } diff --git a/packages/orm/src/client/result-processor.ts b/packages/orm/src/client/result-processor.ts index fc8ae1938..f0cb9a166 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 { ClientContract } 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, 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 doProcessResult(data: any, model: GetModels) { + private async doProcessResult(data: any, model: GetModels, args: any, client: ClientContract) { 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, client); + }), + ); return data; } else { - return this.processRow(data, model); + return this.processRow(data, model, args, client); } } - private processRow(data: any, model: GetModels) { + private async processRow(data: any, model: GetModels, args: any, client: ClientContract) { 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, client); // merge the sub-row into the main row Object.assign(data, processedSubRow); @@ -82,11 +90,16 @@ export class ResultProcessor { } if (fieldDef.relation) { - data[key] = this.processRelation(value, fieldDef); + // Extract relation-specific args (select/omit/include) for nested processing + const relationArgs = this.getRelationArgs(args, key); + data[key] = await this.processRelation(value, fieldDef, relationArgs, client); } else { data[key] = this.processFieldValue(value, fieldDef); } } + + await this.applyVirtualFields(data, model, args, client); + return data; } @@ -100,7 +113,30 @@ export class ResultProcessor { } } - private processRelation(value: unknown, fieldDef: FieldDef) { + /** + * Extracts relation-specific args (select/omit/include) for nested processing. + * */ + 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, client: ClientContract) { let relationData = value; if (typeof value === 'string') { // relation can be returned as a JSON string @@ -110,7 +146,76 @@ export class ResultProcessor { return value; } } - return this.doProcessResult(relationData, fieldDef.type as GetModels); + 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, client: ClientContract) { + 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; + const omitClause = args?.omit; + + const context: VirtualFieldContext = { + row: { ...data }, + 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 + if (selectClause && !selectClause[fieldName]) { + return; + } + + // Skip if omit clause includes this virtual field + if (omitClause?.[fieldName]) { + 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 d98b86f01..0d71746de 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; @@ -63,6 +64,10 @@ export type UpdatedAtInfo = { ignore?: readonly string[]; }; +export type VirtualFieldInfo = { + dependencies?: readonly string[]; +}; + export type FieldDef = { name: string; type: string; @@ -77,6 +82,7 @@ export type FieldDef = { relation?: RelationInfo; foreignKeyFor?: readonly string[]; computed?: boolean; + virtual?: boolean | VirtualFieldInfo; originModel?: string; isDiscriminator?: boolean; }; @@ -205,7 +211,9 @@ export type ScalarFields< ? Key : FieldIsComputed extends true ? never - : Key]: Key; + : FieldIsVirtual extends true + ? never + : Key]: Key; }; export type ForeignKeyFields> = keyof { @@ -230,6 +238,24 @@ export type RelationFields> = keyof { + [Key in GetModelFields as GetModelField['virtual'] extends + | true + | VirtualFieldInfo + ? never + : Key]: Key; +}; + +export type NonVirtualNonRelationFields> = keyof { + [Key in GetModelFields as GetModelField['virtual'] extends + | true + | VirtualFieldInfo + ? never + : GetModelField['relation'] extends object + ? never + : Key]: Key; +}; + export type FieldType< Schema extends SchemaDef, Model extends GetModels, @@ -281,6 +307,20 @@ 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 | 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, 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)) { diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 413131269..8b030d8aa 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,30 @@ export class TsSchemaGenerator { objectFields.push(ts.factory.createPropertyAssignment('computed', 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)) { objectFields.push(ts.factory.createPropertyAssignment('relation', this.createRelationObject(field))); } 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..ff2900411 --- /dev/null +++ b/tests/e2e/orm/client-api/virtual-fields.test.ts @@ -0,0 +1,986 @@ +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 + initials String @virtual +} +`, + { + virtualFields: { + User: { + fullName: ({ row }: any) => `${row.firstName} ${row.lastName}`, + initials: ({ row }: any) => `${row.firstName[0]}${row.lastName[0]}`.toUpperCase(), + }, + }, + } as any, + ); + + await expect( + db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith' }, + }), + ).resolves.toMatchObject({ + fullName: 'Alex Smith', + initials: 'AS', + }); + + await expect( + db.user.findUnique({ + where: { id: 1 }, + }), + ).resolves.toMatchObject({ + fullName: 'Alex Smith', + initials: 'AS', + }); + + await expect( + db.user.findMany(), + ).resolves.toEqual([ + expect.objectContaining({ + fullName: 'Alex Smith', + initials: 'AS', + }), + ]); + }); + + 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 + 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}`, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, firstName: 'Alex', lastName: 'Smith', posts: { create: { title: 'Post1' } } }, + }); + + // Top-level select includes virtual field + await expect( + db.user.findUnique({ + where: { id: 1 }, + select: { id: true, firstName: true, lastName: true, fullName: true }, + }), + ).resolves.toMatchObject({ + 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 () => { + 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('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( + ` +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' } }); + + // @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(); +`, + }, + }, + ); + }); + + 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 } }); + + // @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 }, _sum: { views: true }, _avg: { views: true } }); +} + +main(); +`, + }, + }, + ); + }); + + it('receives auth context in virtual field function', 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 + canEdit Boolean @virtual +} +`, + { + virtualFields: { + Post: { + canEdit: ({ row, client }: any) => { + // User can edit if they are the author + return client?.$auth?.id === row.authorId; + }, + }, + }, + } as any, + ); + + await db.user.create({ + data: { id: 1, name: 'Alex', posts: { create: { id: 1, title: 'My Post' } } }, + }); + + // 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); + + // 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); + + const userOther = await dbWithOtherAuth.user.findUnique({ + where: { id: 1 }, + include: { posts: true }, + }); + expect(userOther?.posts[0]?.canEdit).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', + }); + }); + + 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'); + }); + + // 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( + ` +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 + 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; + + // Top-level omit skips virtual field + 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); + + // 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 () => { + 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, + ); + + // 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 during read when virtual field is computed + await expect(db.user.findUnique({ where: { id: 1 } })).rejects.toThrow( + 'Virtual field computation failed', + ); + }); + + 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); + }); + + it('works with findMany returning multiple rows', 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' } }); + await db.user.create({ data: { id: 2, firstName: 'Jane', lastName: 'Doe' } }); + await db.user.create({ data: { id: 3, firstName: 'Bob', lastName: 'Jones' } }); + + const users = await db.user.findMany({ orderBy: { id: 'asc' } }); + + 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'); + }); + + 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' }); + }); +});