From eaafe95c123d47ca59bcb07d1f274668242cc8dc Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 4 Aug 2026 15:19:41 +0200 Subject: [PATCH 1/2] feat: provide the client in the computed field context Computed field implementations receive (eb, { modelAlias }, args) and cannot read per-client state such as the auth context set via $setAuth. Pass the executing client in the context, mirroring what custom function implementations already get through ZModelFunctionContext. Also replaces the scattered 'as unknown as ClientContract' casts with a single ClientImpl.$contract accessor. Co-Authored-By: Claude Fable 5 --- packages/orm/src/client/client-impl.ts | 17 ++++++++-- .../src/client/crud/dialects/base-dialect.ts | 6 +++- .../orm/src/client/crud/dialects/index.ts | 8 +++-- .../orm/src/client/crud/dialects/mysql.ts | 5 +-- .../src/client/crud/dialects/postgresql.ts | 5 +-- .../orm/src/client/crud/operations/base.ts | 2 +- .../orm/src/client/executor/name-mapper.ts | 2 +- .../executor/zenstack-query-executor.ts | 8 ++--- packages/orm/src/client/options.ts | 26 ++++++++++++-- .../orm/client-api/computed-fields.test.ts | 34 +++++++++++++++++++ 10 files changed, 94 insertions(+), 19 deletions(-) diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 0fb4e4dbd..be256f1c5 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -253,7 +253,7 @@ export class ClientImpl { ): Promise { if (this.kysely.isTransaction) { // proceed directly if already in a transaction - return callback(this as unknown as ClientContract); + return callback(this.$contract); } else { // otherwise, create a new transaction, clone the client, and execute the callback let txBuilder = this.kysely.transaction(); @@ -263,7 +263,7 @@ export class ClientImpl { return txBuilder.execute((tx) => { const txClient = new ClientImpl(this.schema, this.$options, this); txClient.kysely = tx; - return callback(txClient as unknown as ClientContract); + return callback(txClient.$contract); }); } } @@ -285,7 +285,7 @@ export class ClientImpl { const result: any[] = []; for (const promise of arg) { const cb = this.getPromiseCallback(promise); - result.push(await cb(txClient as unknown as ClientContract)); + result.push(await cb(txClient.$contract)); } return result; }; @@ -446,6 +446,17 @@ export class ClientImpl { return this.auth; } + /** + * This client viewed through its public typed contract. `ClientImpl` is intentionally + * untyped internally — the model accessors are added by the runtime proxy — so this + * getter is the single sanctioned bridge to `ClientContract`. The proxy invokes it + * with the proxy as `this` (`Reflect.get` with receiver), so the returned reference + * keeps the model accessors. + */ + get $contract(): ClientContract { + return this as unknown as ClientContract; + } + $setOptions>(options: Options): ClientContract { const newClient = new ClientImpl(this.schema, options as ClientOptions, this); // create a new validator to have a fresh schema cache, because options may change validation settings diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 07abff83f..9eb497fb4 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -15,6 +15,7 @@ import type { SortOrder, StringFilter, } from '../../crud-types'; +import type { ClientContract } from '../../contract'; import { createConfigError, createInvalidInputError, createNotSupportedError } from '../../errors'; import type { ClientOptions } from '../../options'; import { @@ -42,6 +43,9 @@ export abstract class BaseCrudDialect { constructor( protected readonly schema: Schema, protected readonly options: ClientOptions, + // the client executing the query; optional so the dialect can still be + // constructed standalone (e.g. for output transformation only) + protected readonly client?: ClientContract, ) {} // #region capability flags1 @@ -1662,7 +1666,7 @@ export abstract class BaseCrudDialect { } // `computedArgs` is the query-time args object for a parameterized computed // field (undefined otherwise); forwarded as the implementation's 3rd argument. - return computer(this.eb, { modelAlias }, computedArgs); + return computer(this.eb, { modelAlias, client: this.client }, computedArgs); } } diff --git a/packages/orm/src/client/crud/dialects/index.ts b/packages/orm/src/client/crud/dialects/index.ts index 8dd8d25ea..eea929206 100644 --- a/packages/orm/src/client/crud/dialects/index.ts +++ b/packages/orm/src/client/crud/dialects/index.ts @@ -1,5 +1,6 @@ import type { SchemaDef } from '@zenstackhq/schema'; import { match } from 'ts-pattern'; +import type { ClientContract } from '../../contract'; import type { ClientOptions } from '../../options'; import type { BaseCrudDialect } from './base-dialect'; import { MySqlCrudDialect } from './mysql'; @@ -9,10 +10,11 @@ import { SqliteCrudDialect } from './sqlite'; export function getCrudDialect( schema: Schema, options: ClientOptions, + client?: ClientContract, ): BaseCrudDialect { return match(schema.provider.type) - .with('sqlite', () => new SqliteCrudDialect(schema, options)) - .with('postgresql', () => new PostgresCrudDialect(schema, options)) - .with('mysql', () => new MySqlCrudDialect(schema, options)) + .with('sqlite', () => new SqliteCrudDialect(schema, options, client)) + .with('postgresql', () => new PostgresCrudDialect(schema, options, client)) + .with('mysql', () => new MySqlCrudDialect(schema, options, client)) .exhaustive(); } diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 2af95e2cd..9388f4b18 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -12,6 +12,7 @@ import { type SqlBool, } from 'kysely'; import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; +import type { ClientContract } from '../../contract'; import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError, createNotSupportedError } from '../../errors'; import type { ClientOptions } from '../../options'; @@ -20,8 +21,8 @@ import type { FuzzyFilterOptions } from './base-dialect'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; export class MySqlCrudDialect extends LateralJoinDialectBase { - constructor(schema: Schema, options: ClientOptions) { - super(schema, options); + constructor(schema: Schema, options: ClientOptions, client?: ClientContract) { + super(schema, options, client); } override get provider() { diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 67887729d..6461893ea 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -11,6 +11,7 @@ import { } from 'kysely'; import { parse as parsePostgresArray } from 'postgres-array'; import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; +import type { ClientContract } from '../../contract'; import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError } from '../../errors'; import type { ClientOptions } from '../../options'; @@ -73,8 +74,8 @@ export class PostgresCrudDialect extends LateralJoinDi '@db.Boolean': 'boolean', }; - constructor(schema: Schema, options: ClientOptions) { - super(schema, options); + constructor(schema: Schema, options: ClientOptions, client?: ClientContract) { + super(schema, options, client); this.overrideTypeParsers(); } diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 2ef4ca043..6e9e707e8 100644 --- a/packages/orm/src/client/crud/operations/base.ts +++ b/packages/orm/src/client/crud/operations/base.ts @@ -199,7 +199,7 @@ export abstract class BaseOperationHandler { protected readonly model: GetModels, protected readonly inputValidator: InputValidator, ) { - this.dialect = getCrudDialect(this.schema, this.client.$options); + this.dialect = getCrudDialect(this.schema, this.client.$options, this.client); } protected get schema() { diff --git a/packages/orm/src/client/executor/name-mapper.ts b/packages/orm/src/client/executor/name-mapper.ts index e37a946d1..667e677f5 100644 --- a/packages/orm/src/client/executor/name-mapper.ts +++ b/packages/orm/src/client/executor/name-mapper.ts @@ -62,7 +62,7 @@ export class QueryNameMapper extends OperationNodeTransformer { constructor(private readonly client: ClientContract) { super(); - this.dialect = getCrudDialect(client.$schema, client.$options); + this.dialect = getCrudDialect(client.$schema, client.$options, client); for (const [modelName, modelDef] of Object.entries(client.$schema.models)) { const mappedName = this.getMappedName(modelDef); if (mappedName) { diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index ed4f6f6b1..d1723ac24 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -93,10 +93,10 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { client.$schema.provider.type === 'postgresql' || // postgres queries need to be schema-qualified this.schemaHasMappedNames(client.$schema) ) { - this.nameMapper = new QueryNameMapper(client as unknown as ClientContract); + this.nameMapper = new QueryNameMapper(client.$contract); } - this.dialect = getCrudDialect(client.$schema, client.$options); + this.dialect = getCrudDialect(client.$schema, client.$options, client.$contract); } private schemaHasMappedNames(schema: SchemaDef) { @@ -210,7 +210,7 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { proceed = async (query: RootOperationNode) => { const _p = (q: RootOperationNode) => _proceed(q); const hookResult = await hook!({ - client: this.client as unknown as ClientContract, + client: this.client.$contract, schema: this.client.$schema, query, proceed: _p, @@ -660,7 +660,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie if (inTx) { innerClient.forceTransaction(); } - return innerClient as unknown as ClientContract; + return innerClient.$contract; } private andNodes(condition1: WhereNode | undefined, condition2: WhereNode | undefined) { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 4ab02fc1a..158392e29 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -283,16 +283,38 @@ export type OmitConfig = { }; }; +/** + * Context object passed to computed field implementations. + */ +export type ComputedFieldContext = { + /** + * The alias name that can be used to refer to the containing model + */ + modelAlias: string; + + /** + * The ZenStack client executing the query. Useful for reading per-client state, + * e.g. the auth context set via `$setAuth`. Undefined only when the CRUD dialect + * is constructed standalone rather than by the ORM runtime — queries issued + * through the client API always have it. + */ + client?: ClientContract; +}; + export type ComputedFieldsOptions = { [Model in GetModels as 'computedFields' extends keyof GetModel ? Uncapitalize : never]: { [Field in keyof Schema['models'][Model]['computedFields']]: Schema['models'][Model]['computedFields'][Field] extends infer Func - ? Func extends (...args: any[]) => infer R + ? Func extends (...args: infer Params) => infer R ? ( // inject a first parameter for expression builder p: ExpressionBuilder, Model>, - ...args: Parameters + // runtime-provided context (the generated stub only declares + // `modelAlias`; the runtime passes the full context) + context: ComputedFieldContext, + // query-time args of a parameterized field, from the stub + ...args: Params extends [any, ...infer Rest] ? Rest : [] ) => OperandExpression // wrap the return type with Kysely `OperandExpression` : never : never; diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 4b1b6ac28..1f93f318a 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -989,4 +989,38 @@ model User { }), ).toBeRejectedByValidation(['upperName']); }); + + it('provides the client in the computed field context', async () => { + const db = await createTestClient( + ` +model Post { + id Int @id @default(autoincrement()) + authorId Int + isMine Boolean @computed +} +`, + { + computedFields: { + Post: { + isMine: (eb: any, { client }: any) => eb('authorId', '=', client?.$auth?.id ?? -1), + }, + }, + } as any, + ); + + await db.post.create({ data: { id: 1, authorId: 1 } }); + await db.post.create({ data: { id: 2, authorId: 2 } }); + + // no auth set: nothing is mine + await expect(db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ isMine: false }); + + // the client derived with $setAuth carries its auth into the computed field + const authedDb = db.$setAuth({ id: 1 }); + await expect(authedDb.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ isMine: true }); + await expect(authedDb.post.findUnique({ where: { id: 2 } })).resolves.toMatchObject({ isMine: false }); + await expect(authedDb.post.findMany({ where: { isMine: true } })).resolves.toHaveLength(1); + + // the original client is unaffected + await expect(db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ isMine: false }); + }); }); From 771e2161beff6493aeafa444c364373a473aa8ec Mon Sep 17 00:00:00 2001 From: evgenovalov Date: Tue, 4 Aug 2026 18:18:54 +0200 Subject: [PATCH 2/2] test: parenthesize the computed field expression in the client-context test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare binary comparison embedded by the boolean where-filter renders as a chained '=' — a syntax error on postgres (sqlite tolerates it). Co-Authored-By: Claude Fable 5 --- tests/e2e/orm/client-api/computed-fields.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 1f93f318a..fc9202b18 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -1002,7 +1002,10 @@ model Post { { computedFields: { Post: { - isMine: (eb: any, { client }: any) => eb('authorId', '=', client?.$auth?.id ?? -1), + // parenthesized: the expression gets embedded into larger ones + // (e.g. `where: { isMine: true }` wraps it with `= true`), and an + // unparenthesized chained comparison is a syntax error on postgres + isMine: (eb: any, { client }: any) => eb.parens(eb('authorId', '=', client?.$auth?.id ?? -1)), }, }, } as any,