From b2ce74c24c10183f6f31e4acb9bbab367ffa342a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 21 Oct 2025 08:34:17 -0700 Subject: [PATCH 1/2] fix: make computed fields on base models work fixes #284 --- .../src/client/crud/dialects/base-dialect.ts | 32 ++++++++--- packages/runtime/src/client/query-utils.ts | 30 ---------- samples/blog/zenstack/schema.zmodel | 3 +- .../orm/client-api/computed-fields.test.ts | 56 +++++++++++++++---- 4 files changed, 71 insertions(+), 50 deletions(-) diff --git a/packages/runtime/src/client/crud/dialects/base-dialect.ts b/packages/runtime/src/client/crud/dialects/base-dialect.ts index a1c7501b..f82b2ae1 100644 --- a/packages/runtime/src/client/crud/dialects/base-dialect.ts +++ b/packages/runtime/src/client/crud/dialects/base-dialect.ts @@ -19,7 +19,6 @@ import { InternalError, QueryError } from '../../errors'; import type { ClientOptions } from '../../options'; import { aggregate, - buildFieldRef, buildJoinPairs, ensureArray, flattenCompoundUniqueFilters, @@ -931,13 +930,13 @@ export abstract class BaseCrudDialect { field: string, ): SelectQueryBuilder { const fieldDef = requireField(this.schema, model, field); - if (fieldDef.computed) { - // TODO: computed field from delegate base? + + if (!fieldDef.originModel) { + // field defined on this model return query.select(() => this.fieldRef(model, field, modelAlias).as(field)); - } else if (!fieldDef.originModel) { - // regular field - return query.select(this.eb.ref(`${modelAlias}.${field}`).as(field)); } else { + // field defined on a delegate base, build a select with the origin model + // name (the model is already joined from outer query) return this.buildSelectField(query, fieldDef.originModel, fieldDef.originModel, field); } } @@ -1071,7 +1070,26 @@ export abstract class BaseCrudDialect { } fieldRef(model: string, field: string, modelAlias?: string, inlineComputedField = true) { - return buildFieldRef(this.schema, model, field, this.options, this.eb, modelAlias, inlineComputedField); + const fieldDef = requireField(this.schema, model, field); + + if (!fieldDef.computed) { + // regular field + return this.eb.ref(modelAlias ? `${modelAlias}.${field}` : field); + } else { + // computed field + if (!inlineComputedField) { + return this.eb.ref(modelAlias ? `${modelAlias}.${field}` : field); + } + let computer: Function | undefined; + if ('computedFields' in this.options) { + const computedFields = this.options.computedFields as Record; + computer = computedFields?.[fieldDef.originModel ?? model]?.[field]; + } + if (!computer) { + throw new QueryError(`Computed field "${field}" implementation not provided for model "${model}"`); + } + return computer(this.eb, { modelAlias }); + } } protected canJoinWithoutNestedSelect( diff --git a/packages/runtime/src/client/query-utils.ts b/packages/runtime/src/client/query-utils.ts index b5107cdf..9f97305b 100644 --- a/packages/runtime/src/client/query-utils.ts +++ b/packages/runtime/src/client/query-utils.ts @@ -6,7 +6,6 @@ import { TableNode, type Expression, type ExpressionBuilder, - type ExpressionWrapper, type OperationNode, } from 'kysely'; import { match } from 'ts-pattern'; @@ -15,7 +14,6 @@ import { extractFields } from '../utils/object-utils'; import type { AGGREGATE_OPERATORS } from './constants'; import type { OrderBy } from './crud-types'; import { InternalError, QueryError } from './errors'; -import type { ClientOptions } from './options'; export function hasModel(schema: SchemaDef, model: string) { return Object.keys(schema.models) @@ -180,34 +178,6 @@ export function getIdValues(schema: SchemaDef, model: string, data: any): Record return idFields.reduce((acc, field) => ({ ...acc, [field]: data[field] }), {}); } -export function buildFieldRef( - schema: Schema, - model: string, - field: string, - options: ClientOptions, - eb: ExpressionBuilder, - modelAlias?: string, - inlineComputedField = true, -): ExpressionWrapper { - const fieldDef = requireField(schema, model, field); - if (!fieldDef.computed) { - return eb.ref(modelAlias ? `${modelAlias}.${field}` : field); - } else { - if (!inlineComputedField) { - return eb.ref(modelAlias ? `${modelAlias}.${field}` : field); - } - let computer: Function | undefined; - if ('computedFields' in options) { - const computedFields = options.computedFields as Record; - computer = computedFields?.[model]?.[field]; - } - if (!computer) { - throw new QueryError(`Computed field "${field}" implementation not provided for model "${model}"`); - } - return computer(eb, { modelAlias }); - } -} - export function fieldHasDefaultValue(fieldDef: FieldDef) { return fieldDef.default !== undefined || fieldDef.updatedAt; } diff --git a/samples/blog/zenstack/schema.zmodel b/samples/blog/zenstack/schema.zmodel index 6cf112e2..3669d799 100644 --- a/samples/blog/zenstack/schema.zmodel +++ b/samples/blog/zenstack/schema.zmodel @@ -10,7 +10,8 @@ enum Role { } plugin policy { - // due to pnpm layout we can't directly use package name here + // due to pnpm layout we can't directly use package name here, + // don't do this in your code and use "@zenstackhq/plugin-policy" instead provider = '../node_modules/@zenstackhq/plugin-policy/dist/index.js' } diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 84d006b8..3363414b 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1,15 +1,9 @@ import { createTestClient } from '@zenstackhq/testtools'; -import { afterEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; describe('Computed fields tests', () => { - let db: any; - - afterEach(async () => { - await db?.$disconnect(); - }); - it('works with non-optional fields', async () => { - db = await createTestClient( + const db = await createTestClient( ` model User { id Int @id @default(autoincrement()) @@ -97,7 +91,7 @@ model User { }); it('is typed correctly for non-optional fields', async () => { - db = await createTestClient( + await createTestClient( ` model User { id Int @id @default(autoincrement()) @@ -137,7 +131,7 @@ main(); }); it('works with optional fields', async () => { - db = await createTestClient( + const db = await createTestClient( ` model User { id Int @id @default(autoincrement()) @@ -164,7 +158,7 @@ model User { }); it('is typed correctly for optional fields', async () => { - db = await createTestClient( + await createTestClient( ` model User { id Int @id @default(autoincrement()) @@ -203,7 +197,7 @@ main(); }); it('works with read from a relation', async () => { - db = await createTestClient( + const db = await createTestClient( ` model User { id Int @id @default(autoincrement()) @@ -240,4 +234,42 @@ model Post { author: expect.objectContaining({ postCount: 1 }), }); }); + + it('allows sub models to use computed fields from delegate base', async () => { + const db = await createTestClient( + ` +model Content { + id Int @id @default(autoincrement()) + title String + isNews Boolean @computed + contentType String + @@delegate(contentType) +} + +model Post extends Content { + body String +} +`, + { + computedFields: { + Content: { + isNews: (eb: any) => eb('title', 'like', '%news%'), + }, + }, + } as any, + ); + + const posts = await db.post.createManyAndReturn({ + data: [ + { id: 1, title: 'latest news', body: 'some news content' }, + { id: 2, title: 'random post', body: 'some other content' }, + ], + }); + expect(posts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 1, isNews: true }), + expect.objectContaining({ id: 2, isNews: false }), + ]), + ); + }); }); From d9b1abfa04690934f034986e9ff06cb5535efafa Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Tue, 21 Oct 2025 08:43:01 -0700 Subject: [PATCH 2/2] update --- .../src/client/crud/dialects/base-dialect.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/client/crud/dialects/base-dialect.ts b/packages/runtime/src/client/crud/dialects/base-dialect.ts index f82b2ae1..258cf9fb 100644 --- a/packages/runtime/src/client/crud/dialects/base-dialect.ts +++ b/packages/runtime/src/client/crud/dialects/base-dialect.ts @@ -931,14 +931,12 @@ export abstract class BaseCrudDialect { ): SelectQueryBuilder { const fieldDef = requireField(this.schema, model, field); - if (!fieldDef.originModel) { - // field defined on this model - return query.select(() => this.fieldRef(model, field, modelAlias).as(field)); - } else { - // field defined on a delegate base, build a select with the origin model - // name (the model is already joined from outer query) - return this.buildSelectField(query, fieldDef.originModel, fieldDef.originModel, field); - } + // if field is defined on a delegate base, the base model is joined with its + // model name from outer query, so we should use it directly as the alias + const fieldModel = fieldDef.originModel ?? model; + const alias = fieldDef.originModel ?? modelAlias; + + return query.select(() => this.fieldRef(fieldModel, field, alias).as(field)); } buildDelegateJoin(