From 258cee0f500b717dbf0d278c3ebe954954cd6c20 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:10:08 -0700 Subject: [PATCH 1/2] fix: delegate count relation issue, default boolean value issue --- .../attribute-application-validator.ts | 6 ++ .../src/client/crud/dialects/base-dialect.ts | 21 ++--- .../src/client/crud/operations/base.ts | 20 +++-- .../src/client/helpers/schema-db-pusher.ts | 14 ++- .../test/v2-migrated/issue-2028.test.ts | 90 +++++++++++++++++++ .../test/v2-migrated/issue-2038.test.ts | 25 ++++++ .../test/v2-migrated/issue-2039.test.ts | 27 ++++++ .../test/v2-migrated/issue-2106.test.ts | 16 ++++ .../test/v2-migrated/issue-2246.test.ts | 79 ++++++++++++++++ .../test/v2-migrated/issue-2247.test.ts | 61 +++++++++++++ 10 files changed, 334 insertions(+), 25 deletions(-) create mode 100644 tests/regression/test/v2-migrated/issue-2028.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2038.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2039.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2106.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2246.test.ts create mode 100644 tests/regression/test/v2-migrated/issue-2247.test.ts diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index b5384196..82799d2c 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -22,6 +22,7 @@ import { import { getAllAttributes, getStringLiteral, + hasAttribute, isAuthOrAuthMemberAccess, isBeforeInvocation, isCollectionPredicate, @@ -364,6 +365,11 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstType === 'ContextType') { // ContextType is inferred from the attribute's container's type if (isDataField(attr.$container)) { + // If the field is Typed JSON, and the param is @default, the argument must be a string + const dstIsTypedJson = hasAttribute(attr.$container, '@json'); + if (dstIsTypedJson && param.default) { + return argResolvedType.decl === 'String'; + } dstIsArray = attr.$container.type.array; } } diff --git a/packages/runtime/src/client/crud/dialects/base-dialect.ts b/packages/runtime/src/client/crud/dialects/base-dialect.ts index 17d300ac..1b8b1e1c 100644 --- a/packages/runtime/src/client/crud/dialects/base-dialect.ts +++ b/packages/runtime/src/client/crud/dialects/base-dialect.ts @@ -991,15 +991,14 @@ export abstract class BaseCrudDialect { for (const [field, value] of Object.entries(selections.select)) { const fieldDef = requireField(this.schema, model, field); - const fieldModel = fieldDef.type; + const fieldModel = fieldDef.type as GetModels; let fieldCountQuery: SelectQueryBuilder; // join conditions const m2m = getManyToManyRelation(this.schema, model, field); if (m2m) { // many-to-many relation, count the join table - fieldCountQuery = eb - .selectFrom(fieldModel) + fieldCountQuery = this.buildModelSelect(fieldModel, fieldModel, value as any, false) .innerJoin(m2m.joinTable, (join) => join .onRef(`${m2m.joinTable}.${m2m.otherFkName}`, '=', `${fieldModel}.${m2m.otherPKName}`) @@ -1008,7 +1007,9 @@ export abstract class BaseCrudDialect { .select(eb.fn.countAll().as(`_count$${field}`)); } else { // build a nested query to count the number of records in the relation - fieldCountQuery = eb.selectFrom(fieldModel).select(eb.fn.countAll().as(`_count$${field}`)); + fieldCountQuery = this.buildModelSelect(fieldModel, fieldModel, value as any, false).select( + eb.fn.countAll().as(`_count$${field}`), + ); // join conditions const joinPairs = buildJoinPairs(this.schema, model, parentAlias, field, fieldModel); @@ -1017,18 +1018,6 @@ export abstract class BaseCrudDialect { } } - // merge _count filter - if ( - value && - typeof value === 'object' && - 'where' in value && - value.where && - typeof value.where === 'object' - ) { - const filter = this.buildFilter(fieldModel, fieldModel, value.where); - fieldCountQuery = fieldCountQuery.where(filter); - } - jsonObject[field] = fieldCountQuery; } diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index a33913d3..4193fdfa 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -822,16 +822,20 @@ export abstract class BaseOperationHandler { continue; } if (!(field in data)) { - if (typeof fields[field]?.default === 'object' && 'kind' in fields[field].default) { - const generated = this.evalGenerator(fields[field].default); + if (typeof fieldDef?.default === 'object' && 'kind' in fieldDef.default) { + const generated = this.evalGenerator(fieldDef.default); if (generated !== undefined) { - values[field] = generated; + values[field] = this.dialect.transformPrimitive( + generated, + fieldDef.type as BuiltinType, + !!fieldDef.array, + ); } - } else if (fields[field]?.updatedAt) { + } else if (fieldDef?.updatedAt) { // TODO: should this work at kysely level instead? values[field] = this.dialect.transformPrimitive(new Date(), 'DateTime', false); - } else if (fields[field]?.default !== undefined) { - let value = fields[field].default; + } else if (fieldDef?.default !== undefined) { + let value = fieldDef.default; if (fieldDef.type === 'Json') { // Schema uses JSON string for default value of Json fields if (fieldDef.array && Array.isArray(value)) { @@ -842,8 +846,8 @@ export abstract class BaseOperationHandler { } values[field] = this.dialect.transformPrimitive( value, - fields[field].type as BuiltinType, - !!fields[field].array, + fieldDef.type as BuiltinType, + !!fieldDef.array, ); } } diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 9e855398..a666b4d8 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -162,9 +162,21 @@ export class SchemaDbPusher { if (fieldDef.unique) { continue; } + if (fieldDef.originModel && fieldDef.originModel !== modelDef.name) { + // field is inherited from a base model, skip + continue; + } table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, [this.getColumnName(fieldDef)]); } else { - // multi-field constraint + // multi-field constraint, if any field is inherited from base model, skip + if ( + Object.keys(value).some((f) => { + const fDef = modelDef.fields[f]!; + return fDef.originModel && fDef.originModel !== modelDef.name; + }) + ) { + continue; + } table = table.addUniqueConstraint( `unique_${modelDef.name}_${key}`, Object.keys(value).map((f) => this.getColumnName(modelDef.fields[f]!)), diff --git a/tests/regression/test/v2-migrated/issue-2028.test.ts b/tests/regression/test/v2-migrated/issue-2028.test.ts new file mode 100644 index 00000000..cff51a9f --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2028.test.ts @@ -0,0 +1,90 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2028', async () => { + const db = await createTestClient( + ` +enum FooType { + Bar + Baz +} + +model User { + id String @id @default(cuid()) + userFolders UserFolder[] + @@allow('all', true) +} + +model Foo { + id String @id @default(cuid()) + type FooType + + userFolders UserFolder[] + + @@delegate(type) + @@allow('all', true) +} + +model Bar extends Foo { + name String +} + +model Baz extends Foo { + age Int +} + +model UserFolder { + id String @id @default(cuid()) + userId String + fooId String + + user User @relation(fields: [userId], references: [id]) + foo Foo @relation(fields: [fooId], references: [id]) + + @@unique([userId, fooId]) + @@allow('all', true) +} + `, + ); + + // Ensure we can query by the CompoundUniqueInput + const user = await db.user.create({ data: {} }); + const bar = await db.bar.create({ data: { name: 'bar' } }); + const baz = await db.baz.create({ data: { age: 1 } }); + + const userFolderA = await db.userFolder.create({ + data: { + userId: user.id, + fooId: bar.id, + }, + }); + + const userFolderB = await db.userFolder.create({ + data: { + userId: user.id, + fooId: baz.id, + }, + }); + + await expect( + db.userFolder.findUnique({ + where: { + userId_fooId: { + userId: user.id, + fooId: bar.id, + }, + }, + }), + ).resolves.toMatchObject(userFolderA); + + await expect( + db.userFolder.findUnique({ + where: { + userId_fooId: { + userId: user.id, + fooId: baz.id, + }, + }, + }), + ).resolves.toMatchObject(userFolderB); +}); diff --git a/tests/regression/test/v2-migrated/issue-2038.test.ts b/tests/regression/test/v2-migrated/issue-2038.test.ts new file mode 100644 index 00000000..61fb429a --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2038.test.ts @@ -0,0 +1,25 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2038', async () => { + const db = await createTestClient( + ` +model User { + id Int @id @default(autoincrement()) + flag Boolean + @@allow('all', true) +} + +model Post { + id Int @id @default(autoincrement()) + published Boolean @default(auth().flag) + @@allow('all', true) +} + `, + ); + + const authDb = db.$setAuth({ id: 1, flag: true }); + await expect(authDb.post.create({ data: {} })).resolves.toMatchObject({ + published: true, + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-2039.test.ts b/tests/regression/test/v2-migrated/issue-2039.test.ts new file mode 100644 index 00000000..ba62278d --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2039.test.ts @@ -0,0 +1,27 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2039', async () => { + const db = await createTestClient( + ` +type Foo { + a String +} + +model Bar { + id String @id @default(cuid()) + foo Foo @json @default("{ \\"a\\": \\"a\\" }") + fooList Foo[] @json @default("[{ \\"a\\": \\"b\\" }]") + @@allow('all', true) +} + `, + { provider: 'postgresql' }, + ); + + // Ensure default values are correctly set + await expect(db.bar.create({ data: {} })).resolves.toMatchObject({ + id: expect.any(String), + foo: { a: 'a' }, + fooList: [{ a: 'b' }], + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-2106.test.ts b/tests/regression/test/v2-migrated/issue-2106.test.ts new file mode 100644 index 00000000..af9afe96 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2106.test.ts @@ -0,0 +1,16 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2106', async () => { + const db = await createTestClient( + ` +model User { + id Int @id + age BigInt + @@allow('all', true) +} + `, + ); + + await expect(db.user.create({ data: { id: 1, age: 1n } })).toResolveTruthy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-2246.test.ts b/tests/regression/test/v2-migrated/issue-2246.test.ts new file mode 100644 index 00000000..d6ff576c --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2246.test.ts @@ -0,0 +1,79 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2246', async () => { + const db = await createTestClient( + ` +model Media { + id Int @id @default(autoincrement()) + title String + mediaType String + + @@delegate(mediaType) + @@allow('all', true) +} + +model Movie extends Media { + director Director @relation(fields: [directorId], references: [id]) + directorId Int + duration Int + rating String +} + +model Director { + id Int @id @default(autoincrement()) + name String + email String + movies Movie[] + + @@allow('all', true) +} + `, + ); + + await db.director.create({ + data: { + name: 'Christopher Nolan', + email: 'christopher.nolan@example.com', + movies: { + create: { + title: 'Inception', + duration: 148, + rating: 'PG-13', + }, + }, + }, + }); + + await expect( + db.director.findMany({ + include: { + movies: { + where: { title: 'Inception' }, + }, + }, + }), + ).resolves.toHaveLength(1); + + await expect( + db.director.findFirst({ + include: { + _count: { select: { movies: { where: { title: 'Inception' } } } }, + }, + }), + ).resolves.toMatchObject({ _count: { movies: 1 } }); + + await expect( + db.movie.findMany({ + where: { title: 'Interstellar' }, + }), + ).resolves.toHaveLength(0); + + await expect( + db.director.findFirst({ + include: { + _count: { select: { movies: { where: { title: 'Interstellar' } } } }, + }, + }), + ).resolves.toMatchObject({ _count: { movies: 0 } }); +}); diff --git a/tests/regression/test/v2-migrated/issue-2247.test.ts b/tests/regression/test/v2-migrated/issue-2247.test.ts new file mode 100644 index 00000000..6caf02a4 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-2247.test.ts @@ -0,0 +1,61 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 2247', async () => { + const db = await createTestClient( + ` +model User { + id String @id @default(cuid()) + employerId String? +} + +model Member { + id String @id @default(cuid()) + placeId String + place Place @relation(fields: [placeId], references: [id]) +} + +model Place { + id String @id @default(cuid()) + name String + placeType String @map("owner_type") + members Member[] + + @@delegate(placeType) + @@unique([name, placeType]) +} + +model Country extends Place { + regions Region[] + things Thing[] +} + +model Region extends Place { + countryId String + country Country @relation(fields: [countryId], references: [id]) + cities City[] +} + +model City extends Place { + regionId String + region Region @relation(fields: [regionId], references: [id]) +} + + +model Thing { + id String @id @default(cuid()) + countryId String + country Country @relation(fields: [countryId], references: [id]) + + @@allow('read', + country.members?[id == auth().employerId] + || country.regions?[members?[id == auth().employerId]] + || country.regions?[cities?[members?[id == auth().employerId]]] + ) +} + `, + ); + + const authDb = db.$setAuth({ id: '1', employerId: '1' }); + await expect(authDb.thing.findMany()).toResolveTruthy(); +}); From ce19b3f157222c5f1eb1403129765e2b9ba22ee1 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:19:17 -0700 Subject: [PATCH 2/2] address pr comments --- .../src/validators/attribute-application-validator.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index 82799d2c..d1319cf0 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -365,9 +365,9 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstType === 'ContextType') { // ContextType is inferred from the attribute's container's type if (isDataField(attr.$container)) { - // If the field is Typed JSON, and the param is @default, the argument must be a string + // If the field is Typed JSON, and the attribute is @default, the argument must be a string const dstIsTypedJson = hasAttribute(attr.$container, '@json'); - if (dstIsTypedJson && param.default) { + if (dstIsTypedJson && attr.decl.ref?.name === '@default') { return argResolvedType.decl === 'String'; } dstIsArray = attr.$container.type.array;