diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 92494742a..74b6305a5 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -270,7 +270,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(); @@ -280,7 +280,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); }); } } @@ -302,7 +302,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; }; @@ -463,6 +463,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..4d97b685e 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -6,6 +6,7 @@ import { match, P } from 'ts-pattern'; import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; import type { OrArray } from '../../../utils/type-utils'; import { AggregateOperators, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants'; +import type { ClientContract } from '../../contract'; import type { BooleanFilter, BytesFilter, @@ -36,13 +37,38 @@ import { tmpAlias, } from '../../query-utils'; +/** + * Arguments for constructing a CRUD dialect: either the client executing the queries — schema + * and options are derived from it, and it's handed to computed field implementations — or a + * standalone schema/options pair for uses that have no client (e.g. output transformation). + */ +export type CrudDialectArgs = + | [client: ClientContract] + | [schema: Schema, options: ClientOptions]; + export abstract class BaseCrudDialect { protected eb = expressionBuilder(); - constructor( - protected readonly schema: Schema, - protected readonly options: ClientOptions, - ) {} + protected readonly schema: Schema; + protected readonly options: ClientOptions; + + /** + * The client executing the query. Unset only when the dialect was constructed from a + * standalone schema/options pair, in which case it cannot evaluate computed fields. + */ + protected readonly client: ClientContract | undefined; + + constructor(...args: CrudDialectArgs) { + if (args.length === 1) { + const [client] = args; + this.client = client; + this.schema = client.$schema; + this.options = client.$options; + } else { + [this.schema, this.options] = args; + this.client = undefined; + } + } // #region capability flags1 @@ -1660,9 +1686,12 @@ export abstract class BaseCrudDialect { if (!computer) { throw createConfigError(`Computed field "${field}" implementation not provided for model "${model}"`); } + // every query issued through the ORM builds the dialect from a client, and a dialect + // built from a standalone schema/options pair never inlines computed fields + invariant(this.client, `computed field "${field}" of model "${model}" needs a client to be evaluated`); // `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..f2e4aff1a 100644 --- a/packages/orm/src/client/crud/dialects/index.ts +++ b/packages/orm/src/client/crud/dialects/index.ts @@ -1,18 +1,33 @@ 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 type { BaseCrudDialect, CrudDialectArgs } from './base-dialect'; import { MySqlCrudDialect } from './mysql'; import { PostgresCrudDialect } from './postgresql'; import { SqliteCrudDialect } from './sqlite'; +/** + * Creates a CRUD dialect for the client's provider. Schema and options are taken from the + * client, which is also handed to computed field implementations, so prefer this overload + * whenever a client is available. + */ +export function getCrudDialect(client: ClientContract): BaseCrudDialect; + +/** + * Creates a CRUD dialect from a standalone schema/options pair, for uses that have no client + * (e.g. output transformation). Such a dialect cannot evaluate computed fields. + */ export function getCrudDialect( schema: Schema, options: ClientOptions, -): BaseCrudDialect { +): BaseCrudDialect; + +export function getCrudDialect(...args: CrudDialectArgs): BaseCrudDialect { + const schema = args.length === 1 ? args[0].$schema : args[0]; 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(...args)) + .with('postgresql', () => new PostgresCrudDialect(...args)) + .with('mysql', () => new MySqlCrudDialect(...args)) .exhaustive(); } diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index 2af95e2cd..498a431fc 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -14,16 +14,11 @@ import { import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError, createNotSupportedError } from '../../errors'; -import type { ClientOptions } from '../../options'; import { isTypeDef } from '../../query-utils'; 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); - } - override get provider() { return 'mysql' as const; } diff --git a/packages/orm/src/client/crud/dialects/postgresql.ts b/packages/orm/src/client/crud/dialects/postgresql.ts index 67887729d..75ee4f35e 100644 --- a/packages/orm/src/client/crud/dialects/postgresql.ts +++ b/packages/orm/src/client/crud/dialects/postgresql.ts @@ -13,9 +13,8 @@ import { parse as parsePostgresArray } from 'postgres-array'; import { AnyNullClass, DbNullClass, JsonNullClass } from '../../../common-types'; import type { NullsOrder, SortOrder } from '../../crud-types'; import { createInvalidInputError } from '../../errors'; -import type { ClientOptions } from '../../options'; import { isEnum, isTypeDef } from '../../query-utils'; -import type { FuzzyFilterOptions } from './base-dialect'; +import type { CrudDialectArgs, FuzzyFilterOptions } from './base-dialect'; import { LateralJoinDialectBase } from './lateral-join-dialect-base'; /** @@ -73,8 +72,8 @@ export class PostgresCrudDialect extends LateralJoinDi '@db.Boolean': 'boolean', }; - constructor(schema: Schema, options: ClientOptions) { - super(schema, options); + constructor(...args: CrudDialectArgs) { + super(...args); this.overrideTypeParsers(); } diff --git a/packages/orm/src/client/crud/operations/base.ts b/packages/orm/src/client/crud/operations/base.ts index 2ef4ca043..a9b9e572e 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.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..b4ac42d9c 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); 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 b92f4da84..2b5083c2c 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -98,10 +98,10 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { nameMapper ?? (client.$schema.provider.type === 'postgresql' || // postgres queries need to be schema-qualified schemaHasMappedNames(client.$schema) - ? new QueryNameMapper(client as unknown as ClientContract) + ? new QueryNameMapper(client.$contract) : undefined); - this.dialect = getCrudDialect(client.$schema, client.$options); + this.dialect = getCrudDialect(client.$contract); } /** @@ -212,7 +212,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, @@ -662,7 +662,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..29f60281f 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -283,16 +283,36 @@ 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`. + */ + 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/packages/plugins/policy/src/expression-transformer.ts b/packages/plugins/policy/src/expression-transformer.ts index 2beb5be73..b0037e040 100644 --- a/packages/plugins/policy/src/expression-transformer.ts +++ b/packages/plugins/policy/src/expression-transformer.ts @@ -133,7 +133,7 @@ export class ExpressionTransformer { private readonly eb = expressionBuilder(); constructor(private readonly client: ClientContract) { - this.dialect = getCrudDialect(this.schema, this.clientOptions); + this.dialect = getCrudDialect(this.client); } get schema() { diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index 7ebc3d669..b84c39cd4 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -75,7 +75,7 @@ export class PolicyHandler extends OperationNodeTransf private readonly options: PolicyPluginOptions = {}, ) { super(); - this.dialect = getCrudDialect(this.client.$schema, this.client.$options); + this.dialect = getCrudDialect(this.client); } // #region main entry point diff --git a/packages/plugins/soft-delete/src/plugin.ts b/packages/plugins/soft-delete/src/plugin.ts index a20cac974..5ba8347b4 100644 --- a/packages/plugins/soft-delete/src/plugin.ts +++ b/packages/plugins/soft-delete/src/plugin.ts @@ -62,7 +62,7 @@ class SoftDeleteHandler extends OperationNodeTransform constructor(private readonly client: ClientContract) { super(); - this.dialect = getCrudDialect(client.$schema, client.$options); + this.dialect = getCrudDialect(client); } async handle(node: RootOperationNode, proceed: ProceedKyselyQueryFunction) { diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 4b1b6ac28..2be62ad94 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -989,4 +989,41 @@ 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: { + // 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, + ); + + 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 }); + }); });