diff --git a/package.json b/package.json index b72b85dd3..9655ac03d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack", "packageManager": "pnpm@10.23.0", "type": "module", diff --git a/packages/auth-adapters/better-auth/package.json b/packages/auth-adapters/better-auth/package.json index 0b69b2049..b1656e41c 100644 --- a/packages/auth-adapters/better-auth/package.json +++ b/packages/auth-adapters/better-auth/package.json @@ -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": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f7dc0c589..1391e33a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/clients/client-helpers/package.json b/packages/clients/client-helpers/package.json index 0e30523ba..a8da05dbc 100644 --- a/packages/clients/client-helpers/package.json +++ b/packages/clients/client-helpers/package.json @@ -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": { diff --git a/packages/clients/tanstack-query/package.json b/packages/clients/tanstack-query/package.json index 020014b43..335e71bcb 100644 --- a/packages/clients/tanstack-query/package.json +++ b/packages/clients/tanstack-query/package.json @@ -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": { diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 50cc27880..4d07aa1c8 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/config/eslint-config/package.json b/packages/config/eslint-config/package.json index 66b864ba2..2a6274263 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.4.4", + "version": "3.4.5", "type": "module", "private": true, "license": "MIT" diff --git a/packages/config/typescript-config/package.json b/packages/config/typescript-config/package.json index 333a5e4c3..64977930a 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.4.4", + "version": "3.4.5", "private": true, "license": "MIT" } diff --git a/packages/config/vitest-config/package.json b/packages/config/vitest-config/package.json index c8b22184c..a302ad4a4 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.4.4", + "version": "3.4.5", "private": true, "license": "MIT", "exports": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 4de6891f5..b45cc8a10 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.4.4", + "version": "3.4.5", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index f4a05378f..41c420fb1 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -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, diff --git a/packages/language/package.json b/packages/language/package.json index ad73e4afe..85599be25 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -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": [ diff --git a/packages/orm/package.json b/packages/orm/package.json index d35875e55..ca38f2849 100644 --- a/packages/orm/package.json +++ b/packages/orm/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/orm", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack ORM", "type": "module", "scripts": { diff --git a/packages/orm/src/client/crud/dialects/base-dialect.ts b/packages/orm/src/client/crud/dialects/base-dialect.ts index 3ab2d4979..1f5102121 100644 --- a/packages/orm/src/client/crud/dialects/base-dialect.ts +++ b/packages/orm/src/client/crud/dialects/base-dialect.ts @@ -444,35 +444,28 @@ export abstract class BaseCrudDialect { 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; } } @@ -1400,6 +1393,15 @@ export abstract class BaseCrudDialect { // #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): Expression { + return this.eb.exists(innerQuery); + } + // #region abstract methods abstract get provider(): DataSourceProviderType; diff --git a/packages/orm/src/client/crud/dialects/mysql.ts b/packages/orm/src/client/crud/dialects/mysql.ts index e9ca2e4aa..6e444227b 100644 --- a/packages/orm/src/client/crud/dialects/mysql.ts +++ b/packages/orm/src/client/crud/dialects/mysql.ts @@ -177,6 +177,13 @@ export class MySqlCrudDialect extends LateralJoinDiale // #region other overrides + protected override buildExistsExpression(innerQuery: SelectQueryBuilder): Expression { + // 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): AliasableExpression { return this.eb.fn.coalesce(sql`JSON_ARRAYAGG(${arg})`, sql`JSON_ARRAY()`); } diff --git a/packages/orm/src/client/executor/temp-alias-transformer.ts b/packages/orm/src/client/executor/temp-alias-transformer.ts index 1a66a260a..fc742b23a 100644 --- a/packages/orm/src/client/executor/temp-alias-transformer.ts +++ b/packages/orm/src/client/executor/temp-alias-transformer.ts @@ -1,12 +1,37 @@ 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(); + 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(node: T): T { this.aliasMap.clear(); @@ -14,14 +39,31 @@ export class TempAliasTransformer extends OperationNodeTransformer { } 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); } } diff --git a/packages/orm/src/client/executor/zenstack-query-executor.ts b/packages/orm/src/client/executor/zenstack-query-executor.ts index b0aab7627..52afde140 100644 --- a/packages/orm/src/client/executor/zenstack-query-executor.ts +++ b/packages/orm/src/client/executor/zenstack-query-executor.ts @@ -633,10 +633,9 @@ In such cases, ZenStack cannot reliably determine the IDs of the mutated entitie } private processTempAlias(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) { diff --git a/packages/orm/src/client/options.ts b/packages/orm/src/client/options.ts index 111946d3b..e2622d6cf 100644 --- a/packages/orm/src/client/options.ts +++ b/packages/orm/src/client/options.ts @@ -199,8 +199,11 @@ export type ClientOptions = QueryOptions & { 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; diff --git a/packages/plugins/policy/package.json b/packages/plugins/policy/package.json index 5efc9d1a6..0f9a0c8b5 100644 --- a/packages/plugins/policy/package.json +++ b/packages/plugins/policy/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/plugin-policy", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Policy Plugin", "type": "module", "scripts": { diff --git a/packages/plugins/policy/src/policy-handler.ts b/packages/plugins/policy/src/policy-handler.ts index f42459bc4..968703957 100644 --- a/packages/plugins/policy/src/policy-handler.ts +++ b/packages/plugins/policy/src/policy-handler.ts @@ -920,6 +920,10 @@ export class PolicyHandler 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({ diff --git a/packages/schema/package.json b/packages/schema/package.json index 1f4ad8cca..16c77054f 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/schema", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Runtime Schema", "type": "module", "scripts": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fcb6049b9..6dcdb9948 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index 2473f284b..5c1370370 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -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; +} + export function isUniqueField(field: DataField) { if (hasAttribute(field, '@unique')) { return true; diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index a524755da..815403cac 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -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, @@ -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); } } @@ -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' ), ) @@ -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( diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 90f6ceafa..b10068a3f 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -45,6 +45,7 @@ import { ModelUtils } from '.'; import { getAttribute, getAuthDecl, + getDelegateOriginModel, getIdFields, hasAttribute, isDelegateModel, @@ -587,17 +588,14 @@ export class TsSchemaGenerator { if ( contextModel && // id fields are duplicated in inherited models - !isIdField(field, contextModel) && - field.$container !== contextModel && - isDelegateModel(field.$container) + !isIdField(field, contextModel) ) { - // field is inherited from delegate - objectFields.push( - ts.factory.createPropertyAssignment( - 'originModel', - ts.factory.createStringLiteral(field.$container.name), - ), - ); + const delegateOrigin = getDelegateOriginModel(field, contextModel); + if (delegateOrigin) { + objectFields.push( + ts.factory.createPropertyAssignment('originModel', ts.factory.createStringLiteral(delegateOrigin)), + ); + } } // discriminator diff --git a/packages/server/package.json b/packages/server/package.json index c2eba36cf..aed1b34e9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack automatic CRUD API handlers and server adapters", "type": "module", "scripts": { diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 9d8efb11c..83ec62bd0 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/testtools/src/client.ts b/packages/testtools/src/client.ts index 69513eeb4..c9d0ca8b3 100644 --- a/packages/testtools/src/client.ts +++ b/packages/testtools/src/client.ts @@ -238,6 +238,10 @@ export async function createTestClient( execSync('npx prisma db push --schema ./schema.prisma --skip-generate --force-reset', { cwd: workDir, stdio: options.debug ? 'inherit' : 'ignore', + env: { + ...process.env, + PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'true', + }, }); } else { await prepareDatabase(provider, dbName); diff --git a/packages/zod/package.json b/packages/zod/package.json index a9bf32efe..5da1cb6af 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.4.4", + "version": "3.4.5", "description": "ZenStack Zod integration", "type": "module", "scripts": { diff --git a/samples/orm/package.json b/samples/orm/package.json index 0e48042c4..05dfbba1c 100644 --- a/samples/orm/package.json +++ b/samples/orm/package.json @@ -1,6 +1,6 @@ { "name": "sample-orm", - "version": "3.4.4", + "version": "3.4.5", "description": "", "main": "index.js", "private": true, diff --git a/tests/e2e/package.json b/tests/e2e/package.json index bb8a70c7a..29ea61baf 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/package.json b/tests/regression/package.json index e27f0a55c..cdd511a7e 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -1,6 +1,6 @@ { "name": "regression", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { diff --git a/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts new file mode 100644 index 000000000..9d883200d --- /dev/null +++ b/tests/regression/test/issue-2351.test.ts @@ -0,0 +1,79 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2351 +describe('Regression for issue 2351', () => { + it('should correctly query delegate model that inherits from a model using a mixin abstract type', async () => { + const db = await createPolicyTestClient( + ` +type BaseEntity { + id String @id @default(cuid()) + createdOn DateTime @default(now()) + updatedOn DateTime @updatedAt + isDeleted Boolean @default(false) + isArchived Boolean @default(false) +} + +enum DataType { + DataText + DataNumber +} + +model RoutineData with BaseEntity { + dataType DataType + routineId String + Routine Routine @relation(fields: [routineId], references: [id]) + @@delegate(dataType) + @@allow('all', auth().id == Routine.userId) +} + +model Routine { + id String @id @default(cuid()) + userId String + User User @relation(fields: [userId], references: [id]) + data RoutineData[] + @@allow('all', true) +} + +model User { + id String @id @default(cuid()) + name String + routines Routine[] + @@allow('all', true) +} + +model DataText extends RoutineData { + textValue String +} + `, + { usePrismaPush: true }, + ); + + const user = await db.user.create({ + data: { + name: 'Test User', + }, + }); + + const routine = await db.routine.create({ + data: { + userId: user.id, + }, + }); + + const authDb = db.$setAuth({ id: user.id }); + const created = await authDb.dataText.create({ + data: { textValue: 'hello', routineId: routine.id }, + }); + expect(created.textValue).toBe('hello'); + expect(created.isDeleted).toBe(false); + expect(created.isArchived).toBe(false); + + const found = await authDb.dataText.findUnique({ + where: { id: created.id }, + }); + expect(found).not.toBeNull(); + expect(found!.textValue).toBe('hello'); + expect(found!.createdOn).toBeDefined(); + }); +}); diff --git a/tests/regression/test/issue-2424.test.ts b/tests/regression/test/issue-2424.test.ts new file mode 100644 index 000000000..0bfd3e4da --- /dev/null +++ b/tests/regression/test/issue-2424.test.ts @@ -0,0 +1,161 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue #2424', () => { + it('deep nested include with PolicyPlugin works with non-compact alias mode', async () => { + const db = await createPolicyTestClient( + ` +model Store { + id String @id + customerOrders CustomerOrder[] + productCatalogItems ProductCatalogItem[] + @@allow('all', true) +} + +model CustomerOrder { + id String @id + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + customerOrderPaymentSummary CustomerOrderPaymentSummary[] + @@allow('all', true) +} + +model CustomerOrderPaymentSummary { + id String @id + customerOrderId String + customerOrder CustomerOrder @relation(fields: [customerOrderId], references: [id], onDelete: Cascade) + customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[] + @@allow('all', true) +} + +model PaymentTransaction { + id String @id + customerOrderPaymentSummaryLine CustomerOrderPaymentSummaryLine[] + paymentTransactionLineItem PaymentTransactionLineItem[] + @@allow('all', true) +} + +model CustomerOrderPaymentSummaryLine { + customerOrderPaymentSummaryId String + lineIndex Int + paymentTransactionId String + customerOrderPaymentSummary CustomerOrderPaymentSummary @relation(fields: [customerOrderPaymentSummaryId], references: [id], onDelete: Cascade) + paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade) + @@id([customerOrderPaymentSummaryId, lineIndex]) + @@allow('all', true) +} + +model ProductCatalogItem { + storeId String + sku String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + paymentTransactionLineItem PaymentTransactionLineItem[] + @@id([storeId, sku]) + @@allow('all', true) +} + +model InventoryReservation { + id String @id + paymentTransactionLineItem PaymentTransactionLineItem[] + @@allow('all', true) +} + +model PaymentTransactionLineItem { + paymentTransactionId String + lineNumber Int + storeId String + productSku String + inventoryReservationId String? + paymentTransaction PaymentTransaction @relation(fields: [paymentTransactionId], references: [id], onDelete: Cascade) + productCatalogItem ProductCatalogItem @relation(fields: [storeId, productSku], references: [storeId, sku]) + inventoryReservation InventoryReservation? @relation(fields: [inventoryReservationId], references: [id], onDelete: SetNull) + @@id([paymentTransactionId, lineNumber]) + @@allow('all', true) +} + `, + { provider: 'postgresql', useCompactAliasNames: false }, + ); + + const rawDb = db.$unuseAll(); + + await rawDb.store.create({ data: { id: 'store_1' } }); + await rawDb.customerOrder.create({ data: { id: 'order_1', storeId: 'store_1' } }); + await rawDb.customerOrderPaymentSummary.create({ data: { id: 'summary_1', customerOrderId: 'order_1' } }); + await rawDb.paymentTransaction.create({ data: { id: 'payment_1' } }); + await rawDb.customerOrderPaymentSummaryLine.create({ + data: { + customerOrderPaymentSummaryId: 'summary_1', + lineIndex: 0, + paymentTransactionId: 'payment_1', + }, + }); + await rawDb.productCatalogItem.create({ data: { storeId: 'store_1', sku: 'sku_1' } }); + await rawDb.inventoryReservation.create({ data: { id: 'reservation_1' } }); + await rawDb.paymentTransactionLineItem.create({ + data: { + paymentTransactionId: 'payment_1', + lineNumber: 0, + storeId: 'store_1', + productSku: 'sku_1', + inventoryReservationId: 'reservation_1', + }, + }); + + const result = await db.customerOrderPaymentSummary.findUnique({ + where: { id: 'summary_1' }, + include: { + customerOrder: true, + customerOrderPaymentSummaryLine: { + include: { + paymentTransaction: { + include: { + paymentTransactionLineItem: { + include: { + productCatalogItem: true, + inventoryReservation: true, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(result).toMatchObject({ + id: 'summary_1', + customerOrder: { + id: 'order_1', + storeId: 'store_1', + }, + customerOrderPaymentSummaryLine: [ + { + customerOrderPaymentSummaryId: 'summary_1', + lineIndex: 0, + paymentTransactionId: 'payment_1', + paymentTransaction: { + id: 'payment_1', + paymentTransactionLineItem: [ + { + paymentTransactionId: 'payment_1', + lineNumber: 0, + storeId: 'store_1', + productSku: 'sku_1', + inventoryReservationId: 'reservation_1', + productCatalogItem: { + storeId: 'store_1', + sku: 'sku_1', + }, + inventoryReservation: { + id: 'reservation_1', + }, + }, + ], + }, + }, + ], + }); + + await db.$disconnect(); + }); +}); diff --git a/tests/regression/test/issue-2440.test.ts b/tests/regression/test/issue-2440.test.ts new file mode 100644 index 000000000..f61423ab2 --- /dev/null +++ b/tests/regression/test/issue-2440.test.ts @@ -0,0 +1,119 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// https://github.com/zenstackhq/zenstack/issues/2440 +describe('Regression for issue 2440', () => { + const schema = ` +model User { + id Int @id @default(autoincrement()) + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + value Int + userId Int + user User @relation(fields: [userId], references: [id]) +} + `; + + it('some filter should return users that have at least one matching post', async () => { + const db = await createTestClient(schema); + + // userA has posts with value 1 and 3 + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 1 }, + { title: 'p2', value: 3 }, + ], + }, + }, + }); + // userB has only a post with value 2 + const userB = await db.user.create({ data: { name: 'B', posts: { create: [{ title: 'p3', value: 2 }] } } }); + // userC has no posts + await db.user.create({ data: { name: 'C' } }); + + const result = await db.user.findMany({ + where: { posts: { some: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(userA.id); + + const result2 = await db.user.findMany({ where: { posts: { some: {} } }, orderBy: { id: 'asc' } }); + expect(result2).toHaveLength(2); + expect(result2.map((u: any) => u.id)).toEqual([userA.id, userB.id]); + }); + + it('none filter should return users that have no matching posts', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 1 }, + { title: 'p2', value: 3 }, + ], + }, + }, + }); + await db.user.create({ data: { name: 'B', posts: { create: [{ title: 'p3', value: 2 }] } } }); + const userC = await db.user.create({ data: { name: 'C' } }); + + const result = await db.user.findMany({ + where: { posts: { none: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + // userB (value 2, not > 2) and userC (no posts) have none with value > 2 + expect(result).toHaveLength(2); + const ids = result.map((u: any) => u.id); + expect(ids).not.toContain(userA.id); + expect(ids).toContain(userC.id); + }); + + it('every filter should return users where all posts match the condition', async () => { + const db = await createTestClient(schema); + + const userA = await db.user.create({ + data: { + name: 'A', + posts: { + create: [ + { title: 'p1', value: 3 }, + { title: 'p2', value: 5 }, + ], + }, + }, + }); + await db.user.create({ + data: { + name: 'B', + posts: { + create: [ + { title: 'p3', value: 2 }, + { title: 'p4', value: 4 }, + ], + }, + }, + }); + const userC = await db.user.create({ data: { name: 'C' } }); + + // userA: all posts have value > 2 (3 and 5) ✓ + // userB: has a post with value 2, not > 2 ✗ + // userC: no posts, every filter vacuously true ✓ + const result = await db.user.findMany({ + where: { posts: { every: { value: { gt: 2 } } } }, + orderBy: { id: 'asc' }, + }); + expect(result).toHaveLength(2); + expect(result.map((u: any) => u.id)).toEqual([userA.id, userC.id]); + }); +}); diff --git a/tests/regression/test/issue-2460.test.ts b/tests/regression/test/issue-2460.test.ts new file mode 100644 index 000000000..2ad48a147 --- /dev/null +++ b/tests/regression/test/issue-2460.test.ts @@ -0,0 +1,37 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +// createManyAndReturn fails with "Invariant failed: expecting a ValueNode" +// when rows in the batch have asymmetric columns (one row provides a field the other omits) +describe('Regression for issue #2460', () => { + it('createManyAndReturn with asymmetric optional fields across rows', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + role String + @@allow('all', true) +} + +model Item { + id Int @id @default(autoincrement()) + key String + note String? + @@allow('all', auth().role == 'admin') +} + `, + { provider: 'postgresql' }, + ); + + const user = await db.user.create({ data: { role: 'admin' } }); + + const result = await db.$setAuth(user).item.createManyAndReturn({ + data: [ + { key: 'a', note: 'hello' }, + { key: 'b' }, + ], + }); + + expect(result).toHaveLength(2); + }); +}); diff --git a/tests/runtimes/bun/package.json b/tests/runtimes/bun/package.json index 8513628ee..9c2bce7e6 100644 --- a/tests/runtimes/bun/package.json +++ b/tests/runtimes/bun/package.json @@ -1,6 +1,6 @@ { "name": "bun-e2e", - "version": "3.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": { diff --git a/tests/runtimes/edge-runtime/package.json b/tests/runtimes/edge-runtime/package.json index 48cc8cea8..52b9009f1 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.4.4", + "version": "3.4.5", "private": true, "type": "module", "scripts": {