Skip to content
This repository was archived by the owner on Mar 1, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 19 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
5 changes: 5 additions & 0 deletions packages/language/res/stdlib.zmodel
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,11 @@ attribute @json() @@@targetField([TypeDefField])
*/
attribute @computed()

/**
* Marks a field to be virtual
*/
attribute @virtual()

/**
* 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, client.$auth);
} else {
result = r ?? null;
}
Expand Down
8 changes: 6 additions & 2 deletions packages/orm/src/client/crud-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
GetTypeDefs,
ModelFieldIsOptional,
NonRelationFields,
NonVirtualFields,
NonVirtualNonRelationFields,
ProcedureDef,
RelationFields,
RelationFieldType,
Expand Down Expand Up @@ -281,7 +283,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 +758,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
9 changes: 8 additions & 1 deletion packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,11 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
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);
}

Expand All @@ -1143,9 +1148,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
5 changes: 3 additions & 2 deletions packages/orm/src/client/crud/operations/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,9 +1306,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
2 changes: 1 addition & 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
37 changes: 36 additions & 1 deletion packages/orm/src/client/options.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -101,6 +101,14 @@ export type ClientOptions<Schema extends SchemaDef> = {
computedFields: ComputedFieldsOptions<Schema>;
}
: {}) &
(HasVirtualFields<Schema> extends true
? {
/**
* Virtual field definitions (computed at runtime in JavaScript).
*/
virtualFields: VirtualFieldsOptions<Schema>;
}
: {}) &
(HasProcedures<Schema> extends true
? {
/**
Expand Down Expand Up @@ -131,6 +139,33 @@ export type ComputedFieldsOptions<Schema extends SchemaDef> = {
export type HasComputedFields<Schema extends SchemaDef> =
string extends GetModels<Schema> ? false : keyof ComputedFieldsOptions<Schema> extends never ? false : true;

/**
* Context passed to virtual field functions.
*/
export type VirtualFieldContext<Schema extends SchemaDef> = {
/**
* The current authenticated user, if set via `$setAuth()`.
*/
auth: AuthType<Schema> | undefined;
};

/**
* Function that computes a virtual field value at runtime.
*/
export type VirtualFieldFunction<Schema extends SchemaDef = SchemaDef> = (
row: Record<string, unknown>,
context: VirtualFieldContext<Schema>,
) => unknown | Promise<unknown>;
Comment thread
genu marked this conversation as resolved.
Outdated

export type VirtualFieldsOptions<Schema extends SchemaDef> = {
[Model in GetModels<Schema> as 'virtualFields' extends keyof GetModel<Schema, Model> ? Model : never]: {
[Field in keyof Schema['models'][Model]['virtualFields']]: VirtualFieldFunction<Schema>;
};
};

export type HasVirtualFields<Schema extends SchemaDef> =
string extends GetModels<Schema> ? false : keyof VirtualFieldsOptions<Schema> extends never ? false : true;

export type ProceduresOptions<Schema extends SchemaDef> = Schema extends {
procedures: Record<string, ProcedureDef>;
}
Expand Down
74 changes: 63 additions & 11 deletions packages/orm/src/client/result-processor.ts
Original file line number Diff line number Diff line change
@@ -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<Schema extends SchemaDef> {
private dialect: BaseCrudDialect<Schema>;
private readonly virtualFieldsOptions: Record<string, Record<string, VirtualFieldFunction>> | undefined;

constructor(
private readonly schema: Schema,
options: ClientOptions<Schema>,
) {
this.dialect = getCrudDialect(schema, options);
this.virtualFieldsOptions = (options as any).virtualFields;
}

processResult(data: any, model: GetModels<Schema>, args?: any) {
const result = this.doProcessResult(data, model);
async processResult(data: any, model: GetModels<Schema>, args?: any, auth?: AuthType<Schema>) {
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<Schema>) {
private async doProcessResult(data: any, model: GetModels<Schema>, args?: any, auth?: AuthType<Schema>) {
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<Schema>) {
private async processRow(data: any, model: GetModels<Schema>, args?: any, auth?: AuthType<Schema>) {
if (!data || typeof data !== 'object') {
return data;
}
Expand Down Expand Up @@ -59,7 +67,7 @@ export class ResultProcessor<Schema extends SchemaDef> {
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);
Expand All @@ -82,11 +90,14 @@ export class ResultProcessor<Schema extends SchemaDef> {
}

if (fieldDef.relation) {
data[key] = this.processRelation(value, fieldDef);
data[key] = await this.processRelation(value, fieldDef, args, auth);
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
data[key] = this.processFieldValue(value, fieldDef);
}
}

await this.applyVirtualFields(data, model, args, auth);

return data;
}

Expand All @@ -100,7 +111,7 @@ export class ResultProcessor<Schema extends SchemaDef> {
}
}

private processRelation(value: unknown, fieldDef: FieldDef) {
private async processRelation(value: unknown, fieldDef: FieldDef, args?: any, auth?: AuthType<Schema>) {
let relationData = value;
if (typeof value === 'string') {
// relation can be returned as a JSON string
Expand All @@ -110,7 +121,48 @@ export class ResultProcessor<Schema extends SchemaDef> {
return value;
}
}
return this.doProcessResult(relationData, fieldDef.type as GetModels<Schema>);
return this.doProcessResult(relationData, fieldDef.type as GetModels<Schema>, args, auth);
}

private async applyVirtualFields(data: any, model: GetModels<Schema>, args?: any, auth?: AuthType<Schema>) {
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;

// Build the context once for all virtual fields
const context: VirtualFieldContext<Schema> = { 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;
}

// Skip if omit clause includes this virtual field
if (omitClause?.[fieldName]) {
return;
}

const virtualFn = modelVirtualFieldOptions[fieldName]!;

data[fieldName] = await virtualFn({ ...data }, context);
}),
);
}

private fixReversedResult(data: any, model: GetModels<Schema>, args: any) {
Expand Down
Loading
Loading