From d60ae681fba4af2b2142d89269231d6e21b16212 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 3 Jun 2023 10:24:07 +0800 Subject: [PATCH 1/3] fix: issue with connecting multiple relations --- .../src/enhancements/nested-write-vistor.ts | 168 ++++++++++-------- packages/runtime/src/enhancements/omit.ts | 4 +- .../src/enhancements/policy/policy-utils.ts | 59 +++--- packages/runtime/src/enhancements/utils.ts | 7 - packages/server/src/api/rest/index.ts | 107 +++++------ .../server/tests/api/rest-petstore.test.ts | 118 ++++++++++++ tests/integration/test-run/package-lock.json | 2 - .../integration/tests/schema/petstore.zmodel | 64 +++++++ .../tests/with-policy/nested-to-many.test.ts | 15 +- .../tests/with-policy/nested-to-one.test.ts | 40 +++-- .../tests/with-policy/petstore-sample.test.ts | 59 ++++++ 11 files changed, 461 insertions(+), 182 deletions(-) create mode 100644 packages/server/tests/api/rest-petstore.test.ts create mode 100644 tests/integration/tests/schema/petstore.zmodel create mode 100644 tests/integration/tests/with-policy/petstore-sample.test.ts diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-vistor.ts index 23188b7d4..be6d4e9aa 100644 --- a/packages/runtime/src/enhancements/nested-write-vistor.ts +++ b/packages/runtime/src/enhancements/nested-write-vistor.ts @@ -4,7 +4,7 @@ import { FieldInfo, PrismaWriteActionType, PrismaWriteActions } from '../types'; import { resolveField } from './model-meta'; import { ModelMeta } from './types'; -import { Enumerable, ensureArray, getModelFields } from './utils'; +import { enumerate, getModelFields } from './utils'; type NestingPathItem = { field?: FieldInfo; where: any; unique: boolean }; @@ -34,33 +34,25 @@ export type VisitorContext = { export type NestedWriterVisitorCallback = { create?: (model: string, args: any[], context: VisitorContext) => Promise; - connectOrCreate?: ( - model: string, - args: Enumerable<{ where: object; create: any }>, - context: VisitorContext - ) => Promise; + connectOrCreate?: (model: string, args: { where: object; create: any }, context: VisitorContext) => Promise; - connect?: (model: string, args: Enumerable, context: VisitorContext) => Promise; + connect?: (model: string, args: object, context: VisitorContext) => Promise; - disconnect?: (model: string, args: Enumerable, context: VisitorContext) => Promise; + disconnect?: (model: string, args: object, context: VisitorContext) => Promise; - update?: (model: string, args: Enumerable<{ where: object; data: any }>, context: VisitorContext) => Promise; + update?: (model: string, args: { where: object; data: any }, context: VisitorContext) => Promise; - updateMany?: ( - model: string, - args: Enumerable<{ where?: object; data: any }>, - context: VisitorContext - ) => Promise; + updateMany?: (model: string, args: { where?: object; data: any }, context: VisitorContext) => Promise; upsert?: ( model: string, - args: Enumerable<{ where: object; create: any; update: any }>, + args: { where: object; create: any; update: any }, context: VisitorContext ) => Promise; - delete?: (model: string, args: Enumerable | boolean, context: VisitorContext) => Promise; + delete?: (model: string, args: object | boolean, context: VisitorContext) => Promise; - deleteMany?: (model: string, args: Enumerable, context: VisitorContext) => Promise; + deleteMany?: (model: string, args: { where: object }, context: VisitorContext) => Promise; field?: (field: FieldInfo, action: PrismaWriteActionType, data: any, context: VisitorContext) => Promise; }; @@ -115,7 +107,6 @@ export class NestedWriteVisitor { return; } - const fieldContainers: any[] = []; const isToOneUpdate = field?.isDataModel && !field.isArray; const context = { parent, field, nestingPath: [...nestingPath] }; @@ -123,35 +114,46 @@ export class NestedWriteVisitor { switch (action) { case 'create': context.nestingPath.push({ field, where: {}, unique: false }); - if (this.callback.create) { - await this.callback.create(model, data, context); + for (const item of enumerate(data)) { + if (this.callback.create) { + await this.callback.create(model, item, context); + } + await this.visitSubPayload(model, action, item, context.nestingPath); } - fieldContainers.push(...ensureArray(data)); break; case 'createMany': // skip the 'data' layer so as to keep consistency with 'create' if (data.data) { context.nestingPath.push({ field, where: {}, unique: false }); - if (this.callback.create) { - await this.callback.create(model, data.data, context); + for (const item of enumerate(data.data)) { + if (this.callback.create) { + await this.callback.create(model, item, context); + } + await this.visitSubPayload(model, action, item, context.nestingPath); } - fieldContainers.push(...ensureArray(data.data)); } break; case 'connectOrCreate': context.nestingPath.push({ field, where: data.where, unique: true }); - if (this.callback.connectOrCreate) { - await this.callback.connectOrCreate(model, data, context); + for (const item of enumerate(data)) { + if (this.callback.connectOrCreate) { + await this.callback.connectOrCreate(model, item, context); + } + await this.visitSubPayload(model, action, item.create, context.nestingPath); } - fieldContainers.push(...ensureArray(data).map((d) => d.create)); break; case 'connect': - context.nestingPath.push({ field, where: data, unique: true }); if (this.callback.connect) { - await this.callback.connect(model, data, context); + for (const item of enumerate(data)) { + const newContext = { + ...context, + nestingPath: [...context.nestingPath, { field, where: item, unique: true }], + }; + await this.callback.connect(model, item, newContext); + } } break; @@ -159,48 +161,69 @@ export class NestedWriteVisitor { // disconnect has two forms: // if relation is to-many, the payload is a unique filter object // if relation is to-one, the payload can only be boolean `true` - context.nestingPath.push({ field, where: data, unique: typeof data === 'object' }); if (this.callback.disconnect) { - await this.callback.disconnect(model, data, context); + for (const item of enumerate(data)) { + const newContext = { + ...context, + nestingPath: [ + ...context.nestingPath, + { field, where: item, unique: typeof item === 'object' }, + ], + }; + await this.callback.disconnect(model, item, newContext); + } } break; case 'update': context.nestingPath.push({ field, where: data.where, unique: false }); - if (this.callback.update) { - await this.callback.update(model, data, context); + for (const item of enumerate(data)) { + if (this.callback.update) { + await this.callback.update(model, item, context); + } + const payload = isToOneUpdate ? item : item.data; + await this.visitSubPayload(model, action, payload, context.nestingPath); } - fieldContainers.push(...ensureArray(data).map((d) => (isToOneUpdate ? d : d.data))); break; case 'updateMany': context.nestingPath.push({ field, where: data.where, unique: false }); - if (this.callback.updateMany) { - await this.callback.updateMany(model, data, context); + for (const item of enumerate(data)) { + if (this.callback.updateMany) { + await this.callback.updateMany(model, item, context); + } + await this.visitSubPayload(model, action, item, context.nestingPath); } - fieldContainers.push(...ensureArray(data)); break; - case 'upsert': + case 'upsert': { context.nestingPath.push({ field, where: data.where, unique: true }); - if (this.callback.upsert) { - await this.callback.upsert(model, data, context); + for (const item of enumerate(data)) { + if (this.callback.upsert) { + await this.callback.upsert(model, item, context); + } + await this.visitSubPayload(model, action, item.create, context.nestingPath); + await this.visitSubPayload(model, action, item.update, context.nestingPath); } - fieldContainers.push(...ensureArray(data).map((d) => d.create)); - fieldContainers.push(...ensureArray(data).map((d) => d.update)); break; + } - case 'delete': - context.nestingPath.push({ field, where: data.where, unique: false }); + case 'delete': { if (this.callback.delete) { - await this.callback.delete(model, data, context); + context.nestingPath.push({ field, where: data.where, unique: false }); + for (const item of enumerate(data)) { + await this.callback.delete(model, item, context); + } } break; + } case 'deleteMany': - context.nestingPath.push({ field, where: data.where, unique: false }); if (this.callback.deleteMany) { - await this.callback.deleteMany(model, data, context); + context.nestingPath.push({ field, where: data.where, unique: false }); + for (const item of enumerate(data)) { + await this.callback.deleteMany(model, item, context); + } } break; @@ -208,33 +231,38 @@ export class NestedWriteVisitor { throw new Error(`unhandled action type ${action}`); } } + } - for (const fieldContainer of fieldContainers) { - for (const field of getModelFields(fieldContainer)) { - const fieldInfo = resolveField(this.modelMeta, model, field); - if (!fieldInfo) { - continue; - } + private async visitSubPayload( + model: string, + action: PrismaWriteActionType, + payload: any, + nestingPath: NestingPathItem[] + ) { + for (const field of getModelFields(payload)) { + const fieldInfo = resolveField(this.modelMeta, model, field); + if (!fieldInfo) { + continue; + } - if (fieldInfo.isDataModel) { - // recurse into nested payloads - for (const [subAction, subData] of Object.entries(fieldContainer[field])) { - if (this.isPrismaWriteAction(subAction) && subData) { - await this.doVisit(fieldInfo.type, subAction, subData, fieldContainer[field], fieldInfo, [ - ...context.nestingPath, - ]); - } - } - } else { - // visit plain field - if (this.callback.field) { - await this.callback.field(fieldInfo, action, fieldContainer[field], { - parent: fieldContainer, - nestingPath: [...context.nestingPath], - field: fieldInfo, - }); + if (fieldInfo.isDataModel) { + // recurse into nested payloads + for (const [subAction, subData] of Object.entries(payload[field])) { + if (this.isPrismaWriteAction(subAction) && subData) { + await this.doVisit(fieldInfo.type, subAction, subData, payload[field], fieldInfo, [ + ...nestingPath, + ]); } } + } else { + // visit plain field + if (this.callback.field) { + await this.callback.field(fieldInfo, action, payload[field], { + parent: payload, + nestingPath, + field: fieldInfo, + }); + } } } } diff --git a/packages/runtime/src/enhancements/omit.ts b/packages/runtime/src/enhancements/omit.ts index 7f0de58d5..8f12f540e 100644 --- a/packages/runtime/src/enhancements/omit.ts +++ b/packages/runtime/src/enhancements/omit.ts @@ -5,7 +5,7 @@ import { DbClientContract } from '../types'; import { getDefaultModelMeta, resolveField } from './model-meta'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; import { ModelMeta } from './types'; -import { ensureArray, getModelFields } from './utils'; +import { enumerate, getModelFields } from './utils'; /** * Gets an enhanced Prisma client that supports @omit attribute. @@ -28,7 +28,7 @@ class OmitHandler extends DefaultPrismaProxyHandler { // base override protected async processResultEntity(data: T): Promise { if (data) { - for (const value of ensureArray(data)) { + for (const value of enumerate(data)) { await this.doPostProcess(value, this.model); } } diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 9f056f94f..6e1ef58c1 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -262,7 +262,7 @@ export class PolicyUtil { // e.g.: { a_b: { a: '1', b: '1' } } => { a: '1', b: '1' } const uniqueConstraints = this.modelMeta.uniqueConstraints?.[lowerCaseFirst(model)]; let flattened = false; - if (uniqueConstraints) { + if (uniqueConstraints && Object.keys(uniqueConstraints).length > 0) { for (const [field, value] of Object.entries(args)) { if (uniqueConstraints[field] && typeof value === 'object') { for (const [f, v] of Object.entries(value)) { @@ -590,74 +590,55 @@ export class PolicyUtil { // use a visitor to process args before conducting the write action const visitor = new NestedWriteVisitor(this.modelMeta, { create: async (model, args) => { - for (const oneArgs of enumerate(args)) { - await processCreate(model, oneArgs); - } + await processCreate(model, args); }, connectOrCreate: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - if (oneArgs.create) { - await processCreate(model, oneArgs.create); - } - if (oneArgs.where) { - await processRelationUpdate(model, oneArgs.where, context); - } + if (args.create) { + await processCreate(model, args.create); + } + if (args.where) { + await processRelationUpdate(model, args.where, context); } }, connect: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - await processRelationUpdate(model, oneArgs, context); - } + await processRelationUpdate(model, args, context); }, disconnect: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - await processRelationUpdate(model, oneArgs, context); - } + await processRelationUpdate(model, args, context); }, update: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - await processUpdate(model, oneArgs.where, context); - } + await processUpdate(model, args.where, context); }, updateMany: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - await processUpdateMany(model, oneArgs, context); - } + await processUpdateMany(model, args, context); }, upsert: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - if (oneArgs.create) { - await processCreate(model, oneArgs.create); - } + if (args.create) { + await processCreate(model, args.create); + } - if (oneArgs.update) { - await processUpdate(model, oneArgs.where, context); - } + if (args.update) { + await processUpdate(model, args.where, context); } }, delete: async (model, args, context) => { - for (const oneArgs of enumerate(args)) { - await processDelete(model, oneArgs, context); - } + await processDelete(model, args, context); }, - deleteMany: async (model, args, context) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deleteMany: async (model, args, _context) => { const guard = await this.getAuthGuard(model, 'delete'); if (guard === false) { throw this.deniedByPolicy(model, 'delete'); } else if (guard !== true) { - if (Array.isArray(args)) { - context.parent.deleteMany = args.map((oneArgs) => this.and(oneArgs, guard)); - } else { - context.parent.deleteMany = this.and(args, guard); - } + args.where = this.and(args.where, guard); } }, }); diff --git a/packages/runtime/src/enhancements/utils.ts b/packages/runtime/src/enhancements/utils.ts index 11d4a0801..4cc33e9b6 100644 --- a/packages/runtime/src/enhancements/utils.ts +++ b/packages/runtime/src/enhancements/utils.ts @@ -7,13 +7,6 @@ import { AUXILIARY_FIELDS } from '../constants'; import { DbClientContract } from '../types'; import { ModelMeta } from './types'; -/** - * Wraps a value into array if it's not already one - */ -export function ensureArray(value: T): T[] { - return Array.isArray(value) ? value : [value]; -} - /** * Gets field names in a data model entity, filtering out internal fields. */ diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index d4417ae27..1634e6328 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -8,7 +8,6 @@ import { } from '@zenstackhq/runtime'; import { getDefaultModelMeta } from '@zenstackhq/runtime/enhancements/model-meta'; import type { ModelMeta } from '@zenstackhq/runtime/enhancements/types'; -import { ModelZodSchema } from '@zenstackhq/runtime/zod'; import { paramCase } from 'change-case'; import { lowerCaseFirst } from 'lower-case-first'; import { DataDocument, Linker, Paginator, Relator, Serializer, SerializerOptions } from 'ts-japi'; @@ -16,7 +15,7 @@ import UrlPattern from 'url-pattern'; import z from 'zod'; import { fromZodError } from 'zod-validation-error'; import { LoggerConfig, RequestContext, Response } from '../../types'; -import { getZodSchema, logWarning, stripAuxFields } from '../utils'; +import { logWarning, stripAuxFields } from '../utils'; const urlPatterns = { // collection operations @@ -171,22 +170,24 @@ class RequestHandler { private filterParamPattern = new RegExp(/^filter(?(\[[^[\]]+\])+)$/); // zod schema for payload of creating and updating a resource - private createUpdatePayloadSchema = z.object({ - data: z.object({ - type: z.string(), - attributes: z.object({}).passthrough(), - relationships: z - .record( - z.object({ - data: z.union([ - z.object({ type: z.string(), id: z.union([z.string(), z.number()]) }), - z.array(z.object({ type: z.string(), id: z.union([z.string(), z.number()]) })), - ]), - }) - ) - .optional(), - }), - }); + private createUpdatePayloadSchema = z + .object({ + data: z.object({ + type: z.string(), + attributes: z.object({}).passthrough().optional(), + relationships: z + .record( + z.object({ + data: z.union([ + z.object({ type: z.string(), id: z.union([z.string(), z.number()]) }), + z.array(z.object({ type: z.string(), id: z.union([z.string(), z.number()]) })), + ]), + }) + ) + .optional(), + }), + }) + .strict(); // zod schema for updating a single relationship private updateSingleRelationSchema = z.object({ @@ -220,8 +221,8 @@ class RequestHandler { requestBody, logger, modelMeta, - zodSchemas, - }: RequestContext): Promise { + }: /* zodSchemas, */ + RequestContext): Promise { if (!this.serializers) { this.buildSerializers(modelMeta ?? this.defaultModelMeta); } @@ -276,7 +277,7 @@ class RequestHandler { let match = urlPatterns.collection.match(path); if (match) { // resource creation - return await this.processCreate(prisma, match.type, query, requestBody, zodSchemas); + return await this.processCreate(prisma, match.type, query, requestBody /*, zodSchemas */); } match = urlPatterns.relationship.match(path); @@ -302,7 +303,13 @@ class RequestHandler { let match = urlPatterns.single.match(path); if (match) { // resource update - return await this.processUpdate(prisma, match.type, match.id, query, requestBody, zodSchemas); + return await this.processUpdate( + prisma, + match.type, + match.id, + query, + requestBody /*, zodSchemas */ + ); } match = urlPatterns.relationship.match(path); @@ -652,8 +659,8 @@ class RequestHandler { prisma: DbClientContract, type: string, _query: Record | undefined, - requestBody: unknown, - zodSchemas?: ModelZodSchema + requestBody: unknown + // zodSchemas?: ModelZodSchema ): Promise { const typeInfo = this.typeMap[type]; if (!typeInfo) { @@ -667,22 +674,19 @@ class RequestHandler { } const parsedPayload = parsed.data; - - const attributes = parsedPayload.data?.attributes; - if (!attributes) { - return this.makeError('invalidPayload'); - } - - const createPayload: any = { data: { ...attributes } }; - - // zod-parse attributes if a schema is provided - const dataSchema = zodSchemas ? getZodSchema(zodSchemas, type, 'create') : undefined; - if (dataSchema) { - const dataParsed = dataSchema.safeParse(createPayload); - if (!dataParsed.success) { - return this.makeError('invalidPayload', fromZodError(dataParsed.error).message); - } - } + const createPayload: any = { data: { ...parsedPayload.data?.attributes } }; + + // TODO: we need to somehow exclude relation fields from the zod schema because relations are + // not part of "data" in the payload + // + // // zod-parse attributes if a schema is provided + // const dataSchema = zodSchemas ? getZodSchema(zodSchemas, type, 'create') : undefined; + // if (dataSchema) { + // const dataParsed = dataSchema.safeParse(createPayload); + // if (!dataParsed.success) { + // return this.makeError('invalidPayload', fromZodError(dataParsed.error).message); + // } + // } // turn relashionship payload into Prisma connect objects const relationships = parsedPayload.data?.relationships; @@ -822,8 +826,8 @@ class RequestHandler { type: any, resourceId: string, query: Record | undefined, - requestBody: unknown, - zodSchemas?: ModelZodSchema + requestBody: unknown + // zodSchemas?: ModelZodSchema ): Promise { const typeInfo = this.typeMap[type]; if (!typeInfo) { @@ -848,14 +852,17 @@ class RequestHandler { data: { ...attributes }, }; - // zod-parse attributes if a schema is provided - const dataSchema = zodSchemas ? getZodSchema(zodSchemas, type, 'update') : undefined; - if (dataSchema) { - const dataParsed = dataSchema.safeParse(updatePayload); - if (!dataParsed.success) { - return this.makeError('invalidPayload', fromZodError(dataParsed.error).message); - } - } + // TODO: we need to somehow exclude relation fields from the zod schema because relations are + // not part of "data" in the payload + // + // // zod-parse attributes if a schema is provided + // const dataSchema = zodSchemas ? getZodSchema(zodSchemas, type, 'update') : undefined; + // if (dataSchema) { + // const dataParsed = dataSchema.safeParse(updatePayload); + // if (!dataParsed.success) { + // return this.makeError('invalidPayload', fromZodError(dataParsed.error).message); + // } + // } // turn relationships into prisma payload const relationships = parsedPayload.data?.relationships; diff --git a/packages/server/tests/api/rest-petstore.test.ts b/packages/server/tests/api/rest-petstore.test.ts new file mode 100644 index 000000000..69c9075df --- /dev/null +++ b/packages/server/tests/api/rest-petstore.test.ts @@ -0,0 +1,118 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/// + +import { loadSchema, run } from '@zenstackhq/testtools'; +import { ModelMeta } from '@zenstackhq/runtime/enhancements/types'; +import makeHandler from '../../src/api/rest'; +import { Response } from '../../src/types'; + +let prisma: any; +let zodSchemas: any; +let modelMeta: ModelMeta; +let db: any; +let handler: (any: any) => Promise; + +describe('REST server tests - Pet Store API', () => { + const schema = ` + model User { + id String @id @default(cuid()) + email String @unique + orders Order[] + + // everybody can signup + @@allow('create', true) + + // user profile is publicly readable + @@allow('read', true) + } + + model Pet { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + category String + order Order? @relation(fields: [orderId], references: [id]) + orderId String? + + // unsold pets are readable to all; sold ones are readable to buyers only + @@allow('read', orderId == null || order.user == auth()) + + // only allow update to 'orderId' field if it's not set yet (unsold) + @@allow('update', name == future().name && category == future().category && orderId == null ) + } + + model Order { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + pets Pet[] + user User @relation(fields: [userId], references: [id]) + userId String + + // users can read their orders + @@allow('read,create', auth() == user) + } + `; + + beforeAll(async () => { + const params = await loadSchema(schema); + + prisma = params.prisma; + db = params.withPresets({ id: 'user1' }); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; + + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + }); + + beforeEach(async () => { + run('npx prisma migrate reset --force'); + run('npx prisma db push'); + + const petData = [ + { + id: 'luna', + name: 'Luna', + category: 'kitten', + }, + { + id: 'max', + name: 'Max', + category: 'doggie', + }, + { + id: 'cooper', + name: 'Cooper', + category: 'reptile', + }, + ]; + + for (const pet of petData) { + await prisma.pet.create({ data: pet }); + } + + await prisma.user.create({ data: { id: 'user1', email: 'user1@abc.com' } }); + }); + + it('crud test', async () => { + const r = await handler({ + method: 'post', + path: '/order', + prisma: db, + requestBody: { + data: { + type: 'order', + relationships: { + user: { data: { type: 'user', id: 'user1' } }, + pets: { data: [{ type: 'pet', id: 'luna' }] }, + }, + }, + }, + }); + expect(r.status).toBe(201); + expect((r.body as any).data.relationships.user.data.id).toBe('user1'); + expect((r.body as any).data.relationships.pets.data[0].id).toBe('luna'); + }); +}); diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 94130fed8..5bd0c6c76 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -131,7 +131,6 @@ "dependencies": { "@paralleldrive/cuid2": "^2.2.0", "@types/bcryptjs": "^2.4.2", - "@zenstackhq/sdk": "workspace:*", "bcryptjs": "^2.4.3", "change-case": "^4.1.2", "colors": "1.4.0", @@ -361,7 +360,6 @@ "@types/lower-case-first": "^1.0.1", "@types/node": "^14.18.29", "@types/pluralize": "^0.0.29", - "@zenstackhq/sdk": "workspace:*", "bcryptjs": "^2.4.3", "change-case": "^4.1.2", "colors": "1.4.0", diff --git a/tests/integration/tests/schema/petstore.zmodel b/tests/integration/tests/schema/petstore.zmodel new file mode 100644 index 000000000..fee5521a2 --- /dev/null +++ b/tests/integration/tests/schema/petstore.zmodel @@ -0,0 +1,64 @@ +datasource db { + provider = 'sqlite' + url = 'file:./petstore.db' +} + +generator js { + provider = 'prisma-client-js' + previewFeatures = ['clientExtensions'] +} + +plugin meta { + provider = '@core/model-meta' + output = '.zenstack' +} + +plugin policy { + provider = '@core/access-policy' + output = '.zenstack' +} + +plugin zod { + provider = '@core/zod' + output = '.zenstack/zod' +} + +model User { + id String @id @default(cuid()) + email String @unique + orders Order[] + + // everybody can signup + @@allow('create', true) + + // user profile is publicly readable + @@allow('read', true) +} + +model Pet { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + category String + order Order? @relation(fields: [orderId], references: [id]) + orderId String? + + // unsold pets are readable to all; sold ones are readable to buyers only + @@allow('read', orderId == null || order.user == auth()) + + // only allow update to 'orderId' field if it's not set yet (unsold) + @@allow('update', name == future().name && category == future().category && orderId == null ) +} + +model Order { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + pets Pet[] + user User @relation(fields: [userId], references: [id]) + userId String + + // users can read their orders + @@allow('read,create', auth() == user) +} \ No newline at end of file diff --git a/tests/integration/tests/with-policy/nested-to-many.test.ts b/tests/integration/tests/with-policy/nested-to-many.test.ts index 8eedfd75a..c213065ed 100644 --- a/tests/integration/tests/with-policy/nested-to-many.test.ts +++ b/tests/integration/tests/with-policy/nested-to-many.test.ts @@ -211,7 +211,7 @@ describe('With Policy:nested to-many', () => { }); it('update with delete', async () => { - const { withPolicy } = await loadSchema( + const { withPolicy, prisma } = await loadSchema( ` model M1 { id String @id @default(uuid()) @@ -261,6 +261,19 @@ describe('With Policy:nested to-many', () => { }, }) ).toBeRejectedByPolicy(); + expect(await prisma.m2.findMany()).toHaveLength(5); + + await expect( + db.m1.update({ + where: { id: '1' }, + data: { + m2: { + delete: [{ id: '1' }, { id: '2' }], + }, + }, + }) + ).toBeRejectedByPolicy(); + expect(await prisma.m2.findMany()).toHaveLength(5); await expect( db.m1.update({ diff --git a/tests/integration/tests/with-policy/nested-to-one.test.ts b/tests/integration/tests/with-policy/nested-to-one.test.ts index f14916667..b1f3b7a50 100644 --- a/tests/integration/tests/with-policy/nested-to-one.test.ts +++ b/tests/integration/tests/with-policy/nested-to-one.test.ts @@ -194,7 +194,7 @@ describe('With Policy:nested to-one', () => { }); it('nested relation delete', async () => { - const { withPolicy } = await loadSchema( + const { withPolicy, prisma } = await loadSchema( ` model User { id String @id @default(uuid()) @@ -206,32 +206,48 @@ describe('With Policy:nested to-one', () => { model M1 { id String @id @default(uuid()) value Int - user User @relation(fields: [userId], references: [id]) - userId String @unique + user User? @relation(fields: [userId], references: [id]) + userId String? @unique - @@allow('create', true) - @@allow('all', auth() == user) + @@allow('read,create,update', true) + @@allow('delete', auth().id == 'user1' && value > 0) } ` ); + await withPolicy({ id: 'user1' }).m1.create({ + data: { + id: 'm1', + value: 1, + }, + }); + await expect( - withPolicy({ id: 'user1' }).user.create({ + withPolicy({ id: 'user2' }).user.create({ data: { - id: 'user1', + id: 'user2', m1: { - create: { value: 1 }, + connect: { id: 'm1' }, }, }, }) ).toResolveTruthy(); await expect( - withPolicy({ id: 'user2' }).user.create({ + withPolicy({ id: 'user2' }).user.update({ + where: { id: 'user2' }, data: { - id: 'user2', + m1: { delete: true }, + }, + }) + ).toBeRejectedByPolicy(); + + await expect( + withPolicy({ id: 'user1' }).user.create({ + data: { + id: 'user1', m1: { - create: { value: 2 }, + connect: { id: 'm1' }, }, }, }) @@ -245,5 +261,7 @@ describe('With Policy:nested to-one', () => { }, }) ).toResolveTruthy(); + + expect(await prisma.m1.findMany()).toHaveLength(0); }); }); diff --git a/tests/integration/tests/with-policy/petstore-sample.test.ts b/tests/integration/tests/with-policy/petstore-sample.test.ts new file mode 100644 index 000000000..c04615ffa --- /dev/null +++ b/tests/integration/tests/with-policy/petstore-sample.test.ts @@ -0,0 +1,59 @@ +import { AuthUser } from '@zenstackhq/runtime'; +import { loadSchemaFromFile, run, type WeakDbClientContract } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('Pet Store Policy Tests', () => { + let getDb: (user?: AuthUser) => WeakDbClientContract; + let prisma: WeakDbClientContract; + + beforeAll(async () => { + const { withPolicy, prisma: _prisma } = await loadSchemaFromFile( + path.join(__dirname, '../schema/petstore.zmodel'), + false + ); + getDb = withPolicy; + prisma = _prisma; + }); + + beforeEach(() => { + run('npx prisma migrate reset --force'); + run('npx prisma db push'); + }); + + it('crud', async () => { + const petData = [ + { + id: 'luna', + name: 'Luna', + category: 'kitten', + }, + { + id: 'max', + name: 'Max', + category: 'doggie', + }, + { + id: 'cooper', + name: 'Cooper', + category: 'reptile', + }, + ]; + + for (const pet of petData) { + await prisma.pet.create({ data: pet }); + } + + await prisma.user.create({ data: { id: 'user1', email: 'user1@abc.com' } }); + + const r = await getDb({ id: 'user1' }).order.create({ + include: { user: true, pets: true }, + data: { + user: { connect: { id: 'user1' } }, + pets: { connect: [{ id: 'luna' }, { id: 'max' }] }, + }, + }); + + expect(r.user.id).toBe('user1'); + expect(r.pets).toHaveLength(2); + }); +}); From f18ed359b6a7fc60ae37dcabab7d0329beb8f84a Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 3 Jun 2023 11:39:18 +0800 Subject: [PATCH 2/3] update --- package.json | 2 +- packages/language/package.json | 2 +- packages/next/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/react/package.json | 2 +- packages/plugins/swr/package.json | 2 +- packages/plugins/tanstack-query/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- .../runtime/src/enhancements/nested-write-vistor.ts | 2 +- .../runtime/src/enhancements/policy/policy-utils.ts | 11 ++++++++++- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- tests/integration/test-run/package-lock.json | 4 ++-- 16 files changed, 26 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index f5e233bee..a4db43a1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 6e118a593..6ed115909 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/next/package.json b/packages/next/package.json index c850c6b1c..b1cd49f56 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 4dc0960fd..6e838a44c 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index 8a921beb5..d1080bd83 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index abfcc5d3b..80089acfb 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index fa9453689..29bc3f6bd 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index ff2feec7c..27a8458ed 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 511139a15..6192c379c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-vistor.ts index be6d4e9aa..3143cd4b3 100644 --- a/packages/runtime/src/enhancements/nested-write-vistor.ts +++ b/packages/runtime/src/enhancements/nested-write-vistor.ts @@ -52,7 +52,7 @@ export type NestedWriterVisitorCallback = { delete?: (model: string, args: object | boolean, context: VisitorContext) => Promise; - deleteMany?: (model: string, args: { where: object }, context: VisitorContext) => Promise; + deleteMany?: (model: string, args: any | object, context: VisitorContext) => Promise; field?: (field: FieldInfo, action: PrismaWriteActionType, data: any, context: VisitorContext) => Promise; }; diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index 6e1ef58c1..c53f94572 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -638,7 +638,16 @@ export class PolicyUtil { if (guard === false) { throw this.deniedByPolicy(model, 'delete'); } else if (guard !== true) { - args.where = this.and(args.where, guard); + if (args.where) { + args.where = this.and(args.where, guard); + } else { + const copy = deepcopy(args); + for (const key of Object.keys(args)) { + delete args[key]; + } + const combined = this.and(copy, guard); + Object.assign(args, combined); + } } }, }); diff --git a/packages/schema/package.json b/packages/schema/package.json index 5fadda400..341a50c29 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5407cda13..3b4690991 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index baa448f10..1e804b28d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 04d2ea308..218e7b471 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 5bd0c6c76..c34bab4bd 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "license": "MIT", "dependencies": { "@paralleldrive/cuid2": "^2.2.0", @@ -159,7 +159,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.122", + "version": "1.0.0-alpha.123", "hasInstallScript": true, "license": "MIT", "dependencies": { From 28b2e4e4a12506980116719e900714631e076f92 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Sat, 3 Jun 2023 11:39:43 +0800 Subject: [PATCH 3/3] update --- package.json | 2 +- packages/language/package.json | 2 +- packages/next/package.json | 2 +- packages/plugins/openapi/package.json | 2 +- packages/plugins/react/package.json | 2 +- packages/plugins/swr/package.json | 2 +- packages/plugins/tanstack-query/package.json | 2 +- packages/plugins/trpc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/testtools/package.json | 2 +- tests/integration/test-run/package-lock.json | 4 ++-- 14 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a4db43a1b..292583720 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 6ed115909..5deb9a13f 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/next/package.json b/packages/next/package.json index b1cd49f56..0595299cd 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 6e838a44c..f1f9dc929 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/openapi", "displayName": "ZenStack Plugin and Runtime for OpenAPI", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index d1080bd83..7e6978a84 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 80089acfb..229b82512 100644 --- a/packages/plugins/swr/package.json +++ b/packages/plugins/swr/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/swr", "displayName": "ZenStack plugin for generating SWR hooks", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 29bc3f6bd..5c6159071 100644 --- a/packages/plugins/tanstack-query/package.json +++ b/packages/plugins/tanstack-query/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/tanstack-query", "displayName": "ZenStack plugin for generating tanstack-query hooks", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 27a8458ed..bf6bc0bb2 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 6192c379c..148954304 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index 341a50c29..a0bed4858 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "author": { "name": "ZenStack Team" }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 3b4690991..0d4bea15b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 1e804b28d..725b3289f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 218e7b471..0118f2cd7 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index c34bab4bd..2f1594ce2 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "license": "MIT", "dependencies": { "@paralleldrive/cuid2": "^2.2.0", @@ -159,7 +159,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.123", + "version": "1.0.0-alpha.124", "hasInstallScript": true, "license": "MIT", "dependencies": {