Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zenstack-v3",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack",
"packageManager": "pnpm@10.23.0",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/better-auth",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.4.4",
"version": "3.4.5",
"type": "module",
"author": {
"name": "ZenStack Team"
Expand Down
2 changes: 1 addition & 1 deletion packages/clients/client-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/client-helpers",
"version": "3.4.4",
"version": "3.4.5",
"description": "Helpers for implementing clients that consume ZenStack's CRUD service",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/clients/tanstack-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/tanstack-query",
"version": "3.4.4",
"version": "3.4.5",
"description": "TanStack Query Client for consuming ZenStack v3's CRUD service",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/common-helpers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/common-helpers",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack Common Helpers",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/config/eslint-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/eslint-config",
"version": "3.4.4",
"version": "3.4.5",
"type": "module",
"private": true,
"license": "MIT"
Expand Down
2 changes: 1 addition & 1 deletion packages/config/typescript-config/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/typescript-config",
"version": "3.4.4",
"version": "3.4.5",
"private": true,
"license": "MIT"
}
2 changes: 1 addition & 1 deletion packages/config/vitest-config/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/vitest-config",
"type": "module",
"version": "3.4.4",
"version": "3.4.5",
"private": true,
"license": "MIT",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion packages/create-zenstack/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-zenstack",
"version": "3.4.4",
"version": "3.4.5",
"description": "Create a new ZenStack project",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/ide/vscode/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zenstack-v3",
"publisher": "zenstack",
"version": "3.4.4",
"version": "3.4.5",
"displayName": "ZenStack V3 Language Tools",
"description": "VSCode extension for ZenStack (v3) ZModel language",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion packages/language/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/language",
"description": "ZenStack ZModel language specification",
"version": "3.4.4",
"version": "3.4.5",
"license": "MIT",
"author": "ZenStack Team",
"files": [
Expand Down
2 changes: 1 addition & 1 deletion packages/orm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/orm",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack ORM",
"type": "module",
"scripts": {
Expand Down
34 changes: 18 additions & 16 deletions packages/orm/src/client/crud/dialects/base-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,35 +444,28 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {
continue;
}

const countSelect = (negate: boolean) => {
const existsSelect = (negate: boolean) => {
const filter = this.buildFilter(relationModel, relationFilterSelectAlias, subPayload);
return (
this.eb
// the outer select is needed to avoid mysql's scope issue
.selectFrom(
this.buildSelectModel(relationModel, relationFilterSelectAlias)
.select(() => this.eb.fn.count(this.eb.lit(1)).as('$count'))
.where(buildPkFkWhereRefs(this.eb))
.where(() => (negate ? this.eb.not(filter) : filter))
.as('$sub'),
)
.select('$count')
);
const innerQuery = this.buildSelectModel(relationModel, relationFilterSelectAlias)
.select(this.eb.lit(1).as('_'))
.where(buildPkFkWhereRefs(this.eb))
.where(() => (negate ? this.eb.not(filter) : filter));
return this.buildExistsExpression(innerQuery);
};

switch (key) {
case 'some': {
result = this.and(result, this.eb(countSelect(false), '>', 0));
result = this.and(result, existsSelect(false));
break;
}

case 'every': {
result = this.and(result, this.eb(countSelect(true), '=', 0));
result = this.and(result, this.eb.not(existsSelect(true)));
break;
}

case 'none': {
result = this.and(result, this.eb(countSelect(false), '=', 0));
result = this.and(result, this.eb.not(existsSelect(false)));
break;
}
}
Expand Down Expand Up @@ -1400,6 +1393,15 @@ export abstract class BaseCrudDialect<Schema extends SchemaDef> {

// #endregion

/**
* Builds an EXISTS expression from an inner SELECT query.
* Can be overridden by dialects that need special handling (e.g., MySQL wraps
* in a derived table to avoid "can't specify target table for update in FROM clause").
*/
protected buildExistsExpression(innerQuery: SelectQueryBuilder<any, any, any>): Expression<SqlBool> {
return this.eb.exists(innerQuery);
}

// #region abstract methods

abstract get provider(): DataSourceProviderType;
Expand Down
7 changes: 7 additions & 0 deletions packages/orm/src/client/crud/dialects/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ export class MySqlCrudDialect<Schema extends SchemaDef> extends LateralJoinDiale

// #region other overrides

protected override buildExistsExpression(innerQuery: SelectQueryBuilder<any, any, any>): Expression<SqlBool> {
// MySQL doesn't allow referencing the target table of a DELETE/UPDATE in a subquery
// directly within the same statement. Wrapping in a derived table materializes the
// subquery, making it a separate virtual table that MySQL accepts.
return this.eb.exists(this.eb.selectFrom(innerQuery.as('$exists_sub')).select(this.eb.lit(1).as('_')));
}

protected buildArrayAgg(arg: Expression<any>): AliasableExpression<any> {
return this.eb.fn.coalesce(sql`JSON_ARRAYAGG(${arg})`, sql`JSON_ARRAY()`);
}
Expand Down
46 changes: 44 additions & 2 deletions packages/orm/src/client/executor/temp-alias-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,69 @@
import { IdentifierNode, OperationNodeTransformer, type OperationNode, type QueryId } from 'kysely';
import { TEMP_ALIAS_PREFIX } from '../query-utils';

type TempAliasTransformerMode = 'alwaysCompact' | 'compactLongNames';

type TempAliasTransformerOptions = {
mode?: TempAliasTransformerMode;
maxIdentifierLength?: number;
};

/**
* Kysely node transformer that replaces temporary aliases created during query construction with
* shorter names while ensuring the same temp alias gets replaced with the same name.
*/
export class TempAliasTransformer extends OperationNodeTransformer {
private aliasMap = new Map<string, string>();
private readonly textEncoder = new TextEncoder();
private readonly mode: TempAliasTransformerMode;
private readonly maxIdentifierLength: number;

constructor(options: TempAliasTransformerOptions = {}) {
super();
this.mode = options.mode ?? 'alwaysCompact';
// PostgreSQL limits identifier length to 63 bytes and silently truncates overlong aliases.
const maxIdentifierLength = options.maxIdentifierLength ?? 63;
if (
!Number.isFinite(maxIdentifierLength) ||
!Number.isInteger(maxIdentifierLength) ||
maxIdentifierLength <= 0
) {
throw new RangeError('maxIdentifierLength must be a positive integer');
}
this.maxIdentifierLength = maxIdentifierLength;
}

run<T extends OperationNode>(node: T): T {
this.aliasMap.clear();
return this.transformNode(node);
}

protected override transformIdentifier(node: IdentifierNode, queryId?: QueryId): IdentifierNode {
if (node.name.startsWith(TEMP_ALIAS_PREFIX)) {
if (!node.name.startsWith(TEMP_ALIAS_PREFIX)) {
return super.transformIdentifier(node, queryId);
}

let shouldCompact = false;
if (this.mode === 'alwaysCompact') {
shouldCompact = true;
} else {
// check if the alias name exceeds the max identifier length, and
// if so, compact it
const aliasByteLength = this.textEncoder.encode(node.name).length;
if (aliasByteLength > this.maxIdentifierLength) {
shouldCompact = true;
}
}

if (shouldCompact) {
let mapped = this.aliasMap.get(node.name);
if (!mapped) {
mapped = `$$t${this.aliasMap.size + 1}`;
this.aliasMap.set(node.name, mapped);
}
return IdentifierNode.create(mapped);
} else {
return super.transformIdentifier(node, queryId);
}
return super.transformIdentifier(node, queryId);
}
}
7 changes: 3 additions & 4 deletions packages/orm/src/client/executor/zenstack-query-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,10 +633,9 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie
}

private processTempAlias<Node extends RootOperationNode>(query: Node): Node {
if (this.options.useCompactAliasNames === false) {
return query;
}
return new TempAliasTransformer().run(query);
return new TempAliasTransformer({
mode: this.options.useCompactAliasNames === false ? 'compactLongNames' : 'alwaysCompact',
}).run(query);
}

private createClientForConnection(connection: DatabaseConnection, inTx: boolean) {
Expand Down
5 changes: 4 additions & 1 deletion packages/orm/src/client/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,11 @@ export type ClientOptions<Schema extends SchemaDef> = QueryOptions<Schema> & {
validateInput?: boolean;

/**
* Whether to use compact alias names (e.g., "$t1", "$t2") when transforming ORM queries to SQL.
* Whether to use compact alias names (e.g., "$$t1", "$$t2") when transforming ORM queries to SQL.
* Defaults to `true`.
*
* When set to `false`, original aliases are kept unless temporary aliases become too long for
* safe SQL identifier handling, in which case compact aliases are used as a fallback.
*/
useCompactAliasNames?: boolean;

Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/policy/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/plugin-policy",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack Policy Plugin",
"type": "module",
"scripts": {
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/policy/src/policy-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,10 @@ export class PolicyHandler<Schema extends SchemaDef> extends OperationNodeTransf
for (let i = 0; i < data.length; i++) {
const item = data[i]!;
if (typeof item === 'object' && item && 'kind' in item) {
if (item.kind === 'DefaultInsertValueNode') {
result.push({ node: ValueNode.create(null), raw: null });
continue;
}
const fieldDef = QueryUtils.requireField(this.client.$schema, model, fields[i]!);
invariant(item.kind === 'ValueNode', 'expecting a ValueNode');
result.push({
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/schema",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack Runtime Schema",
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/sdk",
"version": "3.4.4",
"version": "3.4.5",
"description": "ZenStack SDK",
"type": "module",
"scripts": {
Expand Down
30 changes: 30 additions & 0 deletions packages/sdk/src/model-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@ export function isDelegateModel(node: AstNode) {
return isDataModel(node) && hasAttribute(node, '@@delegate');
}

/**
* Returns all fields that physically belong to a model's table: its directly declared
* fields plus fields from its mixins (recursively).
*/
export function getOwnedFields(model: DataModel | TypeDef): DataField[] {
const fields: DataField[] = [...model.fields];
for (const mixin of model.mixins) {
if (mixin.ref) {
fields.push(...getOwnedFields(mixin.ref));
}
}
return fields;
}

/**
* Returns the name of the delegate base model that "owns" the given field in the context of
* `contextModel`. This handles both direct fields of delegate models and mixin fields that
* belong to a mixin used by a delegate base model.
*/
export function getDelegateOriginModel(field: DataField, contextModel: DataModel): string | undefined {
let base = contextModel.baseModel?.ref;
while (base) {
if (isDelegateModel(base) && getOwnedFields(base).some((f) => f.name === field.name)) {
return base.name;
}
base = base.baseModel?.ref;
}
return undefined;
Comment thread
ymc9 marked this conversation as resolved.
}

export function isUniqueField(field: DataField) {
if (hasAttribute(field, '@unique')) {
return true;
Expand Down
10 changes: 3 additions & 7 deletions packages/sdk/src/prisma/prisma-schema-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import {
import { AstUtils } from 'langium';
import { match } from 'ts-pattern';
import { ModelUtils } from '..';
import { DELEGATE_AUX_RELATION_PREFIX, getIdFields } from '../model-utils';
import { DELEGATE_AUX_RELATION_PREFIX, getDelegateOriginModel, getIdFields } from '../model-utils';
import {
AttributeArgValue,
ModelFieldType,
Expand Down Expand Up @@ -204,7 +204,7 @@ export class PrismaSchemaGenerator {
continue; // skip computed fields
}
// exclude non-id fields inherited from delegate
if (ModelUtils.isIdField(field, decl) || !this.isInheritedFromDelegate(field, decl)) {
if (ModelUtils.isIdField(field, decl) || !getDelegateOriginModel(field, decl)) {
this.generateModelField(model, field, decl);
}
}
Expand Down Expand Up @@ -311,7 +311,7 @@ export class PrismaSchemaGenerator {
// when building physical schema, exclude `@default` for id fields inherited from delegate base
!(
ModelUtils.isIdField(field, contextModel) &&
this.isInheritedFromDelegate(field, contextModel) &&
getDelegateOriginModel(field, contextModel) &&
attr.decl.$refText === '@default'
),
)
Expand All @@ -335,10 +335,6 @@ export class PrismaSchemaGenerator {
return AstUtils.streamAst(expr).some(isAuthInvocation);
}

private isInheritedFromDelegate(field: DataField, contextModel: DataModel) {
return field.$container !== contextModel && ModelUtils.isDelegateModel(field.$container);
}

private makeFieldAttribute(attr: DataFieldAttribute) {
const attrName = attr.decl.ref!.name;
return new PrismaFieldAttribute(
Expand Down
Loading
Loading