diff --git a/packages/language/package.json b/packages/language/package.json index 522cfa20..c6b487f2 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -11,6 +11,7 @@ "type": "module", "scripts": { "build": "pnpm langium:generate && tsc --noEmit && tsup-node", + "watch": "tsup-node --watch", "lint": "eslint src --ext ts", "langium:generate": "langium generate", "langium:generate:production": "langium generate --mode=production", diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index 85dc8e91..c49f2606 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -676,7 +676,7 @@ attribute @@allow(_ operation: String @@@completionHint(["'create'", "'read'", " * @param condition: a boolean expression that controls if the operation should be allowed. * @param override: a boolean value that controls if the field-level policy should override the model-level policy. */ -attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean, _ override: Boolean?) +// attribute @allow(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean, _ override: Boolean?) /** * Defines an access policy that denies a set of operations when the given condition is true. @@ -692,7 +692,7 @@ attribute @@deny(_ operation: String @@@completionHint(["'create'", "'read'", "' * @param operation: comma-separated list of "create", "read", "update", "delete". Use "all" to denote all operations. * @param condition: a boolean expression that controls if the operation should be denied. */ -attribute @deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean) +// attribute @deny(_ operation: String @@@completionHint(["'create'", "'read'", "'update'", "'delete'", "'all'"]), _ condition: Boolean) /** * Checks if the current user can perform the given operation on the given field. diff --git a/packages/language/src/zmodel-scope.ts b/packages/language/src/zmodel-scope.ts index 2fd8b37a..f4c06ef1 100644 --- a/packages/language/src/zmodel-scope.ts +++ b/packages/language/src/zmodel-scope.ts @@ -36,8 +36,8 @@ import { getAuthDecl, getRecursiveBases, isAuthInvocation, - isCollectionPredicate, isBeforeInvocation, + isCollectionPredicate, resolveImportUri, } from './utils'; @@ -75,7 +75,7 @@ export class ZModelScopeComputation extends DefaultScopeComputation { override processNode(node: AstNode, document: LangiumDocument, scopes: PrecomputedScopes) { super.processNode(node, document, scopes); - if (isDataModel(node)) { + if (isDataModel(node) || isTypeDef(node)) { // add base fields to the scope recursively const bases = getRecursiveBases(node); for (const base of bases) { diff --git a/packages/runtime/src/client/crud/operations/base.ts b/packages/runtime/src/client/crud/operations/base.ts index 65bdbbc2..2aca2980 100644 --- a/packages/runtime/src/client/crud/operations/base.ts +++ b/packages/runtime/src/client/crud/operations/base.ts @@ -939,15 +939,18 @@ export abstract class BaseOperationHandler { combinedWhere = Object.keys(combinedWhere).length > 0 ? { AND: [parentWhere, combinedWhere] } : parentWhere; } - // fill in automatically updated fields const modelDef = this.requireModel(model); let finalData = data; + + // fill in automatically updated fields + const autoUpdatedFields: string[] = []; for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { if (fieldDef.updatedAt) { if (finalData === data) { finalData = clone(data); } finalData[fieldName] = this.dialect.transformPrimitive(new Date(), 'DateTime', false); + autoUpdatedFields.push(fieldName); } } @@ -1027,7 +1030,13 @@ export abstract class BaseOperationHandler { } } - if (Object.keys(updateFields).length === 0) { + let hasFieldUpdate = Object.keys(updateFields).length > 0; + if (hasFieldUpdate) { + // check if only updating auto-updated fields, if so, we can skip the update + hasFieldUpdate = Object.keys(updateFields).some((f) => !autoUpdatedFields.includes(f)); + } + + if (!hasFieldUpdate) { // nothing to update, return the filter so that the caller can identify the entity return combinedWhere; } else { @@ -2073,22 +2082,11 @@ export abstract class BaseOperationHandler { } } - // Given a unique filter of a model, return the entity ids by trying to - // reused the filter if it's a complete id filter (without extra fields) - // otherwise, read the entity by the filter + // Given a unique filter of a model, load the entity and return its id fields private getEntityIds(kysely: ToKysely, model: GetModels, uniqueFilter: any) { - const idFields: string[] = requireIdFields(this.schema, model); - if ( - // all id fields are provided - idFields.every((f) => f in uniqueFilter && uniqueFilter[f] !== undefined) && - // no non-id filter exists - Object.keys(uniqueFilter).every((k) => idFields.includes(k)) - ) { - return uniqueFilter; - } - return this.readUnique(kysely, model, { where: uniqueFilter, + select: this.makeIdSelect(model), }); } diff --git a/packages/runtime/src/client/crud/validator/index.ts b/packages/runtime/src/client/crud/validator/index.ts index 90cc67e0..11d93350 100644 --- a/packages/runtime/src/client/crud/validator/index.ts +++ b/packages/runtime/src/client/crud/validator/index.ts @@ -976,9 +976,14 @@ export class InputValidator { ]) .optional(); + let upsertWhere = this.makeWhereSchema(fieldType, true); + if (!fieldDef.array) { + // to-one relation, can upsert without where clause + upsertWhere = upsertWhere.optional(); + } fields['upsert'] = this.orArray( z.strictObject({ - where: this.makeWhereSchema(fieldType, true), + where: upsertWhere, create: this.makeCreateDataSchema(fieldType, false, withoutFields), update: this.makeUpdateDataSchema(fieldType, withoutFields), }), diff --git a/packages/testtools/src/client.ts b/packages/testtools/src/client.ts index fcb1b1ec..cb3fded5 100644 --- a/packages/testtools/src/client.ts +++ b/packages/testtools/src/client.ts @@ -58,7 +58,6 @@ export async function createTestClient( const provider = options?.provider ?? getTestDbProvider() ?? 'sqlite'; const dbName = options?.dbName ?? getTestDbName(provider); - console.log(`Using provider: ${provider}, db: ${dbName}`); const dbUrl = provider === 'sqlite' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 740f983e..9236661a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,6 +590,9 @@ importers: '@zenstackhq/testtools': specifier: workspace:* version: link:../../packages/testtools + decimal.js: + specifier: ^10.4.3 + version: 10.4.3 devDependencies: '@zenstackhq/cli': specifier: workspace:* diff --git a/tests/e2e/orm/client-api/update.test.ts b/tests/e2e/orm/client-api/update.test.ts index ab39d706..6f059c5a 100644 --- a/tests/e2e/orm/client-api/update.test.ts +++ b/tests/e2e/orm/client-api/update.test.ts @@ -38,7 +38,8 @@ describe('Client update tests', () => { email: user.email, name: user.name, }); - expect(updated.updatedAt.getTime()).toBeGreaterThan(user.updatedAt.getTime()); + // should not update updatedAt + expect(updated.updatedAt.getTime()).toEqual(user.updatedAt.getTime()); // id as filter updated = await client.user.update({ @@ -114,6 +115,21 @@ describe('Client update tests', () => { ).resolves.toMatchObject({ id: 'user2' }); }); + it('does not update updatedAt if no other scalar fields are updated', async () => { + const user = await createUser(client, 'u1@test.com'); + const originalUpdatedAt = user.updatedAt; + + await client.user.update({ + where: { id: user.id }, + data: { + posts: { create: { title: 'Post1' } }, + }, + }); + + const updatedUser = await client.user.findUnique({ where: { id: user.id } }); + expect(updatedUser?.updatedAt).toEqual(originalUpdatedAt); + }); + it('works with numeric incremental update', async () => { await createUser(client, 'u1@test.com', { profile: { create: { id: '1', bio: 'bio' } }, diff --git a/tests/e2e/orm/policy/crud/read.test.ts b/tests/e2e/orm/policy/crud/read.test.ts index d57d6385..652e9f1e 100644 --- a/tests/e2e/orm/policy/crud/read.test.ts +++ b/tests/e2e/orm/policy/crud/read.test.ts @@ -294,7 +294,7 @@ model Bar { await db.$unuseAll().foo.create({ data: { id: 1 } }); await expect(db.foo.findMany()).resolves.toHaveLength(0); - await db.foo.update({ where: { id: 1 }, data: { bar: { create: { id: 1, y: 0 } } } }); + await db.$unuseAll().foo.update({ where: { id: 1 }, data: { bar: { create: { id: 1, y: 0 } } } }); await expect(db.foo.findMany()).resolves.toHaveLength(1); }); @@ -321,7 +321,7 @@ model Bar { await db.$unuseAll().foo.create({ data: { id: 1, bars: { create: [{ id: 1, y: 0 }] } } }); await expect(db.foo.findMany()).resolves.toHaveLength(0); - await db.foo.update({ where: { id: 1 }, data: { bars: { create: { id: 2, y: 1 } } } }); + await db.$unuseAll().foo.update({ where: { id: 1 }, data: { bars: { create: { id: 2, y: 1 } } } }); await expect(db.foo.findMany()).resolves.toHaveLength(1); }); diff --git a/tests/e2e/orm/policy/migrated/omit.test.ts b/tests/e2e/orm/policy/migrated/omit.test.ts index 84761f5b..c32a44f4 100644 --- a/tests/e2e/orm/policy/migrated/omit.test.ts +++ b/tests/e2e/orm/policy/migrated/omit.test.ts @@ -10,7 +10,7 @@ describe('prisma omit', () => { name String profile Profile? age Int - value Int @allow('read', age > 20) + value Int @@allow('all', age > 18) } diff --git a/tests/regression/package.json b/tests/regression/package.json index 576b870e..9bf71b47 100644 --- a/tests/regression/package.json +++ b/tests/regression/package.json @@ -8,13 +8,14 @@ "test": "pnpm generate && tsc && vitest run" }, "dependencies": { - "@zenstackhq/testtools": "workspace:*" + "@zenstackhq/testtools": "workspace:*", + "decimal.js": "^10.4.3" }, "devDependencies": { "@zenstackhq/cli": "workspace:*", - "@zenstackhq/sdk": "workspace:*", "@zenstackhq/language": "workspace:*", "@zenstackhq/runtime": "workspace:*", + "@zenstackhq/sdk": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", "@zenstackhq/vitest-config": "workspace:*" } diff --git a/tests/regression/test/v2-migrated/issue-657.test.ts b/tests/regression/test/v2-migrated/issue-657.test.ts new file mode 100644 index 00000000..fc73bc31 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-657.test.ts @@ -0,0 +1,30 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import Decimal from 'decimal.js'; +import { expect, it } from 'vitest'; + +// TODO: zod support +it.skip('verifies issue 657', async () => { + const { zodSchemas } = await createTestClient(` +model Foo { + id Int @id @default(autoincrement()) + intNumber Int @gt(0) + floatNumber Float @gt(0) + decimalNumber Decimal @gt(0.1) @lte(10) +} + `); + + const schema = zodSchemas.models.FooUpdateSchema; + expect(schema.safeParse({ intNumber: 0 }).success).toBeFalsy(); + expect(schema.safeParse({ intNumber: 1 }).success).toBeTruthy(); + expect(schema.safeParse({ floatNumber: 0 }).success).toBeFalsy(); + expect(schema.safeParse({ floatNumber: 1.1 }).success).toBeTruthy(); + expect(schema.safeParse({ decimalNumber: 0 }).success).toBeFalsy(); + expect(schema.safeParse({ decimalNumber: '0' }).success).toBeFalsy(); + expect(schema.safeParse({ decimalNumber: new Decimal(0) }).success).toBeFalsy(); + expect(schema.safeParse({ decimalNumber: 11 }).success).toBeFalsy(); + expect(schema.safeParse({ decimalNumber: '11.123456789' }).success).toBeFalsy(); + expect(schema.safeParse({ decimalNumber: new Decimal('11.123456789') }).success).toBeFalsy(); + expect(schema.safeParse({ decimalNumber: 10 }).success).toBeTruthy(); + expect(schema.safeParse({ decimalNumber: '10' }).success).toBeTruthy(); + expect(schema.safeParse({ decimalNumber: new Decimal('10') }).success).toBeTruthy(); +}); diff --git a/tests/regression/test/v2-migrated/issue-665.test.ts b/tests/regression/test/v2-migrated/issue-665.test.ts new file mode 100644 index 00000000..0f5d0457 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-665.test.ts @@ -0,0 +1,38 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: field-level policy support +it.skip('verifies issue 665', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + admin Boolean @default(false) + username String @unique @allow("all", auth() == this) @allow("all", auth().admin) + password String @password @default("") @allow("all", auth() == this) @allow("all", auth().admin) + firstName String @default("") + lastName String @default("") + + @@allow('all', true) +} + `, + ); + + await db.$unuseAll().user.create({ data: { id: 1, username: 'test', password: 'test', admin: true } }); + + // admin + let r = await db.$setAuth({ id: 1, admin: true }).user.findFirst(); + expect(r.username).toEqual('test'); + + // owner + r = await db.$setAuth({ id: 1 }).user.findFirst(); + expect(r.username).toEqual('test'); + + // anonymous + r = await db.$setAuth({ id: 0 }).user.findFirst(); + expect(r.username).toBeUndefined(); + + // non-owner + r = await db.$setAuth({ id: 2 }).user.findFirst(); + expect(r.username).toBeUndefined(); +}); diff --git a/tests/regression/test/v2-migrated/issue-674.test.ts b/tests/regression/test/v2-migrated/issue-674.test.ts new file mode 100644 index 00000000..03ef6cec --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-674.test.ts @@ -0,0 +1,14 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 674', async () => { + await loadSchema( + ` +model Foo { + id Int @id +} + +enum MyUnUsedEnum { ABC CDE @@map('my_unused_enum') } + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-689.test.ts b/tests/regression/test/v2-migrated/issue-689.test.ts new file mode 100644 index 00000000..d62922ec --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-689.test.ts @@ -0,0 +1,71 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 689', async () => { + const db = await createPolicyTestClient( + ` + model UserRole { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + role String + + @@allow('all', true) + } + + model User { + id Int @id @default(autoincrement()) + userRole UserRole[] + deleted Boolean @default(false) + + @@allow('create,read', true) + @@allow('read', auth() == this) + @@allow('read', userRole?[user == auth() && 'Admin' == role]) + @@allow('read', userRole?[user == auth()]) + } + `, + ); + + const rawDb = db.$unuseAll(); + + await rawDb.user.create({ + data: { + id: 1, + userRole: { + create: [ + { id: 1, role: 'Admin' }, + { id: 2, role: 'Student' }, + ], + }, + }, + }); + + await rawDb.user.create({ + data: { + id: 2, + userRole: { + connect: { id: 1 }, + }, + }, + }); + + const c1 = await rawDb.user.count({ + where: { + userRole: { + some: { role: 'Student' }, + }, + NOT: { deleted: true }, + }, + }); + + const c2 = await db.user.count({ + where: { + userRole: { + some: { role: 'Student' }, + }, + NOT: { deleted: true }, + }, + }); + + expect(c1).toEqual(c2); +}); diff --git a/tests/regression/test/v2-migrated/issue-703.test.ts b/tests/regression/test/v2-migrated/issue-703.test.ts new file mode 100644 index 00000000..73fdc7f5 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-703.test.ts @@ -0,0 +1,26 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +// TODO: field-level policy support +it.skip('verifies issue 703', async () => { + await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + name String? + admin Boolean @default(false) + + companiesWorkedFor Company[] + + username String @unique @allow("all", auth() == this) @allow('read', companiesWorkedFor?[owner == auth()]) @allow("all", auth().admin) + } + + model Company { + id Int @id @default(autoincrement()) + name String? + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + } + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-714.test.ts b/tests/regression/test/v2-migrated/issue-714.test.ts new file mode 100644 index 00000000..854a5c19 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-714.test.ts @@ -0,0 +1,145 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 714', async () => { + const db = await createPolicyTestClient( + ` + model User { + id Int @id @default(autoincrement()) + username String @unique + + employedBy CompanyUser[] + properties PropertyUser[] + companies Company[] + + @@allow('all', true) + } + + model Company { + id Int @id @default(autoincrement()) + name String + + companyUsers CompanyUser[] + propertyUsers User[] + properties Property[] + + @@allow('all', true) + } + + model CompanyUser { + company Company @relation(fields: [companyId], references: [id]) + companyId Int + user User @relation(fields: [userId], references: [id]) + userId Int + + dummyField String + + @@id([companyId, userId]) + + @@allow('all', true) + } + + enum PropertyUserRoleType { + Owner + Administrator + } + + model PropertyUserRole { + id Int @id @default(autoincrement()) + type PropertyUserRoleType + + user PropertyUser @relation(fields: [userId], references: [id]) + userId Int + + @@allow('all', true) + } + + model PropertyUser { + id Int @id @default(autoincrement()) + dummyField String + + property Property @relation(fields: [propertyId], references: [id]) + propertyId Int + user User @relation(fields: [userId], references: [id]) + userId Int + + roles PropertyUserRole[] + + @@unique([propertyId, userId]) + + @@allow('all', true) + } + + model Property { + id Int @id @default(autoincrement()) + name String + + users PropertyUser[] + company Company @relation(fields: [companyId], references: [id]) + companyId Int + + @@allow('all', true) + } + `, + { usePrismaPush: true }, + ); + + await db.user.create({ + data: { + username: 'test@example.com', + }, + }); + + await db.company.create({ + data: { + name: 'My Company', + companyUsers: { + create: { + dummyField: '', + user: { + connect: { + id: 1, + }, + }, + }, + }, + propertyUsers: { + connect: { + id: 1, + }, + }, + properties: { + create: [ + { + name: 'Test', + }, + ], + }, + }, + }); + + await db.property.update({ + data: { + users: { + create: { + dummyField: '', + roles: { + createMany: { + data: { + type: 'Owner', + }, + }, + }, + user: { + connect: { + id: 1, + }, + }, + }, + }, + }, + where: { + id: 1, + }, + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-735.test.ts b/tests/regression/test/v2-migrated/issue-735.test.ts new file mode 100644 index 00000000..0739f5d8 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-735.test.ts @@ -0,0 +1,19 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 735', async () => { + await loadSchema( + ` + model MyModel { + id String @id @default(cuid()) + view String + import Int + } + + model view { + id String @id @default(cuid()) + name String + } + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-756.test.ts b/tests/regression/test/v2-migrated/issue-756.test.ts new file mode 100644 index 00000000..85b004a6 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-756.test.ts @@ -0,0 +1,31 @@ +import { loadSchemaWithError } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 756', async () => { + await loadSchemaWithError( + ` + generator client { + provider = "prisma-client-js" + } + + datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + } + + model User { + id Int @id @default(autoincrement()) + email Int + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + author User? @relation(fields: [authorId], references: [id]) + authorId Int + @@allow('all', auth().posts.authorId == authorId) + } + `, + `Could not resolve reference to MemberAccessTarget named 'authorId'.`, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-764.test.ts b/tests/regression/test/v2-migrated/issue-764.test.ts new file mode 100644 index 00000000..404616fb --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-764.test.ts @@ -0,0 +1,46 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 764', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + + post Post? @relation(fields: [postId], references: [id]) + postId Int? + + @@allow('all', true) +} + +model Post { + id Int @id @default(autoincrement()) + title String + User User[] + + @@allow('all', true) +} + `, + ); + + const user = await db.$unuseAll().user.create({ + data: { name: 'Me' }, + }); + + await db.user.update({ + where: { id: user.id }, + data: { + post: { + upsert: { + create: { + title: 'Hello World', + }, + update: { + title: 'Hello World', + }, + }, + }, + }, + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-765.test.ts b/tests/regression/test/v2-migrated/issue-765.test.ts new file mode 100644 index 00000000..f4cc959b --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-765.test.ts @@ -0,0 +1,35 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 765', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + name String + + post Post? @relation(fields: [postId], references: [id]) + postId Int? + + @@allow('all', true) +} + +model Post { + id Int @id @default(autoincrement()) + title String + User User[] + + @@allow('all', true) +} + `, + ); + + const r = await db.user.create({ + data: { + name: 'Me', + post: undefined, + }, + }); + expect(r.name).toBe('Me'); + expect(r.post).toBeUndefined(); +}); diff --git a/tests/regression/test/v2-migrated/issue-804.test.ts b/tests/regression/test/v2-migrated/issue-804.test.ts new file mode 100644 index 00000000..fcb1ab4d --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-804.test.ts @@ -0,0 +1,33 @@ +import { loadSchemaWithError } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 804', async () => { + await loadSchemaWithError( + ` + generator client { + provider = "prisma-client-js" + } + + datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + } + + model User { + id Int @id @default(autoincrement()) + email Int + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + author User? @relation(fields: [authorId], references: [id]) + authorId Int + published Boolean + + @@allow('all', auth().posts?[published] == 'TRUE') + } + `, + 'incompatible operand types', + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-811.test.ts b/tests/regression/test/v2-migrated/issue-811.test.ts new file mode 100644 index 00000000..384b3d19 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-811.test.ts @@ -0,0 +1,70 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 811', async () => { + const db = await createPolicyTestClient( + ` + model Membership { + id String @id @default(uuid()) + role String @default('STANDARD') + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String @unique + + @@auth + @@allow('create,update,delete', auth().role == 'ADMIN') + @@allow('update', auth() == this) + @@allow('read', true) + } + model User { + id String @id @default(uuid()) + profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) + profileId String @unique + memberships Membership[] + + @@allow('create,update,delete', auth().role == 'ADMIN') + @@allow('update', id == auth().userId) + @@allow('read', true) + } + model Profile { + id String @id @default(uuid()) + firstName String + users User[] + + @@allow('create,update,delete', auth().role == 'ADMIN') + @@allow('update', users?[id == auth().userId]) + @@allow('read', true) + } + `, + ); + + const r = await db.$unuseAll().user.create({ + data: { + profile: { + create: { firstName: 'Tom' }, + }, + memberships: { + create: { role: 'STANDARD' }, + }, + }, + include: { + profile: true, + memberships: true, + }, + }); + + const membershipId = r.memberships[0].id; + const userId = r.id; + const authDb = db.$setAuth({ id: membershipId, role: 'ADMIN', userId }); + + const r1 = await authDb.membership.update({ + data: { + role: 'VIP', + user: { update: { data: { profile: { update: { data: { firstName: 'Jerry' } } } } } }, + }, + include: { user: { include: { profile: true } } }, + where: { id: membershipId }, + }); + + expect(r1.role).toBe('VIP'); + expect(r1.user.profile.firstName).toBe('Jerry'); +}); diff --git a/tests/regression/test/v2-migrated/issue-814.test.ts b/tests/regression/test/v2-migrated/issue-814.test.ts new file mode 100644 index 00000000..40a260c9 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-814.test.ts @@ -0,0 +1,40 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: field-level policy support +it.skip('verifies issue 814', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + profile Profile? + + @@allow('all', true) +} + +model Profile { + id Int @id @default(autoincrement()) + name String @allow('read', !private) + private Boolean @default(false) + user User @relation(fields: [userId], references: [id]) + userId Int @unique + + @@allow('all', true) +} + `, + ); + + const user = await db.$unuseAll().user.create({ + data: { profile: { create: { name: 'Foo', private: true } } }, + include: { profile: true }, + }); + + const r = await db.profile.findFirst({ where: { id: user.profile.id } }); + expect(r.name).toBeUndefined(); + + const r1 = await db.user.findFirst({ + where: { id: user.id }, + include: { profile: true }, + }); + expect(r1.profile.name).toBeUndefined(); +}); diff --git a/tests/regression/test/v2-migrated/issue-825.test.ts b/tests/regression/test/v2-migrated/issue-825.test.ts new file mode 100644 index 00000000..56079097 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-825.test.ts @@ -0,0 +1,39 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +it('verifies issue 825', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id @default(autoincrement()) + role String + + @@allow('read', true) + @@allow('update', auth().id == id || auth().role == 'superadmin' || auth().role == 'admin') + @@deny('update', + (role == 'superadmin' && auth().id != id) + || (role == 'admin' && auth().id != id && auth().role != 'superadmin')) + + @@deny('post-update', + (before().role != role && auth().role != 'admin' && auth().role != 'superadmin') + || (before().role != role && role == 'superadmin') + || (before().role != role && role == 'admin' && auth().role != 'superadmin')) +} + `, + ); + + const admin = await db.$unuseAll().user.create({ + data: { role: 'admin' }, + }); + + const user = await db.$unuseAll().user.create({ + data: { role: 'customer' }, + }); + + const r = await db.$setAuth(admin).user.update({ + where: { id: user.id }, + data: { role: 'staff' }, + }); + + expect(r.role).toEqual('staff'); +}); diff --git a/tests/regression/test/v2-migrated/issue-864.test.ts b/tests/regression/test/v2-migrated/issue-864.test.ts new file mode 100644 index 00000000..0a3cb373 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-864.test.ts @@ -0,0 +1,183 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, it } from 'vitest'; + +describe('Regression for issue 864', () => { + it('safe create', async () => { + const db = await createPolicyTestClient( + ` + model A { + id Int @id @default(autoincrement()) + aValue Int + b B[] + + @@allow('all', aValue > 0) + } + + model B { + id Int @id @default(autoincrement()) + bValue Int + aId Int + a A @relation(fields: [aId], references: [id]) + c C[] + + @@allow('all', bValue > 0) + } + + model C { + id Int @id @default(autoincrement()) + cValue Int + bId Int + b B @relation(fields: [bId], references: [id]) + + @@allow('all', cValue > 0) + } + `, + ); + + await db.$unuseAll().a.create({ + data: { id: 1, aValue: 1, b: { create: { id: 2, bValue: 2 } } }, + include: { b: true }, + }); + + await db.a.update({ + where: { id: 1 }, + data: { + b: { + update: [ + { + where: { id: 2 }, + data: { + c: { + create: [ + { + cValue: 3, + }, + ], + }, + }, + }, + ], + }, + }, + }); + }); + + it('unsafe create nested in to-many', async () => { + const db = await createPolicyTestClient( + ` + model A { + id Int @id @default(autoincrement()) + aValue Int + b B[] + + @@allow('all', aValue > 0) + } + + model B { + id Int @id @default(autoincrement()) + bValue Int + aId Int + a A @relation(fields: [aId], references: [id]) + c C[] + + @@allow('all', bValue > 0) + } + + model C { + id Int @id @default(autoincrement()) + cValue Int + bId Int + b B @relation(fields: [bId], references: [id]) + + @@allow('all', cValue > 0) + } + `, + ); + + await db.$unuseAll().a.create({ + data: { id: 1, aValue: 1, b: { create: { id: 2, bValue: 2 } } }, + include: { b: true }, + }); + + await db.a.update({ + where: { id: 1 }, + data: { + b: { + update: [ + { + where: { id: 2 }, + data: { + c: { + create: [ + { + id: 1, + cValue: 3, + }, + ], + }, + }, + }, + ], + }, + }, + }); + }); + + it('unsafe create nested in to-one', async () => { + const db = await createPolicyTestClient( + ` + model A { + id Int @id @default(autoincrement()) + aValue Int + b B? + + @@allow('all', aValue > 0) + } + + model B { + id Int @id @default(autoincrement()) + bValue Int + aId Int @unique + a A @relation(fields: [aId], references: [id]) + c C[] + + @@allow('all', bValue > 0) + } + + model C { + id Int @id @default(autoincrement()) + cValue Int + bId Int + b B @relation(fields: [bId], references: [id]) + + @@allow('all', cValue > 0) + } + `, + ); + + await db.$unuseAll().a.create({ + data: { id: 1, aValue: 1, b: { create: { id: 2, bValue: 2 } } }, + include: { b: true }, + }); + + await db.a.update({ + where: { id: 1 }, + data: { + b: { + update: { + data: { + c: { + create: [ + { + id: 1, + cValue: 3, + }, + ], + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-866.test.ts b/tests/regression/test/v2-migrated/issue-866.test.ts new file mode 100644 index 00000000..00f83cce --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-866.test.ts @@ -0,0 +1,22 @@ +import { createTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: zod schema support +it.skip('verifies issue 866', async () => { + const { zodSchemas } = await createTestClient( + ` + model Model { + id Int @id @default(autoincrement()) + a Int @default(100) + b String @default('') + c DateTime @default(now()) + } + `, + ); + + const r = zodSchemas.models.ModelSchema.parse({ id: 1 }); + expect(r.a).toBe(100); + expect(r.b).toBe(''); + expect(r.c).toBeInstanceOf(Date); + expect(r.id).toBe(1); +}); diff --git a/tests/regression/test/v2-migrated/issue-925.test.ts b/tests/regression/test/v2-migrated/issue-925.test.ts new file mode 100644 index 00000000..eede4bcd --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-925.test.ts @@ -0,0 +1,69 @@ +import { loadSchema, loadSchemaWithError } from '@zenstackhq/testtools'; +import { describe, it } from 'vitest'; + +describe('Regression for issue 925', () => { + it('member reference without using this', async () => { + await loadSchemaWithError( + ` + model User { + id Int @id @default(autoincrement()) + company Company[] + test Int + + @@allow('read', auth().company?[staff?[companyId == test]]) + } + + model Company { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + + staff Staff[] + @@allow('read', true) + } + + model Staff { + id Int @id @default(autoincrement()) + + company Company @relation(fields: [companyId], references: [id]) + companyId Int + + @@allow('read', true) + } + `, + "Could not resolve reference to ReferenceTarget named 'test'.", + ); + }); + + it('reference with this', async () => { + await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + company Company[] + test Int + + @@allow('read', auth().company?[staff?[companyId == this.test]]) + } + + model Company { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + + staff Staff[] + @@allow('read', true) + } + + model Staff { + id Int @id @default(autoincrement()) + + company Company @relation(fields: [companyId], references: [id]) + companyId Int + + @@allow('read', true) + } + `, + ); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-947.test.ts b/tests/regression/test/v2-migrated/issue-947.test.ts new file mode 100644 index 00000000..04a24538 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-947.test.ts @@ -0,0 +1,23 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 947', async () => { + await loadSchema( + ` +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Test { + id String @id + props TestEnum[] @default([]) + } + +enum TestEnum { + A + B +} + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-961.test.ts b/tests/regression/test/v2-migrated/issue-961.test.ts new file mode 100644 index 00000000..a1e112e5 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-961.test.ts @@ -0,0 +1,211 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { describe, expect, it } from 'vitest'; + +describe('Regression for issue 961', () => { + const schema = ` + model User { + id String @id @default(cuid()) + backups UserColumnBackup[] + } + + model UserColumnBackup { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + key String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt() + columns UserColumn[] + @@unique([userId, key]) + @@allow('all', auth().id == userId) + } + + model UserColumn { + id String @id @default(cuid()) + userColumnBackup UserColumnBackup @relation(fields: [userColumnBackupId], references: [id], onDelete: Cascade) + userColumnBackupId String + column String + version Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt() + + @@unique([userColumnBackupId, column]) + @@allow('all', auth().id == userColumnBackup.userId) + @@deny('update,delete', column == 'c2') + } + `; + + it('deleteMany', async () => { + const db = await createPolicyTestClient(schema); + + const user = await db.$unuseAll().user.create({ + data: { + backups: { + create: { + key: 'key1', + columns: { + create: [{ column: 'c1' }, { column: 'c2' }, { column: 'c3' }], + }, + }, + }, + }, + include: { backups: true }, + }); + const backup = user.backups[0]; + + const authDb = db.$setAuth({ id: user.id }); + + // delete with non-existing outer filter + await expect( + authDb.userColumnBackup.update({ + where: { id: 'abc' }, + data: { + columns: { + deleteMany: { + column: 'c1', + }, + }, + }, + }), + ).toBeRejectedNotFound(); + await expect(authDb.userColumn.findMany()).resolves.toHaveLength(3); + + // delete c1 + await authDb.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c1', + }, + }, + }, + include: { columns: true }, + }); + await expect(authDb.userColumn.findMany()).resolves.toHaveLength(2); + + // delete c1 again, no change + await authDb.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c1', + }, + }, + }, + }); + await expect(authDb.userColumn.findMany()).resolves.toHaveLength(2); + + // delete c2, filtered out by policy + await authDb.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c2', + }, + }, + }, + }); + await expect(authDb.userColumn.findMany()).resolves.toHaveLength(2); + + // delete c3, should succeed + await authDb.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + deleteMany: { + column: 'c3', + }, + }, + }, + }); + await expect(authDb.userColumn.findMany()).resolves.toHaveLength(1); + }); + + it('updateMany', async () => { + const db = await createPolicyTestClient(schema); + + const user = await db.$unuseAll().user.create({ + data: { + backups: { + create: { + key: 'key1', + columns: { + create: [ + { column: 'c1', version: 1 }, + { column: 'c2', version: 2 }, + ], + }, + }, + }, + }, + include: { backups: true }, + }); + const backup = user.backups[0]; + + const authDb = db.$setAuth({ id: user.id }); + + // update with non-existing outer filter + await expect( + authDb.userColumnBackup.update({ + where: { id: 'abc' }, + data: { + columns: { + updateMany: { + where: { column: 'c1' }, + data: { version: { increment: 1 } }, + }, + }, + }, + }), + ).toBeRejectedNotFound(); + await expect(authDb.userColumn.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'c1', version: 1 }), + expect.objectContaining({ column: 'c2', version: 2 }), + ]), + ); + + // update c1 + await authDb.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + updateMany: { + where: { column: 'c1' }, + data: { version: { increment: 1 } }, + }, + }, + }, + include: { columns: true }, + }); + await expect(authDb.userColumn.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'c1', version: 2 }), + expect.objectContaining({ column: 'c2', version: 2 }), + ]), + ); + + // update c2, filtered out by policy + await authDb.userColumnBackup.update({ + where: { id: backup.id }, + data: { + columns: { + updateMany: { + where: { column: 'c2' }, + data: { version: { increment: 1 } }, + }, + }, + }, + include: { columns: true }, + }); + await expect(authDb.userColumn.findMany()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ column: 'c1', version: 2 }), + expect.objectContaining({ column: 'c2', version: 2 }), + ]), + ); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-965.test.ts b/tests/regression/test/v2-migrated/issue-965.test.ts new file mode 100644 index 00000000..b46d767b --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-965.test.ts @@ -0,0 +1,53 @@ +import { loadSchema, loadSchemaWithError } from '@zenstackhq/testtools'; +import { describe, it } from 'vitest'; + +describe('Regression for issue 965', () => { + it('regression1', async () => { + await loadSchema(` + type Base { + id String @id @default(cuid()) + } + + type A { + URL String? @url + } + + type B { + anotherURL String? @url + } + + type C { + oneMoreURL String? @url + } + + model D with Base, A, B { + } + + model E with Base, B, C { + }`); + }); + + it('regression2', async () => { + await loadSchemaWithError( + ` + type A { + URL String? @url + } + + type B { + anotherURL String? @url + } + + type C { + oneMoreURL String? @url + } + + model D with A, B { + } + + model E with B, C { + }`, + 'Model must have at least one unique criteria. Either mark a single field with `@id`, `@unique` or add a multi field criterion with `@@id([])` or `@@unique([])` to the model.', + ); + }); +}); diff --git a/tests/regression/test/v2-migrated/issue-971.test.ts b/tests/regression/test/v2-migrated/issue-971.test.ts new file mode 100644 index 00000000..a20d5bb6 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-971.test.ts @@ -0,0 +1,22 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import { it } from 'vitest'; + +it('verifies issue 971', async () => { + await loadSchema( + ` +type Level1 { + id String @id @default(cuid()) + URL String? + @@validate(URL != null, "URL must be provided") // works +} +type Level2 with Level1 { + @@validate(URL != null, "URL must be provided") // works +} +type Level3 with Level2 { + @@validate(URL != null, "URL must be provided") // doesn't work +} +model Foo with Level3 { +} + `, + ); +}); diff --git a/tests/regression/test/v2-migrated/issue-992.test.ts b/tests/regression/test/v2-migrated/issue-992.test.ts new file mode 100644 index 00000000..151afd34 --- /dev/null +++ b/tests/regression/test/v2-migrated/issue-992.test.ts @@ -0,0 +1,44 @@ +import { createPolicyTestClient } from '@zenstackhq/testtools'; +import { expect, it } from 'vitest'; + +// TODO: global omit support +it.skip('regression', async () => { + const db = await createPolicyTestClient( + ` +model Product { + id String @id @default(cuid()) + category Category @relation(fields: [categoryId], references: [id]) + categoryId String + + deleted Int @default(0) @omit + @@deny('read', deleted != 0) + @@allow('all', true) +} + +model Category { + id String @id @default(cuid()) + products Product[] + @@allow('all', true) +} + `, + ); + + await db.$unuseAll().category.create({ + data: { + products: { + create: [ + { + deleted: 0, + }, + { + deleted: 0, + }, + ], + }, + }, + }); + + const category = await db.category.findFirst({ include: { products: true } }); + expect(category.products[0].deleted).toBeUndefined(); + expect(category.products[1].deleted).toBeUndefined(); +});