From 4a99088bcef82af53be24714c910319d579dd6ae Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:25:10 -0800 Subject: [PATCH 1/3] fix(sdk): correctly handle mixin fields for delegate model inheritance Fields inherited via a mixin type on a delegate base model were not getting their `originModel` set in the generated schema, causing the ORM to include them in the wrong table's INSERT statement. Introduces `getOwnedFields` and `getDelegateOriginModel` helpers in `model-utils.ts` and uses them in both `ts-schema-generator` and `prisma-schema-generator`, replacing the previous logic that only checked `field.$container` directly. Fixes #2351 Co-Authored-By: Claude Sonnet 4.6 --- packages/sdk/src/model-utils.ts | 30 +++++++ .../sdk/src/prisma/prisma-schema-generator.ts | 10 +-- packages/sdk/src/ts-schema-generator.ts | 18 ++--- packages/testtools/src/client.ts | 4 + tests/regression/test/issue-2351.test.ts | 79 +++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 tests/regression/test/issue-2351.test.ts diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index 2473f284b..e1cc43f0c 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).includes(field)) { + 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/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/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts new file mode 100644 index 000000000..fcd88d562 --- /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 { + TEXT + NUMBER +} + +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(); + }); +}); From ffa17203f3bc87fad32f11ac7dc7e60273852cfa Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:14:26 +0000 Subject: [PATCH 2/3] fix(test): add missing dataType field in issue-2351 regression test The test was failing on MySQL because the dataType enum field was required but not provided when creating DataText records. MySQL is stricter about enum validation than SQLite, causing 'Data truncated for column' errors. Co-authored-by: Yiming Cao --- tests/regression/test/issue-2351.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts index fcd88d562..ef1d551ef 100644 --- a/tests/regression/test/issue-2351.test.ts +++ b/tests/regression/test/issue-2351.test.ts @@ -63,7 +63,7 @@ model DataText extends RoutineData { const authDb = db.$setAuth({ id: user.id }); const created = await authDb.dataText.create({ - data: { textValue: 'hello', routineId: routine.id }, + data: { textValue: 'hello', routineId: routine.id, dataType: 'TEXT' }, }); expect(created.textValue).toBe('hello'); expect(created.isDeleted).toBe(false); From 956a64ddbea18974dd85916d4fcf52ea3eb28ff7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 17:30:46 +0000 Subject: [PATCH 3/3] fix(test): correct delegate discriminator enum values to match model names - Change DataType enum from TEXT/NUMBER to DataText/DataNumber - Remove explicit dataType field from test as it's auto-set by delegate discriminator - Fixes MySQL test failure: delegate discriminators must use model names Co-authored-by: Yiming Cao --- tests/regression/test/issue-2351.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/regression/test/issue-2351.test.ts b/tests/regression/test/issue-2351.test.ts index ef1d551ef..9d883200d 100644 --- a/tests/regression/test/issue-2351.test.ts +++ b/tests/regression/test/issue-2351.test.ts @@ -15,8 +15,8 @@ type BaseEntity { } enum DataType { - TEXT - NUMBER + DataText + DataNumber } model RoutineData with BaseEntity { @@ -63,7 +63,7 @@ model DataText extends RoutineData { const authDb = db.$setAuth({ id: user.id }); const created = await authDb.dataText.create({ - data: { textValue: 'hello', routineId: routine.id, dataType: 'TEXT' }, + data: { textValue: 'hello', routineId: routine.id }, }); expect(created.textValue).toBe('hello'); expect(created.isDeleted).toBe(false);