diff --git a/package.json b/package.json index 734bd0ec6..9ba93da23 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "zenstack-v3", "displayName": "ZenStack", "description": "ZenStack", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index e3c22a70a..251b7440c 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/better-auth", "displayName": "ZenStack Better Auth Adapter", "description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/cli/package.json b/packages/cli/package.json index 02727db0a..5528ed2da 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/cli", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index eed53f257..2365b05cd 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/client-helpers", "displayName": "ZenStack Client Helpers", "description": "Helpers for implementing clients that consume ZenStack's CRUD service", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/fetch-client/package.json b/packages/clients/fetch-client/package.json index 169a626b2..2a346c2f2 100644 --- a/packages/clients/fetch-client/package.json +++ b/packages/clients/fetch-client/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/fetch-client", "displayName": "ZenStack Fetch Client", "description": "Simple fetch-based client for consuming ZenStack's RPC-style CRUD API", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 0d3a46d85..f0797c59b 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack TanStack Query Integration", "description": "TanStack Query Client for consuming ZenStack v3's CRUD service", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index af0b998f3..71ea97760 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/common-helpers", "displayName": "ZenStack Common Helpers", "description": "ZenStack Common Helpers", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 1a2966071..3ea4179b8 100644 --- a/packages/config/eslint-config/package.json +++ b/packages/config/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/tsdown-config/package.json b/packages/config/tsdown-config/package.json index 1c81ac64f..ab173f37a 100644 --- a/packages/config/tsdown-config/package.json +++ b/packages/config/tsdown-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tsdown-config", - "version": "3.9.0", + "version": "3.9.1", "private": true, "type": "module", "license": "MIT", diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 694ac0e22..8a04ef805 100644 --- a/packages/config/typescript-config/package.json +++ b/packages/config/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.9.0", + "version": "3.9.1", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index 973f9b69a..df6894189 100644 --- a/packages/config/vitest-config/package.json +++ b/packages/config/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.9.0", + "version": "3.9.1", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 01d875959..7b8baef5e 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -2,7 +2,7 @@ "name": "create-zenstack", "displayName": "Create ZenStack", "description": "Create a new ZenStack project", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index f7196c9c1..b3aee168e 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack-v3", "publisher": "zenstack", - "version": "3.9.0", + "version": "3.9.1", "displayName": "ZenStack V3 Language Tools", "description": "VSCode extension for ZenStack (v3) ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index dcdeff32a..e515552dd 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/language", "displayName": "ZenStack Language Tooling", "description": "ZenStack ZModel language specification", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/package.json b/packages/orm/package.json index 9603b140b..491ded680 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/orm", "displayName": "ZenStack ORM", "description": "ZenStack ORM", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/orm/src/client/client-impl.ts b/packages/orm/src/client/client-impl.ts index 0fb4e4dbd..74b6305a5 100644 --- a/packages/orm/src/client/client-impl.ts +++ b/packages/orm/src/client/client-impl.ts @@ -50,6 +50,13 @@ type ExtResultFieldDef = { compute: (data: Record) => unknown; }; +/** + * Returns the name mapper held by a ZenStack executor, or undefined for a plain kysely one. + */ +function getExecutorNameMapper(executor: QueryExecutor | undefined) { + return executor instanceof ZenStackQueryExecutor ? executor.getNameMapper() : undefined; +} + /** * ZenStack ORM client. */ @@ -99,6 +106,16 @@ export class ClientImpl { baseClient.kyselyProps.dialect.createQueryCompiler(), baseClient.kyselyProps.dialect.createAdapter(), new DefaultConnectionProvider(baseClient.kyselyProps.driver), + [], + false, + // A name mapper is derived purely from `$schema` and `$options`, so it can be + // reused when neither changed - which is the case for derived clients like the + // one `$transaction` creates. Rebuilding it is O(models x fields). See #2773. + // Deliberately an identity check: `$use`/`$setOptions` and friends pass a new + // options object, and the mapper's dialect is built from those options. + baseClient.$schema === schema && baseClient.$options === options + ? getExecutorNameMapper(baseClient.kyselyProps.executor) + : undefined, ), }; this.kyselyRaw = baseClient.kyselyRaw; @@ -253,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(); @@ -263,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); }); } } @@ -285,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; }; @@ -446,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..5d71fe78f 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,13 @@ export abstract class BaseCrudDialect { if (!computer) { throw createConfigError(`Computed field "${field}" implementation not provided for model "${model}"`); } - // `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); + // 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 of a parameterized computed field (undefined + // otherwise), forwarded as the implementation's 3rd argument. The result is parenthesized + // as it gets embedded into larger expressions: `where: { isMine: true }` → `() = $n`. + return this.eb.parens(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 ed4f6f6b1..2b5083c2c 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -1,5 +1,5 @@ import { invariant } from '@zenstackhq/common-helpers'; -import type { ModelDef, SchemaDef, TypeDefDef } from '@zenstackhq/schema'; +import type { SchemaDef } from '@zenstackhq/schema'; import type { QueryId } from 'kysely'; import { AndNode, @@ -37,7 +37,7 @@ import { getCrudDialect } from '../crud/dialects'; import type { BaseCrudDialect } from '../crud/dialects/base-dialect'; import { createDBQueryError, createInternalError, ORMError } from '../errors'; import type { AfterEntityMutationCallback, OnKyselyQueryCallback } from '../plugin'; -import { requireIdFields, stripAlias } from '../query-utils'; +import { requireIdFields, schemaHasMappedNames, stripAlias } from '../query-utils'; import { QueryNameMapper } from './name-mapper'; import { TempAliasTransformer } from './temp-alias-transformer'; import type { ZenStackDriver } from './zenstack-driver'; @@ -86,28 +86,30 @@ export class ZenStackQueryExecutor extends DefaultQueryExecutor { private readonly connectionProvider: ConnectionProvider, plugins: KyselyPlugin[] = [], private suppressMutationHooks: boolean = false, + nameMapper?: QueryNameMapper, ) { super(compiler, adapter, connectionProvider, plugins); - if ( - 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); - } + // A `QueryNameMapper` is derived purely from the client's `$schema` and `$options`, and building + // it is O(models x fields) (plus an O(models x relations) pass for postgres). Reuse the one from + // the executor/client we're derived from when it was built from the same schema and options, + // otherwise every derived executor rebuilds whole-schema state. See issue #2773. + this.nameMapper = + nameMapper ?? + (client.$schema.provider.type === 'postgresql' || // postgres queries need to be schema-qualified + schemaHasMappedNames(client.$schema) + ? new QueryNameMapper(client.$contract) + : undefined); - this.dialect = getCrudDialect(client.$schema, client.$options); + this.dialect = getCrudDialect(client.$contract); } - private schemaHasMappedNames(schema: SchemaDef) { - const hasMapAttr = (decl: ModelDef | TypeDefDef) => { - if (decl.attributes?.some((attr) => attr.name === '@@map')) { - return true; - } - return Object.values(decl.fields).some((field) => field.attributes?.some((attr) => attr.name === '@map')); - }; - - return Object.values(schema.models).some(hasMapAttr) || Object.values(schema.typeDefs ?? []).some(hasMapAttr); + /** + * The name mapper built for this executor's schema, if the schema needs one. Exposed so that + * derived clients built from the same schema and options can reuse it instead of rebuilding it. + */ + getNameMapper() { + return this.nameMapper; } private get kysely() { @@ -210,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, @@ -660,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) { @@ -770,6 +772,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [...this.plugins, plugin], this.suppressMutationHooks, + this.nameMapper, ); } @@ -782,6 +785,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [...this.plugins, ...plugins], this.suppressMutationHooks, + this.nameMapper, ); } @@ -794,6 +798,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [plugin, ...this.plugins], this.suppressMutationHooks, + this.nameMapper, ); } @@ -806,6 +811,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie this.connectionProvider, [], this.suppressMutationHooks, + this.nameMapper, ); } @@ -818,6 +824,7 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie connectionProvider, this.plugins as KyselyPlugin[], this.suppressMutationHooks, + this.nameMapper, ); // replace client with a new one associated with the new executor newExecutor.client = this.client.withExecutor(newExecutor); 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/orm/src/client/query-utils.ts b/packages/orm/src/client/query-utils.ts index 5a4b146b4..7941b7faf 100644 --- a/packages/orm/src/client/query-utils.ts +++ b/packages/orm/src/client/query-utils.ts @@ -1,5 +1,13 @@ import { invariant } from '@zenstackhq/common-helpers'; -import { ExpressionUtils, type FieldDef, type GetModels, type ModelDef, type SchemaDef } from '@zenstackhq/schema'; +import { + type EnumDef, + ExpressionUtils, + type FieldDef, + type GetModels, + type ModelDef, + type SchemaDef, + type TypeDefDef, +} from '@zenstackhq/schema'; import { AliasNode, ColumnNode, @@ -30,6 +38,7 @@ interface SchemaLookupCache { model: Map; m2mRelation: Map>; m2mJoinTable?: Map; + hasMappedNames?: boolean; } const schemaLookupCache = new WeakMap(); @@ -58,6 +67,32 @@ export function getTypeDef(schema: SchemaDef, type: string) { return schema.typeDefs?.[type]; } +/** + * Whether any model, type def, or field in the schema carries `@@map`/`@map`. Answering it walks + * every model and field, and it is asked once per query-executor construction, so the (immutable) + * answer is memoized per schema alongside the other structural lookups. See issue #2773. + */ +export function schemaHasMappedNames(schema: SchemaDef) { + const cache = getSchemaLookupCache(schema); + if (cache.hasMappedNames === undefined) { + // `fields` is optional on `EnumDef` (required on the other two), hence the `?? {}`. + const hasMapAttr = (decl: ModelDef | TypeDefDef | EnumDef) => { + if (decl.attributes?.some((attr) => attr.name === '@@map')) { + return true; + } + return Object.values(decl.fields ?? {}).some((field) => + field.attributes?.some((attr) => attr.name === '@map'), + ); + }; + cache.hasMappedNames = + Object.values(schema.models).some(hasMapAttr) || + Object.values(schema.typeDefs ?? {}).some(hasMapAttr) || + // Enums carry name mapping too — `@@map` on the enum and `@map` on its members. + Object.values(schema.enums ?? {}).some(hasMapAttr); + } + return cache.hasMappedNames; +} + export function requireModel(schema: SchemaDef, model: string) { const modelDef = getModel(schema, model); if (!modelDef) { diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index f92344395..06e88e406 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-policy", "displayName": "ZenStack Access Policy Plugin", "description": "ZenStack plugin that enforces access control policies defined in the schema", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", 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/package.json b/packages/plugins/soft-delete/package.json index 6348682c5..07265a3d9 100644 --- a/packages/plugins/soft-delete/package.json +++ b/packages/plugins/soft-delete/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/plugin-soft-delete", "displayName": "ZenStack Soft Delete Plugin", "description": "ZenStack plugin that implements soft-delete by intercepting Kysely queries", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", 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/packages/schema/package.json b/packages/schema/package.json index 33c8a6332..fbcc63754 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/schema", "displayName": "ZenStack Schema Object Model", "description": "TypeScript representation of ZModel schema", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fc971cd5d..6b1f3cf7b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/sdk", "displayName": "ZenStack SDK", "description": "Utilities for building ZenStack plugins", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/server/package.json b/packages/server/package.json index 48147b1df..753bd3361 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/server", "displayName": "ZenStack Automatic CRUD Server", "description": "ZenStack automatic CRUD API handlers and server adapters for popular frameworks", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index db0ed7236..5665c0604 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/testtools", "displayName": "ZenStack Test Tools", "description": "ZenStack Test Tools", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/packages/zod/package.json b/packages/zod/package.json index 1dde08458..f7b90702e 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -2,7 +2,7 @@ "name": "@zenstackhq/zod", "displayName": "ZenStack Zod Integration", "description": "Automatically deriving Zod schemas from ZModel schemas", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "author": { "name": "ZenStack Team", diff --git a/samples/orm/package.json b/samples/orm/package.json index 692804323..a2dc38851 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.9.0", + "version": "3.9.1", "description": "", "main": "index.js", "private": true, diff --git a/samples/taskforge/package.json b/samples/taskforge/package.json index e821488cd..f47ce79cc 100644 --- a/samples/taskforge/package.json +++ b/samples/taskforge/package.json @@ -1,6 +1,6 @@ { "name": "taskforge", - "version": "3.9.0", + "version": "3.9.1", "type": "module", "private": true, "description": "A CLI for a team collaboration / project-tracking platform, built on ZenStack v3 (ORM) and better-auth.", diff --git a/tests/e2e/orm/client-api/computed-fields.test.ts b/tests/e2e/orm/client-api/computed-fields.test.ts index 4b1b6ac28..01f74c662 100644 --- a/tests/e2e/orm/client-api/computed-fields.test.ts +++ b/tests/e2e/orm/client-api/computed-fields.test.ts @@ -989,4 +989,82 @@ 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: { + // the dialect parenthesizes an inlined implementation itself; the explicit + // `eb.parens` here guards that an already-parenthesized one isn't wrapped twice + 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 }); + }); + + it('contains the precedence of an inlined computed field expression', async () => { + const db = await createTestClient( + ` +model Post { + id Int @id @default(autoincrement()) + authorId Int + isMine Boolean @computed + isSpecial Boolean @computed +} +`, + { + computedFields: { + Post: { + // top-level node is a binary operation, which is embedded into + // ` = $n` when the field is used as a boolean filter + isMine: (eb: any) => eb('authorId', '=', 1), + // top-level node is a logical combinator + isSpecial: (eb: any) => eb.or([eb('authorId', '=', 1), eb('id', '=', 2)]), + }, + }, + } as any, + ); + + await db.post.create({ data: { id: 1, authorId: 1 } }); + await db.post.create({ data: { id: 2, authorId: 2 } }); + await db.post.create({ data: { id: 3, authorId: 3 } }); + + const findIds = async (where: any) => + (await db.post.findMany({ where, orderBy: { id: 'asc' } })).map((r: any) => r.id); + + expect(await findIds({ isMine: true })).toEqual([1]); + expect(await findIds({ isMine: false })).toEqual([2, 3]); + expect(await findIds({ NOT: { isMine: true } })).toEqual([2, 3]); + expect(await findIds({ isSpecial: true })).toEqual([1, 2]); + + // reading the fields is unaffected + await expect(db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ + isMine: true, + isSpecial: true, + }); + }); }); diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 90154b029..4cdb8cc07 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.9.0", + "version": "3.9.1", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index a3ad79f92..6afa59680 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.9.0", + "version": "3.9.1", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-2773.test.ts b/tests/regression/test/issue-2773.test.ts new file mode 100644 index 000000000..f16c282a6 --- /dev/null +++ b/tests/regression/test/issue-2773.test.ts @@ -0,0 +1,161 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2773 +// +// `QueryNameMapper` is derived purely from the client's `$schema` and `$options`, but it was rebuilt +// for every derived executor/client. Building it is O(models x fields), plus an O(models x relations) +// pass on postgres, so on a large schema every `$transaction` paid that cost twice - once when +// `$transaction` derives a client, and once when the query derives a connection-scoped executor. +// +// These assert the invariant (the mapper is not rebuilt) rather than elapsed time, which would be +// both flaky and only an indirect proxy for it. + +const schema = ` +model Post { + id Int @id @default(autoincrement()) + title String @map("post_title") + + @@map("posts_table") +} +`; + +// Note: inside a transaction `$qb.getExecutor()` is kysely's own wrapping executor, so we read the +// ZenStack executor the client itself was built with. We read the underlying field rather than the +// accessor so that these assertions fail on a mapper-identity mismatch - the actual defect - rather +// than on a missing method. +function nameMapperOf(client: any) { + return client.kyselyProps.executor.nameMapper; +} + +describe('Regression for issue #2773', () => { + it('reuses the name mapper for the client derived by $transaction', async () => { + const db = await createTestClient(schema, { provider: 'postgresql' }); + const mapper = nameMapperOf(db); + expect(mapper).toBeDefined(); + + await db.$transaction(async (tx: any) => { + expect(nameMapperOf(tx)).toBe(mapper); + }); + }); + + it('reuses the name mapper for connection-scoped and plugin-derived executors', async () => { + const db = await createTestClient(schema, { provider: 'postgresql' }); + const executor = (db as any).kyselyProps.executor; + const mapper = executor.nameMapper; + expect(mapper).toBeDefined(); + + expect(executor.withoutPlugins().nameMapper).toBe(mapper); + expect(executor.withPlugin({}).nameMapper).toBe(mapper); + expect(executor.withPluginAtFront({}).nameMapper).toBe(mapper); + expect(executor.withPlugins([{}]).nameMapper).toBe(mapper); + expect(executor.withConnectionProvider(executor.connectionProvider).nameMapper).toBe(mapper); + }); + + it('still builds a mapper for a client derived with different options', async () => { + const db = await createTestClient(schema, { provider: 'postgresql' }); + const mapper = nameMapperOf(db); + + // `$use` derives a client with a NEW options object, and the mapper's dialect is built from + // options - so this one must not inherit the existing mapper. + const derived = db.$use({ id: 'noop', name: 'noop' } as any); + expect(nameMapperOf(derived)).toBeDefined(); + expect(nameMapperOf(derived)).not.toBe(mapper); + }); + + it('keeps working when the schema needs no mapper at all', async () => { + // sqlite + no @@map/@map means nameMapper stays undefined. Deciding that walks every model + // and field, and it is asked on every executor construction, so it is memoized per schema - + // this guards the path where nothing is threaded through. + const db = await createTestClient( + ` +model Item { + id Int @id @default(autoincrement()) + name String +} + `, + { provider: 'sqlite' }, + ); + expect(nameMapperOf(db)).toBeUndefined(); + + await db.item.create({ data: { name: 'a' } }); + const outside = await db.item.findMany(); + expect(outside).toHaveLength(1); + + await db.$transaction(async (tx: any) => { + expect(nameMapperOf(tx)).toBeUndefined(); + expect(await tx.item.findMany()).toHaveLength(1); + }); + }); + + // Enums carry name mapping too, and `schemaHasMappedNames` originally checked only models + // and type defs. On postgres the mapper is built unconditionally, which masked it; on any + // other provider a schema whose ONLY mapped name is on an enum got no mapper at all. + it('builds a mapper for a schema whose only mapped name is on an enum', async () => { + const db = await createTestClient( + ` +enum Status { + ACTIVE + ARCHIVED + + @@map('status_enum') +} + +model Item { + id Int @id @default(autoincrement()) + name String + status Status @default(ACTIVE) +} + `, + { provider: 'sqlite' }, + ); + + expect(nameMapperOf(db)).toBeDefined(); + + await db.item.create({ data: { name: 'a' } }); + expect(await db.item.findMany()).toHaveLength(1); + }); + + it('builds a mapper for a schema whose only mapped name is on an enum MEMBER', async () => { + const db = await createTestClient( + ` +enum Status { + ACTIVE @map('is_active') + ARCHIVED +} + +model Item { + id Int @id @default(autoincrement()) + name String + status Status @default(ACTIVE) +} + `, + { provider: 'sqlite' }, + ); + + expect(nameMapperOf(db)).toBeDefined(); + + await db.item.create({ data: { name: 'a' } }); + expect(await db.item.findMany()).toHaveLength(1); + }); + + it('applies @@map and @map identically inside and outside a transaction', async () => { + const db = await createTestClient(schema, { provider: 'postgresql' }); + await db.post.create({ data: { title: 'hello' } }); + + const outside = await db.post.findMany(); + expect(outside).toHaveLength(1); + expect(outside[0].title).toBe('hello'); + + const inside = await db.$transaction(async (tx: any) => tx.post.findMany()); + expect(inside).toEqual(outside); + + // the mapped table/column really are what the mapper produced + const raw = await db.$qb + .selectFrom('posts_table' as any) + .selectAll() + .execute(); + expect(raw).toHaveLength(1); + expect((raw[0] as any).post_title).toBe('hello'); + }); +}); diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 09cc3d301..50f87900c 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.9.0", + "version": "3.9.1", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index db7c89ae4..0e746b961 100644 --- a/tests/runtimes/edge-runtime/package.json +++ b/tests/runtimes/edge-runtime/package.json @@ -1,6 +1,6 @@ { "name": "edge-runtime-e2e", - "version": "3.9.0", + "version": "3.9.1", "private": true, "type": "module", "scripts": {