diff --git a/packages/runtime/package.json b/packages/runtime/package.json index da696705..d9b7b3fb 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -73,7 +73,8 @@ "toposort": "^2.0.2", "ts-pattern": "catalog:", "ulid": "^3.0.0", - "uuid": "^11.0.5" + "uuid": "^11.0.5", + "zod-validation-error": "catalog:" }, "peerDependencies": { "better-sqlite3": "^12.2.0", diff --git a/packages/runtime/src/client/crud/validator.ts b/packages/runtime/src/client/crud/validator.ts index 372129ff..3a8bdf3e 100644 --- a/packages/runtime/src/client/crud/validator.ts +++ b/packages/runtime/src/client/crud/validator.ts @@ -6,6 +6,7 @@ import { z, ZodType } from 'zod'; import { type BuiltinType, type EnumDef, type FieldDef, type GetModels, type SchemaDef } from '../../schema'; import { enumerate } from '../../utils/enumerate'; import { extractFields } from '../../utils/object-utils'; +import { formatError } from '../../utils/zod-utils'; import { AGGREGATE_OPERATORS, LOGICAL_COMBINATORS, NUMERIC_FIELD_TYPES } from '../constants'; import { type AggregateArgs, @@ -185,7 +186,7 @@ export class InputValidator { } const { error } = schema.safeParse(args); if (error) { - throw new InputValidationError(`Invalid ${operation} args: ${error.message}`, error); + throw new InputValidationError(`Invalid ${operation} args: ${formatError(error)}`, error); } return args as T; } diff --git a/packages/runtime/src/plugins/policy/expression-transformer.ts b/packages/runtime/src/plugins/policy/expression-transformer.ts index 9cf81ccc..f6e35a55 100644 --- a/packages/runtime/src/plugins/policy/expression-transformer.ts +++ b/packages/runtime/src/plugins/policy/expression-transformer.ts @@ -281,11 +281,12 @@ export class ExpressionTransformer { .map((f) => f.name); invariant(idFields.length > 0, 'auth type model must have at least one id field'); + // convert `auth() == other` into `auth().id == other.id` const conditions = idFields.map((fieldName) => ExpressionUtils.binary( ExpressionUtils.member(authExpr, [fieldName]), '==', - ExpressionUtils.member(other, [fieldName]), + this.makeOrAppendMember(other, fieldName), ), ); let result = this.buildAnd(conditions); @@ -296,6 +297,14 @@ export class ExpressionTransformer { } } + private makeOrAppendMember(other: Expression, fieldName: string): Expression { + if (ExpressionUtils.isMember(other)) { + return ExpressionUtils.member(other.receiver, [...other.members, fieldName]); + } else { + return ExpressionUtils.member(other, [fieldName]); + } + } + private transformValue(value: unknown, type: BuiltinType) { return ValueNode.create(this.dialect.transformPrimitive(value, type, false) ?? null); } diff --git a/packages/runtime/src/utils/zod-utils.ts b/packages/runtime/src/utils/zod-utils.ts new file mode 100644 index 00000000..2ca23ca8 --- /dev/null +++ b/packages/runtime/src/utils/zod-utils.ts @@ -0,0 +1,14 @@ +import { ZodError } from 'zod'; +import { fromError as fromError3 } from 'zod-validation-error/v3'; +import { fromError as fromError4 } from 'zod-validation-error/v4'; + +/** + * Format ZodError into a readable string + */ +export function formatError(error: ZodError): string { + if ('_zod' in error) { + return fromError4(error).toString(); + } else { + return fromError3(error).toString(); + } +} diff --git a/packages/runtime/test/client-api/group-by.test.ts b/packages/runtime/test/client-api/group-by.test.ts index a9c0d56a..859a1050 100644 --- a/packages/runtime/test/client-api/group-by.test.ts +++ b/packages/runtime/test/client-api/group-by.test.ts @@ -269,7 +269,7 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client groupBy tests', ({ createCl age: 10, }, }), - ).rejects.toThrow(/must be in \\"by\\"/); + ).rejects.toThrow(/must be in "by"/); }); it('complains about fields in orderBy that are not in by', async () => { @@ -280,6 +280,6 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client groupBy tests', ({ createCl age: 'asc', }, }), - ).rejects.toThrow(/must be in \\"by\\"/); + ).rejects.toThrow(/must be in "by"/); }); }); diff --git a/packages/runtime/test/policy/crud/update.test.ts b/packages/runtime/test/policy/crud/update.test.ts index e0082a49..ef56b40f 100644 --- a/packages/runtime/test/policy/crud/update.test.ts +++ b/packages/runtime/test/policy/crud/update.test.ts @@ -340,8 +340,138 @@ model Post { }); }); + describe('Nested create tests', () => { + it('works with nested create non-owner side', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + profile Profile? + @@allow('all', true) +} + +model Profile { + id Int @id + user User? @relation(fields: [userId], references: [id]) + userId Int? @unique + @@allow('create', user.id == auth().id) + @@allow('read', true) +} + `, + ); + + await db.user.create({ data: { id: 1 } }); + await expect( + db.user.update({ where: { id: 1 }, data: { profile: { create: { id: 1 } } } }), + ).toBeRejectedByPolicy(); + await expect( + db.$setAuth({ id: 1 }).user.update({ + where: { id: 1 }, + data: { profile: { create: { id: 1 } } }, + include: { profile: true }, + }), + ).resolves.toMatchObject({ + profile: { + id: 1, + }, + }); + }); + + it('works with nested create owner side', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + profile Profile? @relation(fields: [profileId], references: [id]) + profileId Int? @unique + @@allow('create,read', true) + @@allow('update', auth() == this) +} + +model Profile { + id Int @id + user User? + @@allow('all', true) +} +`, + ); + + await db.user.create({ data: { id: 1 } }); + await expect( + db.user.update({ where: { id: 1 }, data: { profile: { create: { id: 1 } } } }), + ).toBeRejectedNotFound(); + await expect( + db.$setAuth({ id: 1 }).user.update({ + where: { id: 1 }, + data: { profile: { create: { id: 1 } } }, + include: { profile: true }, + }), + ).resolves.toMatchObject({ + profile: { + id: 1, + }, + }); + }); + + it('works with nested create many', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + posts Post[] + @@allow('all', true) +} + +model Post { + id Int @id + title String + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('read', true) + @@allow('create', auth() == this.user) +} +`, + ); + + await db.user.create({ data: { id: 1 } }); + await expect( + db.user.update({ + where: { id: 1 }, + data: { + posts: { + createMany: { + data: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, + }, + }, + }), + ).toBeRejectedByPolicy(); + await expect( + db.$setAuth({ id: 1 }).user.update({ + where: { id: 1 }, + data: { + posts: { + createMany: { + data: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + posts: [{ id: 1 }, { id: 2 }], + }); + }); + }); + describe('Nested update tests', () => { - it('works with nested update owner side', async () => { + it('works with nested update non-owner side', async () => { const db = await createPolicyTestClient( ` model User { @@ -384,7 +514,7 @@ model Profile { }); }); - it('works with nested update non-owner side', async () => { + it('works with nested update owner side', async () => { const db = await createPolicyTestClient( ` model User { @@ -426,6 +556,188 @@ model Profile { }, }); }); + + it('works with nested update many', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + posts Post[] + @@allow('all', true) +} + +model Post { + id Int @id + title String + private Boolean + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('create,read', true) + @@allow('update', !private) +} +`, + ); + + await db.user.create({ + data: { + id: 1, + posts: { + create: [ + { id: 1, title: 'Post 1', private: true }, + { id: 2, title: 'Post 2', private: false }, + ], + }, + }, + }); + await expect( + db.user.update({ + where: { id: 1 }, + data: { + posts: { + updateMany: { + where: { title: { contains: 'Post' } }, + data: { title: 'Updated Title' }, + }, + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + posts: [{ title: 'Post 1' }, { title: 'Updated Title' }], + }); + }); + + it('works with nested upsert', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + posts Post[] + @@allow('all', true) +} + +model Post { + id Int @id + title String + user User @relation(fields: [userId], references: [id]) + userId Int + @@allow('read', true) + @@allow('create', contains(title, 'Foo')) + @@allow('update', contains(title, 'Bar')) +} +`, + ); + + await db.user.create({ data: { id: 1 } }); + // can't create + await expect( + db.user.update({ + where: { id: 1 }, + data: { + posts: { + upsert: { + where: { id: 1 }, + create: { id: 1, title: 'Post1' }, + update: { title: 'Post1' }, + }, + }, + }, + }), + ).toBeRejectedByPolicy(); + // can create + await expect( + db.user.update({ + where: { id: 1 }, + data: { + posts: { + upsert: { + where: { id: 1 }, + create: { id: 1, title: 'Foo Post' }, + update: { title: 'Post1' }, + }, + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + posts: [{ id: 1, title: 'Foo Post' }], + }); + // can't update + await expect( + db.user.update({ + where: { id: 1 }, + data: { + posts: { + upsert: { + where: { id: 1 }, + create: { id: 1, title: 'Foo Post' }, + update: { title: 'Post1' }, + }, + }, + }, + }), + ).rejects.toThrow('constraint'); + await db.$unuseAll().post.update({ where: { id: 1 }, data: { title: 'Bar Post' } }); + // can update + await expect( + db.user.update({ + where: { id: 1 }, + data: { + posts: { + upsert: { + where: { id: 1 }, + create: { id: 1, title: 'Foo Post' }, + update: { title: 'Bar Updated' }, + }, + }, + }, + include: { posts: true }, + }), + ).resolves.toMatchObject({ + posts: [{ id: 1, title: 'Bar Updated' }], + }); + }); + }); + + describe('Nested delete tests', () => { + it('works with nested delete non-owner side', async () => { + const db = await createPolicyTestClient( + ` +model User { + id Int @id + profile Profile? + @@allow('all', true) +} + +model Profile { + id Int @id + private Boolean + user User? @relation(fields: [userId], references: [id]) + userId Int? @unique + @@allow('create,read', true) + @@allow('delete', !private) +} +`, + ); + + await db.user.create({ data: { id: 1, profile: { create: { id: 1, private: true } } } }); + await expect( + db.user.update({ + where: { id: 1 }, + data: { profile: { delete: true } }, + }), + ).toBeRejectedNotFound(); + + await db.user.create({ data: { id: 2, profile: { create: { id: 2, private: false } } } }); + await expect( + db.user.update({ + where: { id: 2 }, + data: { profile: { delete: true } }, + include: { profile: true }, + }), + ).resolves.toMatchObject({ profile: null }); + await expect(db.profile.findUnique({ where: { id: 2 } })).resolves.toBeNull(); + }); }); describe('Relation manipulation tests', () => { @@ -578,7 +890,67 @@ model Profile { }); }); - // describe('Upsert tests', () => {}); + describe('Upsert tests', () => { + it('works with upsert', async () => { + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id + x Int + @@allow('create', x > 0) + @@allow('update', x > 1) + @@allow('read', true) +} +`, + ); + // can't create + await expect( + db.foo.upsert({ where: { id: 1 }, create: { id: 1, x: 0 }, update: { x: 2 } }), + ).toBeRejectedByPolicy(); + await expect( + db.foo.upsert({ where: { id: 1 }, create: { id: 1, x: 1 }, update: { x: 2 } }), + ).resolves.toMatchObject({ x: 1 }); + // can't update, but create violates unique constraint + await expect( + db.foo.upsert({ where: { id: 1 }, create: { id: 1, x: 1 }, update: { x: 1 } }), + ).rejects.toThrow('constraint'); + await db.$unuseAll().foo.update({ where: { id: 1 }, data: { x: 2 } }); + // can update now + await expect( + db.foo.upsert({ where: { id: 1 }, create: { id: 1, x: 1 }, update: { x: 3 } }), + ).resolves.toMatchObject({ x: 3 }); + }); + }); + + describe('Update many tests', () => { + it('works with update many', async () => { + const db = await createPolicyTestClient( + ` +model Foo { + id Int @id + x Int + @@allow('create', true) + @@allow('update', x > 1) + @@allow('read', true) +} +`, + ); - // describe('Update many tests', () => {}); + await db.foo.createMany({ + data: [ + { id: 1, x: 1 }, + { id: 2, x: 2 }, + { id: 3, x: 3 }, + ], + }); + await expect(db.foo.updateMany({ data: { x: 5 } })).resolves.toMatchObject({ count: 2 }); + await expect(db.foo.findMany()).resolves.toEqual( + expect.arrayContaining([ + { id: 1, x: 1 }, + { id: 2, x: 5 }, + { id: 3, x: 5 }, + ]), + ); + }); + }); }); diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 64484593..f07a2a27 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -152,7 +152,7 @@ export async function createTestClient( fs.writeFileSync(path.resolve(workDir!, 'schema.prisma'), prismaSchemaText); execSync('npx prisma db push --schema ./schema.prisma --skip-generate --force-reset', { cwd: workDir, - stdio: 'inherit', + stdio: 'ignore', }); } else { if (provider === 'postgresql') { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c339a56b..a6638dc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ catalogs: typescript: specifier: ^5.8.0 version: 5.8.3 + zod-validation-error: + specifier: ^4.0.1 + version: 4.0.1 importers: @@ -299,6 +302,9 @@ importers: uuid: specifier: ^11.0.5 version: 11.0.5 + zod-validation-error: + specifier: 'catalog:' + version: 4.0.1(zod@3.25.76) devDependencies: '@types/better-sqlite3': specifier: ^7.6.13 @@ -2711,6 +2717,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-validation-error@4.0.1: + resolution: {integrity: sha512-F3rdaCOHs5ViJ5YTz5zzRtfkQdMdIeKudJAoxy7yB/2ZMEHw73lmCAcQw11r7++20MyGl4WV59EVh7A9rNAyog==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -4791,4 +4803,8 @@ snapshots: yocto-queue@0.1.0: {} + zod-validation-error@4.0.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 11ea3a73..d54970c6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,3 +13,4 @@ catalog: '@types/node': ^20.17.24 tmp: ^0.2.3 '@types/tmp': ^0.2.6 + 'zod-validation-error': ^4.0.1