Skip to content
This repository was archived by the owner on Feb 10, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ca6debb
feat: add virtual field attribute and utility function
genu Feb 4, 2026
8796ed2
fix: pass authentication context to result processor in createModelCr…
genu Feb 4, 2026
1f25dc0
feat: exclude virtual fields from WhereInput and OrderBy types
genu Feb 4, 2026
d64994e
feat: skip virtual fields in select field processing
genu Feb 4, 2026
64091ab
feat: exclude virtual fields from relation selections in LateralJoinD…
genu Feb 4, 2026
14f90d6
feat: exclude virtual fields from submodel field selections in BaseCr…
genu Feb 4, 2026
e4c4e06
feat: add support for virtual fields with context and computation fun…
genu Feb 4, 2026
3838b36
feat: enhance ResultProcessor to support async processing of virtual …
genu Feb 4, 2026
e5bfd33
feat: make result processing in createModelCrudHandler asynchronous
genu Feb 4, 2026
578bb1d
feat: allow exclusion of virtual fields in InputValidator
genu Feb 4, 2026
05fbe12
feat: add support for virtual fields in ModelDef and FieldDef types
genu Feb 4, 2026
95bf5fb
fix: update filter logic to use typed whereRecord for id field values
genu Feb 4, 2026
c2aad29
feat: exclude virtual fields from model field creation in SchemaDbPusher
genu Feb 4, 2026
4d91875
feat: add support for creating virtual fields in TsSchemaGenerator
genu Feb 4, 2026
a850776
feat: skip virtual fields in model generation in PrismaSchemaGenerator
genu Feb 4, 2026
019619d
feat: add comprehensive tests for virtual fields functionality
genu Feb 4, 2026
ef63095
Merge branch 'dev' into feat/add-virtual-fields
genu Feb 4, 2026
9bbd365
feat: add omit clause support for virtual fields in ResultProcessor
genu Feb 4, 2026
fd1d986
feat: add tests for update, upsert, and multiple virtual fields funct…
genu Feb 4, 2026
fcbcd1d
feat: add support for virtual fields in BaseOperationHandler
genu Feb 4, 2026
a351558
feat: skip virtual fields in scalar field selection within BaseOperat…
genu Feb 4, 2026
612ca58
feat: extract relation-specific args for nested processing in ResultP…
genu Feb 4, 2026
20a6931
feat: enhance virtual fields tests for nested select and omit clauses
genu Feb 4, 2026
b8a64fb
feat: reject virtual fields in update data within InputValidator
genu Feb 4, 2026
f703373
feat: add real-world e-commerce schema tests for virtual fields
genu Feb 4, 2026
d735ecd
chore: add function comments
genu Feb 4, 2026
5d5b784
feat: enforce error when selecting only virtual fields in query
genu Feb 4, 2026
09a93fb
feat: replace internal error with invalid input error for selecting o…
genu Feb 5, 2026
2dfad55
feat: exclude virtual fields from groupBy and aggregate types in clie…
genu Feb 5, 2026
c4d5883
feat: exclude virtual fields from aggregate operations in client API
genu Feb 5, 2026
c3787f4
feat: prevent virtual fields from being used in create and update ope…
genu Feb 5, 2026
dbe9e06
feat: use context
genu Feb 6, 2026
5389bfd
feat: optimize tests
genu Feb 6, 2026
815befe
feat: use transaction client in result processing for model CRUD handler
genu Feb 6, 2026
2a4ff12
feat: update return type of VirtualFieldFunction to support Promise
genu Feb 6, 2026
d7b8f1d
feat: enforce required args in processResult and related methods
genu Feb 6, 2026
067700a
feat: add null check for virtual field function in applyVirtualFields
genu Feb 6, 2026
79adc3d
feat: simplify virtual field function retrieval in applyVirtualFields
genu Feb 6, 2026
1cac24e
feat: enhance virtual fields to support dependency injection
genu Feb 9, 2026
cb2a279
feat: add virtual field dependencies and enhance related types
genu Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
7 changes: 7 additions & 0 deletions packages/language/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
2 changes: 1 addition & 1 deletion packages/orm/src/client/client-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
27 changes: 18 additions & 9 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
FieldIsDelegateDiscriminator,
FieldIsDelegateRelation,
FieldIsRelation,
FieldIsVirtual,
FieldType,
ForeignKeyFields,
GetEnum,
Expand All @@ -23,6 +24,8 @@ import type {
GetTypeDefs,
ModelFieldIsOptional,
NonRelationFields,
NonVirtualFields,
NonVirtualNonRelationFields,
ProcedureDef,
RelationFields,
RelationFieldType,
Expand Down Expand Up @@ -281,7 +284,8 @@ export type WhereInput<
ScalarOnly extends boolean = false,
WithAggregations extends boolean = false,
> = {
[Key in GetModelFields<Schema, Model> 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<Schema, Model> as ScalarOnly extends true
? Key extends RelationFields<Schema, Model>
? never
: Key
Expand Down Expand Up @@ -755,7 +759,8 @@ export type OrderBy<
WithRelation extends boolean,
WithAggregation extends boolean,
> = {
[Key in NonRelationFields<Schema, Model>]?: ModelFieldIsOptional<Schema, Model, Key> extends true
// Use NonVirtualNonRelationFields to exclude virtual fields - they are computed at runtime and cannot be sorted in the database
[Key in NonVirtualNonRelationFields<Schema, Model>]?: ModelFieldIsOptional<Schema, Model, Key> extends true
?
| SortOrder
| {
Expand Down Expand Up @@ -968,7 +973,7 @@ type RelationFilter<

//#region Field utils

type MapModelFieldType<
export type MapModelFieldType<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends GetModelFields<Schema, Model>,
Expand Down Expand Up @@ -1354,7 +1359,7 @@ type UpdateScalarInput<
Without extends string = never,
> = Omit<
{
[Key in NonRelationFields<Schema, Model> as FieldIsDelegateDiscriminator<Schema, Model, Key> extends true
[Key in NonVirtualNonRelationFields<Schema, Model> as FieldIsDelegateDiscriminator<Schema, Model, Key> extends true
? // discriminator fields cannot be assigned
never
: Key]?: ScalarUpdatePayload<Schema, Model, Key>;
Expand All @@ -1365,7 +1370,7 @@ type UpdateScalarInput<
type ScalarUpdatePayload<
Schema extends SchemaDef,
Model extends GetModels<Schema>,
Field extends NonRelationFields<Schema, Model>,
Field extends NonVirtualNonRelationFields<Schema, Model>,
> =
| ScalarFieldMutationPayload<Schema, Model, Field>
| (Field extends NumericFields<Schema, Model>
Expand Down Expand Up @@ -1587,7 +1592,7 @@ export type CountArgs<Schema extends SchemaDef, Model extends GetModels<Schema>>
};

type CountAggregateInput<Schema extends SchemaDef, Model extends GetModels<Schema>> = {
[Key in NonRelationFields<Schema, Model>]?: true;
[Key in NonVirtualNonRelationFields<Schema, Model>]?: true;
} & { _all?: true };

export type CountResult<Schema extends SchemaDef, _Model extends GetModels<Schema>, Args> = Args extends {
Expand Down Expand Up @@ -1661,7 +1666,9 @@ type NumericFields<Schema extends SchemaDef, Model extends GetModels<Schema>> =
| 'Decimal'
? FieldIsArray<Schema, Model, Key> extends true
? never
: Key
: FieldIsVirtual<Schema, Model, Key> extends true
? never
: Key
: never]: GetModelField<Schema, Model, Key>;
};

Expand All @@ -1674,7 +1681,9 @@ type MinMaxInput<Schema extends SchemaDef, Model extends GetModels<Schema>, Valu
? never
: FieldIsRelation<Schema, Model, Key> extends true
? never
: Key]?: ValueType;
: FieldIsVirtual<Schema, Model, Key> extends true
? never
: Key]?: ValueType;
};

export type AggregateResult<Schema extends SchemaDef, _Model extends GetModels<Schema>, Args> = (Args extends {
Expand Down Expand Up @@ -1751,7 +1760,7 @@ export type GroupByArgs<Schema extends SchemaDef, Model extends GetModels<Schema
/**
* Fields to group by
*/
by: NonRelationFields<Schema, Model> | NonEmptyArray<NonRelationFields<Schema, Model>>;
by: NonVirtualNonRelationFields<Schema, Model> | NonEmptyArray<NonVirtualNonRelationFields<Schema, Model>>;

/**
* Filter conditions for the grouped records
Expand Down
31 changes: 29 additions & 2 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,11 +1126,36 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
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<string>();
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);
Expand All @@ -1143,9 +1168,11 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
result = result.select((eb) => {
const jsonObject: Record<string, Expression<any>> = {};
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,13 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> 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),
Expand All @@ -233,14 +233,18 @@ export abstract class LateralJoinDialectBase<Schema extends SchemaDef> 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`)
: // reference a plain field
this.fieldRef(relationModel, field, relationModelAlias, false);
return { [field]: fieldValue };
}
}),
})
.filter((v) => v !== null),
);
}

Expand Down
5 changes: 4 additions & 1 deletion packages/orm/src/client/crud/dialects/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export class SqliteCrudDialect<Schema extends SchemaDef> 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),
Expand Down Expand Up @@ -283,6 +283,8 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
value,
);
return [sql.lit(field), subJson];
} else if (fieldDef.virtual) {
return null;
} else {
return [
sql.lit(field),
Expand All @@ -291,6 +293,7 @@ export class SqliteCrudDialect<Schema extends SchemaDef> extends BaseCrudDialect
}
}
})
.filter((v) => v !== null)
.flatMap((v) => v),
);
}
Expand Down
52 changes: 46 additions & 6 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,23 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
parentAlias: string,
) {
let result = query;
let hasNonVirtualField = false;

// Collect dependency fields needed by selected virtual fields
const injectedDependencies = new Set<string>();
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) {
Expand All @@ -330,13 +347,17 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {

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);
Expand All @@ -353,9 +374,20 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
// 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;
}

Expand Down Expand Up @@ -1306,9 +1338,10 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
// collect id field/values from the original filter
const idFields = requireIdFields(this.schema, model);
const filterIdValues: any = {};
const whereRecord = combinedWhere as Record<string, unknown>;
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];
}
}

Expand Down Expand Up @@ -2554,6 +2587,9 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
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[] = [];

Expand All @@ -2573,8 +2609,12 @@ export abstract class BaseOperationHandler<Schema extends SchemaDef> {
);
}

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 };
Expand Down
5 changes: 4 additions & 1 deletion packages/orm/src/client/crud/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1236,7 +1236,7 @@ export class InputValidator<Schema extends SchemaDef> {
return;
}
const fieldDef = requireField(this.schema, model, field);
if (fieldDef.computed) {
if (fieldDef.computed || fieldDef.virtual) {
return;
}

Expand Down Expand Up @@ -1557,6 +1557,9 @@ export class InputValidator<Schema extends SchemaDef> {
return;
}
const fieldDef = requireField(this.schema, model, field);
if (fieldDef.computed || fieldDef.virtual) {
return;
}

if (fieldDef.relation) {
if (withoutRelationFields) {
Expand Down
6 changes: 5 additions & 1 deletion packages/orm/src/client/helpers/schema-db-pusher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class SchemaDbPusher<Schema extends SchemaDef> {

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);
}
}
Expand Down Expand Up @@ -175,6 +175,10 @@ export class SchemaDbPusher<Schema extends SchemaDef> {
return fieldDef.attributes?.some((a) => a.name === '@computed');
}

private isVirtualField(fieldDef: FieldDef) {
return fieldDef.attributes?.some((a) => a.name === '@virtual');
}

private addPrimaryKeyConstraint(table: CreateTableBuilder<string, any>, modelDef: ModelDef) {
if (modelDef.idFields.length === 1) {
if (Object.values(modelDef.fields).some((f) => f.id)) {
Expand Down
Loading
Loading