diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 2f28cead0..9ba516b23 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: Feature request about: Suggest an idea for this project -title: '' +title: '[Feature Request] ' labels: '' assignees: '' --- diff --git a/package.json b/package.json index 5f1fa99e5..6a1d2d69e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-beta.21", + "version": "1.0.0-beta.22", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index a6697b54c..44f3e3989 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-beta.21", + "version": "1.0.0-beta.22", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index 5ab874b25..8afe590da 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-beta.21", + "version": "1.0.0-beta.22", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/openapi/src/rest-generator.ts b/packages/plugins/openapi/src/rest-generator.ts index a6c313f79..175268081 100644 --- a/packages/plugins/openapi/src/rest-generator.ts +++ b/packages/plugins/openapi/src/rest-generator.ts @@ -596,6 +596,15 @@ export class RESTfulOpenAPIGenerator extends OpenAPIGeneratorBase { }, title: { type: 'string', description: 'Error title' }, detail: { type: 'string', description: 'Error detail' }, + reason: { + type: 'string', + description: 'Detailed error reason', + }, + zodErrors: { + type: 'object', + additionalProperties: true, + description: 'Zod validation errors if the error is due to data validation failure', + }, }, }, }, diff --git a/packages/plugins/openapi/src/rpc-generator.ts b/packages/plugins/openapi/src/rpc-generator.ts index 0bddd1f73..29657f68c 100644 --- a/packages/plugins/openapi/src/rpc-generator.ts +++ b/packages/plugins/openapi/src/rpc-generator.ts @@ -661,6 +661,11 @@ export class RPCOpenAPIGenerator extends OpenAPIGeneratorBase { type: 'string', description: 'Detailed error reason', }, + zodErrors: { + type: 'object', + additionalProperties: true, + description: 'Zod validation errors if the error is due to data validation failure', + }, }, additionalProperties: true, }, diff --git a/packages/plugins/openapi/tests/baseline/rest-type-coverage.baseline.yaml b/packages/plugins/openapi/tests/baseline/rest-type-coverage.baseline.yaml index 72f43187f..3577cdb2e 100644 --- a/packages/plugins/openapi/tests/baseline/rest-type-coverage.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rest-type-coverage.baseline.yaml @@ -564,6 +564,14 @@ components: detail: type: string description: Error detail + reason: + type: string + description: Detailed error reason + zodErrors: + type: object + additionalProperties: true + description: Zod validation errors if the error is due to data validation + failure _errorResponse: type: object required: diff --git a/packages/plugins/openapi/tests/baseline/rest.baseline.yaml b/packages/plugins/openapi/tests/baseline/rest.baseline.yaml index c7a1f0df9..10153859a 100644 --- a/packages/plugins/openapi/tests/baseline/rest.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rest.baseline.yaml @@ -1395,6 +1395,14 @@ components: detail: type: string description: Error detail + reason: + type: string + description: Detailed error reason + zodErrors: + type: object + additionalProperties: true + description: Zod validation errors if the error is due to data validation + failure _errorResponse: type: object required: diff --git a/packages/plugins/openapi/tests/baseline/rpc-type-coverage.baseline.yaml b/packages/plugins/openapi/tests/baseline/rpc-type-coverage.baseline.yaml index a5cc407bc..cbe6476f8 100644 --- a/packages/plugins/openapi/tests/baseline/rpc-type-coverage.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rpc-type-coverage.baseline.yaml @@ -19,16 +19,16 @@ components: - decimal - boolean - bytes - QueryMode: - type: string - enum: - - default - - insensitive SortOrder: type: string enum: - asc - desc + QueryMode: + type: string + enum: + - default + - insensitive Foo: type: object properties: @@ -368,13 +368,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -401,13 +405,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -426,13 +434,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -452,15 +464,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -484,13 +502,17 @@ components: equals: type: number in: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number notIn: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number lt: type: number lte: @@ -511,17 +533,25 @@ components: - type: string - type: number in: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number notIn: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number lt: oneOf: - type: string @@ -560,15 +590,21 @@ components: type: string format: byte in: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte notIn: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte not: oneOf: - type: string @@ -580,13 +616,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -619,13 +659,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -654,13 +698,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -690,15 +738,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -728,13 +782,17 @@ components: equals: type: number in: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number notIn: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number lt: type: number lte: @@ -765,17 +823,25 @@ components: - type: string - type: number in: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number notIn: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number lt: oneOf: - type: string @@ -830,15 +896,21 @@ components: type: string format: byte in: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte notIn: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte not: oneOf: - type: string @@ -940,13 +1012,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -971,13 +1047,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -996,13 +1076,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1022,15 +1106,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -1054,13 +1144,17 @@ components: equals: type: number in: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number notIn: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number lt: type: number lte: @@ -1081,17 +1175,25 @@ components: - type: string - type: number in: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number notIn: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number lt: oneOf: - type: string @@ -1130,15 +1232,21 @@ components: type: string format: byte in: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte notIn: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte not: oneOf: - type: string @@ -1150,13 +1258,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -1187,13 +1299,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1222,13 +1338,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1258,15 +1378,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -1296,13 +1422,17 @@ components: equals: type: number in: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number notIn: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number lt: type: number lte: @@ -1333,17 +1463,25 @@ components: - type: string - type: number in: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number notIn: - type: array - items: - oneOf: - - type: string - - type: number + oneOf: + - type: array + items: + oneOf: + - type: string + - type: number + - oneOf: + - type: string + - type: number lt: oneOf: - type: string @@ -1398,15 +1536,21 @@ components: type: string format: byte in: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte notIn: - type: array - items: - type: string - format: byte + oneOf: + - type: array + items: + type: string + format: byte + - type: string + format: byte not: oneOf: - type: string @@ -1729,6 +1873,11 @@ components: reason: type: string description: Detailed error reason + zodErrors: + type: object + additionalProperties: true + description: Zod validation errors if the error is due to data validation + failure additionalProperties: true BatchPayload: type: object diff --git a/packages/plugins/openapi/tests/baseline/rpc.baseline.yaml b/packages/plugins/openapi/tests/baseline/rpc.baseline.yaml index 4e87045be..e35f7bd12 100644 --- a/packages/plugins/openapi/tests/baseline/rpc.baseline.yaml +++ b/packages/plugins/openapi/tests/baseline/rpc.baseline.yaml @@ -14,6 +14,14 @@ components: enum: - USER - ADMIN + UserScalarFieldEnum: + type: string + enum: + - id + - createdAt + - updatedAt + - email + - role Post_ItemScalarFieldEnum: type: string enum: @@ -24,24 +32,21 @@ components: - authorId - published - viewCount - QueryMode: - type: string - enum: - - default - - insensitive SortOrder: type: string enum: - asc - desc - UserScalarFieldEnum: + QueryMode: type: string enum: - - id - - createdAt - - updatedAt - - email - - role + - default + - insensitive + NullsOrder: + type: string + enum: + - first + - last User: type: object properties: @@ -238,10 +243,6 @@ components: oneOf: - $ref: '#/components/schemas/StringFilter' - type: string - author: - oneOf: - - $ref: '#/components/schemas/UserRelationFilter' - - $ref: '#/components/schemas/UserWhereInput' authorId: oneOf: - $ref: '#/components/schemas/StringNullableFilter' @@ -254,6 +255,10 @@ components: oneOf: - $ref: '#/components/schemas/IntFilter' - type: integer + author: + oneOf: + - $ref: '#/components/schemas/UserRelationFilter' + - $ref: '#/components/schemas/UserWhereInput' Post_ItemOrderByWithRelationInput: type: object properties: @@ -265,14 +270,16 @@ components: $ref: '#/components/schemas/SortOrder' title: $ref: '#/components/schemas/SortOrder' - author: - $ref: '#/components/schemas/UserOrderByWithRelationInput' authorId: - $ref: '#/components/schemas/SortOrder' + oneOf: + - $ref: '#/components/schemas/SortOrder' + - $ref: '#/components/schemas/SortOrderInput' published: $ref: '#/components/schemas/SortOrder' viewCount: $ref: '#/components/schemas/SortOrder' + author: + $ref: '#/components/schemas/UserOrderByWithRelationInput' Post_ItemWhereUniqueInput: type: object properties: @@ -430,12 +437,12 @@ components: format: date-time title: type: string - author: - $ref: '#/components/schemas/UserCreateNestedOneWithoutPostsInput' published: type: boolean viewCount: type: integer + author: + $ref: '#/components/schemas/UserCreateNestedOneWithoutPostsInput' required: - id - title @@ -460,8 +467,6 @@ components: oneOf: - type: string - $ref: '#/components/schemas/StringFieldUpdateOperationsInput' - author: - $ref: '#/components/schemas/UserUpdateOneWithoutPostsNestedInput' published: oneOf: - type: boolean @@ -470,6 +475,8 @@ components: oneOf: - type: integer - $ref: '#/components/schemas/IntFieldUpdateOperationsInput' + author: + $ref: '#/components/schemas/UserUpdateOneWithoutPostsNestedInput' Post_ItemCreateManyInput: type: object properties: @@ -527,13 +534,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -561,15 +572,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -593,13 +610,17 @@ components: equals: $ref: '#/components/schemas/Role' in: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' notIn: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' not: oneOf: - $ref: '#/components/schemas/Role' @@ -624,13 +645,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -664,15 +689,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -702,13 +733,17 @@ components: equals: $ref: '#/components/schemas/Role' in: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' notIn: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' not: oneOf: - $ref: '#/components/schemas/Role' @@ -719,26 +754,23 @@ components: $ref: '#/components/schemas/NestedEnumroleFilter' _max: $ref: '#/components/schemas/NestedEnumroleFilter' - UserRelationFilter: - type: object - properties: - is: - $ref: '#/components/schemas/UserWhereInput' - isNot: - $ref: '#/components/schemas/UserWhereInput' StringNullableFilter: type: object properties: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -774,13 +806,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -793,19 +829,39 @@ components: oneOf: - type: integer - $ref: '#/components/schemas/NestedIntFilter' + UserRelationFilter: + type: object + properties: + is: + $ref: '#/components/schemas/UserWhereInput' + isNot: + $ref: '#/components/schemas/UserWhereInput' + SortOrderInput: + type: object + properties: + sort: + $ref: '#/components/schemas/SortOrder' + nulls: + $ref: '#/components/schemas/NullsOrder' + required: + - sort StringNullableWithAggregatesFilter: type: object properties: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -853,13 +909,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1005,6 +1065,24 @@ components: $ref: '#/components/schemas/UserCreateOrConnectWithoutPostsInput' connect: $ref: '#/components/schemas/UserWhereUniqueInput' + BoolFieldUpdateOperationsInput: + type: object + properties: + set: + type: boolean + IntFieldUpdateOperationsInput: + type: object + properties: + set: + type: integer + increment: + type: integer + decrement: + type: integer + multiply: + type: integer + divide: + type: integer UserUpdateOneWithoutPostsNestedInput: type: object properties: @@ -1026,37 +1104,23 @@ components: oneOf: - $ref: '#/components/schemas/UserUpdateWithoutPostsInput' - $ref: '#/components/schemas/UserUncheckedUpdateWithoutPostsInput' - BoolFieldUpdateOperationsInput: - type: object - properties: - set: - type: boolean - IntFieldUpdateOperationsInput: - type: object - properties: - set: - type: integer - increment: - type: integer - decrement: - type: integer - multiply: - type: integer - divide: - type: integer NestedStringFilter: type: object properties: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -1082,15 +1146,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -1114,13 +1184,17 @@ components: equals: $ref: '#/components/schemas/Role' in: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' notIn: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' not: oneOf: - $ref: '#/components/schemas/Role' @@ -1131,13 +1205,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -1168,13 +1246,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1194,15 +1276,21 @@ components: type: string format: date-time in: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time notIn: - type: array - items: - type: string - format: date-time + oneOf: + - type: array + items: + type: string + format: date-time + - type: string + format: date-time lt: type: string format: date-time @@ -1232,13 +1320,17 @@ components: equals: $ref: '#/components/schemas/Role' in: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' notIn: - type: array - items: - $ref: '#/components/schemas/Role' + oneOf: + - type: array + items: + $ref: '#/components/schemas/Role' + - $ref: '#/components/schemas/Role' not: oneOf: - $ref: '#/components/schemas/Role' @@ -1255,13 +1347,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -1295,13 +1391,17 @@ components: equals: type: string in: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string notIn: - type: array - items: - type: string + oneOf: + - type: array + items: + type: string + - type: string lt: type: string lte: @@ -1332,13 +1432,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1372,13 +1476,17 @@ components: equals: type: integer in: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer notIn: - type: array - items: - type: integer + oneOf: + - type: array + items: + type: integer + - type: integer lt: type: integer lte: @@ -1407,13 +1515,17 @@ components: equals: type: number in: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number notIn: - type: array - items: - type: number + oneOf: + - type: array + items: + type: number + - type: number lt: type: number lte: @@ -1482,9 +1594,11 @@ components: type: object properties: data: - type: array - items: - $ref: '#/components/schemas/Post_ItemCreateManyAuthorInput' + oneOf: + - $ref: '#/components/schemas/Post_ItemCreateManyAuthorInput' + - type: array + items: + $ref: '#/components/schemas/Post_ItemCreateManyAuthorInput' skipDuplicates: type: boolean required: @@ -2164,6 +2278,11 @@ components: reason: type: string description: Detailed error reason + zodErrors: + type: object + additionalProperties: true + description: Zod validation errors if the error is due to data validation + failure additionalProperties: true BatchPayload: type: object diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index e2ebaedc8..0311eee85 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-beta.21", + "version": "1.0.0-beta.22", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/src/generator.ts b/packages/plugins/swr/src/generator.ts index 31431e74e..0ad8177f8 100644 --- a/packages/plugins/swr/src/generator.ts +++ b/packages/plugins/swr/src/generator.ts @@ -14,7 +14,7 @@ import { paramCase } from 'change-case'; import { lowerCaseFirst } from 'lower-case-first'; import path from 'path'; import semver from 'semver'; -import { FunctionDeclaration, Project, SourceFile } from 'ts-morph'; +import { FunctionDeclaration, OptionalKind, ParameterDeclarationStructure, Project, SourceFile } from 'ts-morph'; import { upperCaseFirst } from 'upper-case-first'; export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.Document) { @@ -61,7 +61,7 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, }); sf.addStatements([ `import { useContext } from 'react';`, - `import { RequestHandlerContext, type RequestOptions, type PickEnumerable, type CheckSelect } from '@zenstackhq/swr/runtime';`, + `import { RequestHandlerContext, type GetNextArgs, type RequestOptions, type InfiniteRequestOptions, type PickEnumerable, type CheckSelect } from '@zenstackhq/swr/runtime';`, `import * as request from '@zenstackhq/swr/runtime';`, ]); @@ -108,7 +108,12 @@ function generateModelHooks(project: Project, outDir: string, model: DataModel, const argsType = `Prisma.${model.name}FindManyArgs`; const inputType = `Prisma.SelectSubset`; const returnType = `Array>`; + + // regular findMany generateQueryHook(sf, model, 'findMany', argsType, inputType, returnType); + + // infinite findMany + generateQueryHook(sf, model, 'findMany', argsType, inputType, returnType, undefined, true); } // findUnique @@ -289,28 +294,45 @@ function generateQueryHook( argsType: string, inputType: string, returnType: string, - typeParameters?: string[] + typeParameters?: string[], + infinite = false ) { const modelRouteName = lowerCaseFirst(model.name); + + const typeParams = typeParameters ? [...typeParameters] : [`T extends ${argsType}`]; + if (infinite) { + typeParams.push(`R extends ${returnType}`); + } + + const parameters: OptionalKind[] = []; + if (!infinite) { + parameters.push({ + name: 'args?', + type: inputType, + }); + } else { + parameters.push({ + name: 'getNextArgs', + type: `GetNextArgs<${inputType} | undefined, R>`, + }); + } + parameters.push({ + name: 'options?', + type: infinite ? `InfiniteRequestOptions<${returnType}>` : `RequestOptions<${returnType}>`, + }); + sf.addFunction({ - name: `use${upperCaseFirst(operation)}${model.name}`, - typeParameters: typeParameters ?? [`T extends ${argsType}`], + name: `use${infinite ? 'Infinite' : ''}${upperCaseFirst(operation)}${model.name}`, + typeParameters: typeParams, isExported: true, - parameters: [ - { - name: 'args?', - type: inputType, - }, - { - name: 'options?', - type: `RequestOptions<${returnType}>`, - }, - ], + parameters, }) .addBody() .addStatements([ 'const { endpoint, fetch } = useContext(RequestHandlerContext);', - `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/${operation}\`, args, options, fetch);`, + !infinite + ? `return request.get<${returnType}>(\`\${endpoint}/${modelRouteName}/${operation}\`, args, options, fetch);` + : `return request.infiniteGet<${inputType} | undefined, ${returnType}>(\`\${endpoint}/${modelRouteName}/${operation}\`, getNextArgs, options, fetch);`, ]); } diff --git a/packages/plugins/swr/src/runtime/index.ts b/packages/plugins/swr/src/runtime/index.ts index 78b17242f..3b82af812 100644 --- a/packages/plugins/swr/src/runtime/index.ts +++ b/packages/plugins/swr/src/runtime/index.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { deserialize, serialize } from '@zenstackhq/runtime/browser'; import { createContext } from 'react'; -import type { MutatorCallback, MutatorOptions, SWRResponse } from 'swr'; +import type { Fetcher, MutatorCallback, MutatorOptions, SWRConfiguration, SWRResponse } from 'swr'; import useSWR, { useSWRConfig } from 'swr'; +import useSWRInfinite, { SWRInfiniteConfiguration, SWRInfiniteFetcher, SWRInfiniteResponse } from 'swr/infinite'; export * from './prisma-types'; /** @@ -39,31 +40,92 @@ export const RequestHandlerContext = createContext({ export const Provider = RequestHandlerContext.Provider; /** - * Client request options + * Client request options for regular query. */ -export type RequestOptions = { - // disable data fetching +export type RequestOptions = { + /** + * Disable data fetching + */ + disabled?: boolean; + + /** + * Equivalent to @see SWRConfiguration.fallbackData + */ + initialData?: Result; +} & SWRConfiguration>; + +/** + * Client request options for infinite query. + */ +export type InfiniteRequestOptions = { + /** + * Disable data fetching + */ disabled?: boolean; - initialData?: T; -}; + + /** + * Equivalent to @see SWRInfiniteConfiguration.fallbackData + */ + initialData?: Result[]; +} & SWRInfiniteConfiguration>; /** * Makes a GET request with SWR. * * @param url The request URL. * @param args The request args object, which will be superjson-stringified and appended as "?q=" parameter + * @param options Query options + * @param fetch Custom fetch function * @returns SWR response */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function get( url: string | null, args?: unknown, - options?: RequestOptions, + options?: RequestOptions, fetch?: FetchFn ): SWRResponse { const reqUrl = options?.disabled ? null : url ? makeUrl(url, args) : null; return useSWR(reqUrl, (url) => fetcher(url, undefined, fetch, false), { - fallbackData: options?.initialData, + ...options, + fallbackData: options?.initialData ?? options?.fallbackData, + }); +} + +/** + * Function for computing the query args for fetching a page during an infinite query. + */ +export type GetNextArgs = (pageIndex: number, previousPageData: Result | null) => Args | null; + +/** + * Makes an infinite GET request with SWR. + * + * @param url The request URL. + * @param getNextArgs Function for computing the query args for a page. + * @param options Query options + * @param fetch Custom fetch function + * @returns SWR infinite query response + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function infiniteGet( + url: string | null, + getNextArgs: GetNextArgs, + options?: InfiniteRequestOptions, + fetch?: FetchFn +): SWRInfiniteResponse { + const getKey = (pageIndex: number, previousPageData: Result | null) => { + if (options?.disabled || !url) { + return null; + } + const nextArgs = getNextArgs(pageIndex, previousPageData); + return nextArgs !== null // null means reached the end + ? makeUrl(url, nextArgs) + : null; + }; + + return useSWRInfinite(getKey, (url) => fetcher(url, undefined, fetch, false), { + ...options, + fallbackData: options?.initialData ?? options?.fallbackData, }); } diff --git a/packages/plugins/swr/tests/swr.test.ts b/packages/plugins/swr/tests/swr.test.ts index 4659718da..fca774d64 100644 --- a/packages/plugins/swr/tests/swr.test.ts +++ b/packages/plugins/swr/tests/swr.test.ts @@ -58,7 +58,7 @@ ${sharedModel} { provider: 'postgresql', pushDb: false, - extraDependencies: [`${origDir}/dist`, 'react', '@types/react', 'swr'], + extraDependencies: [`${origDir}/dist`, 'react@18.2.0', '@types/react@18.2.0', 'swr@^2'], compile: true, } ); diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index 4c3a6cebb..eff25fdc8 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-beta.21", + "version": "1.0.0-beta.22", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { @@ -11,6 +11,12 @@ "./package.json": { "default": "./package.json" }, + "./runtime": { + "import": "./runtime/index.mjs", + "require": "./runtime/index.js", + "default": "./runtime/index.js", + "types": "./runtime/index.d.ts" + }, "./runtime/react": { "import": "./runtime/react.mjs", "require": "./runtime/react.js", diff --git a/packages/plugins/tanstack-query/src/generator.ts b/packages/plugins/tanstack-query/src/generator.ts index 1b5e4178e..4c0ddee3c 100644 --- a/packages/plugins/tanstack-query/src/generator.ts +++ b/packages/plugins/tanstack-query/src/generator.ts @@ -62,7 +62,8 @@ function generateQueryHook( optionalInput: boolean, overrideReturnType?: string, overrideInputType?: string, - overrideTypeParameters?: string[] + overrideTypeParameters?: string[], + infinite = false ) { const capOperation = upperCaseFirst(operation); @@ -70,10 +71,10 @@ function generateQueryHook( const inputType = `Prisma.SelectSubset`; const returnType = overrideReturnType ?? (returnArray ? `Array>` : `Prisma.${model}GetPayload`); - const optionsType = makeQueryOptions(target, returnType); + const optionsType = makeQueryOptions(target, returnType, infinite); const func = sf.addFunction({ - name: `use${capOperation}${model}`, + name: `use${infinite ? 'Infinite' : ''}${capOperation}${model}`, typeParameters: overrideTypeParameters ?? [`T extends ${argsType}`], parameters: [ { @@ -90,7 +91,7 @@ function generateQueryHook( func.addStatements([ makeGetContext(target), - `return query<${returnType}>('${model}', \`\${endpoint}/${lowerCaseFirst( + `return ${infinite ? 'infiniteQuery' : 'query'}<${returnType}>('${model}', \`\${endpoint}/${lowerCaseFirst( model )}/${operation}\`, args, options, fetch);`, ]); @@ -248,7 +249,10 @@ function generateModelHooks( // findMany if (mapping.findMany) { + // regular findMany generateQueryHook(target, sf, model.name, 'findMany', true, true); + // infinite findMany + generateQueryHook(target, sf, model.name, 'findMany', true, true, undefined, undefined, undefined, true); } // findUnique @@ -431,14 +435,14 @@ function makeGetContext(target: TargetFramework) { function makeBaseImports(target: TargetFramework) { const shared = [ - `import { query, postMutation, putMutation, deleteMutation } from '@zenstackhq/tanstack-query/runtime/${target}';`, + `import { query, infiniteQuery, postMutation, putMutation, deleteMutation } from '@zenstackhq/tanstack-query/runtime/${target}';`, `import type { PickEnumerable, CheckSelect } from '@zenstackhq/tanstack-query/runtime';`, ]; switch (target) { case 'react': return [ `import { useContext } from 'react';`, - `import type { UseMutationOptions, UseQueryOptions } from '@tanstack/react-query';`, + `import type { UseMutationOptions, UseQueryOptions, UseInfiniteQueryOptions } from '@tanstack/react-query';`, `import { RequestHandlerContext } from '@zenstackhq/tanstack-query/runtime/${target}';`, ...shared, ]; @@ -446,7 +450,7 @@ function makeBaseImports(target: TargetFramework) { return [ `import { getContext } from 'svelte';`, `import { derived } from 'svelte/store';`, - `import type { MutationOptions, QueryOptions } from '@tanstack/svelte-query';`, + `import type { MutationOptions, QueryOptions, CreateInfiniteQueryOptions } from '@tanstack/svelte-query';`, `import { SvelteQueryContextKey, type RequestHandlerContext } from '@zenstackhq/tanstack-query/runtime/${target}';`, ...shared, ]; @@ -455,12 +459,12 @@ function makeBaseImports(target: TargetFramework) { } } -function makeQueryOptions(target: string, returnType: string) { +function makeQueryOptions(target: string, returnType: string, infinite: boolean) { switch (target) { case 'react': - return `UseQueryOptions<${returnType}>`; + return `Use${infinite ? 'Infinite' : ''}QueryOptions<${returnType}>`; case 'svelte': - return `QueryOptions<${returnType}>`; + return `${infinite ? 'CreateInfinite' : ''}QueryOptions<${returnType}>`; default: throw new PluginError(name, `Unsupported target: ${target}`); } diff --git a/packages/plugins/tanstack-query/src/runtime/react.ts b/packages/plugins/tanstack-query/src/runtime/react.ts index cdbbe1b68..1eca9f045 100644 --- a/packages/plugins/tanstack-query/src/runtime/react.ts +++ b/packages/plugins/tanstack-query/src/runtime/react.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { + useInfiniteQuery, useMutation, useQuery, useQueryClient, type MutateFunction, type QueryClient, + type UseInfiniteQueryOptions, type UseMutationOptions, type UseQueryOptions, } from '@tanstack/react-query'; @@ -41,7 +43,6 @@ export const Provider = RequestHandlerContext.Provider; * @param options The react-query options object * @returns useQuery hook */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export function query(model: string, url: string, args?: unknown, options?: UseQueryOptions, fetch?: FetchFn) { const reqUrl = makeUrl(url, args); return useQuery({ @@ -51,6 +52,31 @@ export function query(model: string, url: string, args?: unknown, options?: U }); } +/** + * Creates a react-query infinite query. + * + * @param model The name of the model under query. + * @param url The request URL. + * @param args The initial request args object, URL-encoded and appended as "?q=" parameter + * @param options The react-query infinite query options object + * @returns useInfiniteQuery hook + */ +export function infiniteQuery( + model: string, + url: string, + args?: unknown, + options?: UseInfiniteQueryOptions, + fetch?: FetchFn +) { + return useInfiniteQuery({ + queryKey: [QUERY_KEY_PREFIX + model, url, args], + queryFn: ({ pageParam }) => { + return fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false); + }, + ...options, + }); +} + /** * Creates a POST mutation with react-query. * diff --git a/packages/plugins/tanstack-query/src/runtime/svelte.ts b/packages/plugins/tanstack-query/src/runtime/svelte.ts index 7edab8edc..3b888e87e 100644 --- a/packages/plugins/tanstack-query/src/runtime/svelte.ts +++ b/packages/plugins/tanstack-query/src/runtime/svelte.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { + createInfiniteQuery, createMutation, createQuery, useQueryClient, + type CreateInfiniteQueryOptions, type MutateFunction, type MutationOptions, type QueryClient, @@ -26,7 +28,6 @@ export const SvelteQueryContextKey = 'zenstack-svelte-query-context'; * @param options The svelte-query options object * @returns useQuery hook */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any export function query(model: string, url: string, args?: unknown, options?: QueryOptions, fetch?: FetchFn) { const reqUrl = makeUrl(url, args); return createQuery({ @@ -36,6 +37,29 @@ export function query(model: string, url: string, args?: unknown, options?: Q }); } +/** + * Creates a svelte-query infinite query. + * + * @param model The name of the model under query. + * @param url The request URL. + * @param args The initial request args object, URL-encoded and appended as "?q=" parameter + * @param options The svelte-query infinite query options object + * @returns useQuery hook + */ +export function infiniteQuery( + model: string, + url: string, + args?: unknown, + options?: CreateInfiniteQueryOptions, + fetch?: FetchFn +) { + return createInfiniteQuery({ + queryKey: [QUERY_KEY_PREFIX + model, url, args], + queryFn: ({ pageParam }) => fetcher(makeUrl(url, pageParam ?? args), undefined, fetch, false), + ...options, + }); +} + /** * Creates a POST mutation with svelte-query. * diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 57113be2a..b18897143 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-beta.21", + "version": "1.0.0-beta.22", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/src/generator.ts b/packages/plugins/trpc/src/generator.ts index 32cc64582..5ce930fe6 100644 --- a/packages/plugins/trpc/src/generator.ts +++ b/packages/plugins/trpc/src/generator.ts @@ -19,7 +19,7 @@ import { name } from '.'; import { generateHelperImport, generateProcedure, - generateRouterSchemaImports, + generateRouterSchemaImport, generateRouterTyping, generateRouterTypingImports, getInputSchemaByOpName, @@ -89,21 +89,22 @@ function createAppRouter( const prismaImport = getPrismaClientImportSpec(zmodel, path.dirname(indexFile)); appRouter.addImportDeclarations([ { - namedImports: ['type AnyRootConfig', 'type Procedure', 'type ProcedureParams', 'type ProcedureType'], + namedImports: [ + 'type AnyRouter', + 'type AnyRootConfig', + 'type CreateRouterInner', + 'type Procedure', + 'type ProcedureBuilder', + 'type ProcedureParams', + 'type ProcedureRouterRecord', + 'type ProcedureType', + ], moduleSpecifier: '@trpc/server', }, { namedImports: ['type PrismaClient', 'type Prisma'], moduleSpecifier: prismaImport, }, - { - namedImports: ['type createRouterFactory', 'AnyRouter'], - moduleSpecifier: '@trpc/server/dist/core/router', - }, - { - namedImports: ['type ProcedureBuilder'], - moduleSpecifier: '@trpc/server/dist/core/internals/procedureBuilder', - }, { defaultImport: 'z', moduleSpecifier: 'zod', isTypeOnly: true }, ]); @@ -113,10 +114,12 @@ function createAppRouter( export type BaseConfig = AnyRootConfig; - export type RouterFactory = ReturnType< - typeof createRouterFactory - >; - + export type RouterFactory = < + ProcRouterRecord extends ProcedureRouterRecord + >( + procedures: ProcRouterRecord + ) => CreateRouterInner; + ${ /** this is needed in order to prevent type errors between a procedure and a middleware-extended procedure */ '' } @@ -299,7 +302,7 @@ function generateModelCreateRouter( }, ]); - generateRouterSchemaImports(modelRouter, upperCaseFirst(model), zodSchemasImport); + generateRouterSchemaImport(modelRouter, zodSchemasImport); generateHelperImport(modelRouter); if (generateClientHelpers) { generateRouterTypingImports(modelRouter, zmodel); diff --git a/packages/plugins/trpc/src/helpers.ts b/packages/plugins/trpc/src/helpers.ts index c3a776556..1fbeb9efd 100644 --- a/packages/plugins/trpc/src/helpers.ts +++ b/packages/plugins/trpc/src/helpers.ts @@ -24,7 +24,7 @@ export function generateProcedure( )}.${prismaMethod}(input as any))) as ProcReturns< "query", Proc, - (typeof ${upperCaseFirst(modelName)}InputSchema)["${opType.replace('OrThrow', '')}"], + (typeof $Schema.${upperCaseFirst(modelName)}InputSchema)["${opType.replace('OrThrow', '')}"], ReturnType >, `); @@ -36,7 +36,7 @@ export function generateProcedure( )}.${prismaMethod}(input as any))) as ProcReturns< "mutation", Proc, - (typeof ${upperCaseFirst(modelName)}InputSchema)["${opType.replace('OrThrow', '')}"], + (typeof $Schema.${upperCaseFirst(modelName)}InputSchema)["${opType.replace('OrThrow', '')}"], ReturnType >, `); @@ -247,8 +247,8 @@ export function generateRouterTypingImports(sourceFile: SourceFile, model: Model } // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function generateRouterSchemaImports(sourceFile: SourceFile, name: string, zodSchemasImport: string) { - sourceFile.addStatements(`import { ${name}InputSchema } from '${zodSchemasImport}/input';`); +export function generateRouterSchemaImport(sourceFile: SourceFile, zodSchemasImport: string) { + sourceFile.addStatements(`import $Schema from '${zodSchemasImport}/input';`); } export function generateHelperImport(sourceFile: SourceFile) { @@ -257,54 +257,55 @@ export function generateHelperImport(sourceFile: SourceFile) { export const getInputSchemaByOpName = (opName: string, modelName: string) => { let inputType; + const capModelName = upperCaseFirst(modelName); switch (opName) { case 'findUnique': - inputType = `${modelName}InputSchema.findUnique`; + inputType = `$Schema.${capModelName}InputSchema.findUnique`; break; case 'findFirst': - inputType = `${modelName}InputSchema.findFirst`; + inputType = `$Schema.${capModelName}InputSchema.findFirst`; break; case 'findMany': - inputType = `${modelName}InputSchema.findMany`; + inputType = `$Schema.${capModelName}InputSchema.findMany`; break; case 'findRaw': - inputType = `${modelName}InputSchema.findRawObject`; + inputType = `$Schema.${capModelName}InputSchema.findRawObject`; break; case 'createOne': - inputType = `${modelName}InputSchema.create`; + inputType = `$Schema.${capModelName}InputSchema.create`; break; case 'createMany': - inputType = `${modelName}InputSchema.createMany`; + inputType = `$Schema.${capModelName}InputSchema.createMany`; break; case 'deleteOne': - inputType = `${modelName}InputSchema.delete`; + inputType = `$Schema.${capModelName}InputSchema.delete`; break; case 'updateOne': - inputType = `${modelName}InputSchema.update`; + inputType = `$Schema.${capModelName}InputSchema.update`; break; case 'deleteMany': - inputType = `${modelName}InputSchema.deleteMany`; + inputType = `$Schema.${capModelName}InputSchema.deleteMany`; break; case 'updateMany': - inputType = `${modelName}InputSchema.updateMany`; + inputType = `$Schema.${capModelName}InputSchema.updateMany`; break; case 'upsertOne': - inputType = `${modelName}InputSchema.upsert`; + inputType = `$Schema.${capModelName}InputSchema.upsert`; break; case 'aggregate': - inputType = `${modelName}InputSchema.aggregate`; + inputType = `$Schema.${capModelName}InputSchema.aggregate`; break; case 'aggregateRaw': - inputType = `${modelName}InputSchema.aggregateRawObject`; + inputType = `$Schema.${capModelName}InputSchema.aggregateRawObject`; break; case 'groupBy': - inputType = `${modelName}InputSchema.groupBy`; + inputType = `$Schema.${capModelName}InputSchema.groupBy`; break; case 'count': - inputType = `${modelName}InputSchema.count`; + inputType = `$Schema.${capModelName}InputSchema.count`; break; default: - console.log('getInputTypeByOpName: ', { opName, modelName }); + break; } return inputType; }; diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7589ac87e..dadbca61f 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-beta.21", + "version": "1.0.0-beta.22", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/runtime/src/constants.ts b/packages/runtime/src/constants.ts index 6006eb618..96d04604b 100644 --- a/packages/runtime/src/constants.ts +++ b/packages/runtime/src/constants.ts @@ -1,3 +1,8 @@ +/** + * Default path for loading CLI-generated code + */ +export const DEFAULT_RUNTIME_LOAD_PATH = '.zenstack'; + /** * Default length of password hash salt (used by bcryptjs to hash password) */ @@ -8,7 +13,12 @@ export const DEFAULT_PASSWORD_SALT_LENGTH = 12; */ export enum CrudFailureReason { /** - * CRUD suceeded but the result was not readable. + * CRUD failed because of access policy violation. + */ + ACCESS_POLICY_VIOLATION = 'ACCESS_POLICY_VIOLATION', + + /** + * CRUD succeeded but the result was not readable. */ RESULT_NOT_READABLE = 'RESULT_NOT_READABLE', diff --git a/packages/runtime/src/enhancements/enhance.ts b/packages/runtime/src/enhancements/enhance.ts index 4da36dbc8..42a504bdf 100644 --- a/packages/runtime/src/enhancements/enhance.ts +++ b/packages/runtime/src/enhancements/enhance.ts @@ -1,4 +1,4 @@ -import { getDefaultModelMeta } from './model-meta'; +import { getDefaultModelMeta } from '../loader'; import { withOmit, WithOmitOptions } from './omit'; import { withPassword, WithPasswordOptions } from './password'; import { withPolicy, WithPolicyContext, WithPolicyOptions } from './policy'; @@ -29,7 +29,7 @@ export function enhance( let result = prisma; if (hasPassword === undefined || hasOmit === undefined) { - const modelMeta = options?.modelMeta ?? getDefaultModelMeta(); + const modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); const allFields = Object.values(modelMeta.fields).flatMap((modelInfo) => Object.values(modelInfo)); hasPassword = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@password')); hasOmit = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@omit')); diff --git a/packages/runtime/src/enhancements/index.ts b/packages/runtime/src/enhancements/index.ts index 5efb76762..df45e34c1 100644 --- a/packages/runtime/src/enhancements/index.ts +++ b/packages/runtime/src/enhancements/index.ts @@ -1,5 +1,5 @@ export * from './model-meta'; -export * from './nested-write-vistor'; +export * from './nested-write-visitor'; export * from './omit'; export * from './password'; export * from './policy'; diff --git a/packages/runtime/src/enhancements/model-meta.ts b/packages/runtime/src/enhancements/model-meta.ts index 83eef9a64..626a7f26e 100644 --- a/packages/runtime/src/enhancements/model-meta.ts +++ b/packages/runtime/src/enhancements/model-meta.ts @@ -1,34 +1,13 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { lowerCaseFirst } from 'lower-case-first'; -import path from 'path'; import { FieldInfo } from '../types'; import { ModelMeta } from './types'; -/** - * Load model meta from standard location. - */ -export function getDefaultModelMeta(): ModelMeta { - try { - // normal load - return require('.zenstack/model-meta').default; - } catch { - if (process.env.ZENSTACK_TEST === '1') { - try { - // special handling for running as tests, try resolving relative to CWD - return require(path.join(process.cwd(), 'node_modules', '.zenstack', 'model-meta')).default; - } catch { - throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); - } - } - throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); - } -} - /** * Resolves a model field to its metadata. Returns undefined if not found. */ export function resolveField(modelMeta: ModelMeta, model: string, field: string): FieldInfo | undefined { - return modelMeta.fields[lowerCaseFirst(model)][field]; + return modelMeta.fields[lowerCaseFirst(model)]?.[field]; } /** diff --git a/packages/runtime/src/enhancements/nested-write-vistor.ts b/packages/runtime/src/enhancements/nested-write-visitor.ts similarity index 100% rename from packages/runtime/src/enhancements/nested-write-vistor.ts rename to packages/runtime/src/enhancements/nested-write-visitor.ts diff --git a/packages/runtime/src/enhancements/omit.ts b/packages/runtime/src/enhancements/omit.ts index a23f1e7d3..236151981 100644 --- a/packages/runtime/src/enhancements/omit.ts +++ b/packages/runtime/src/enhancements/omit.ts @@ -1,27 +1,28 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { getDefaultModelMeta } from '../loader'; import { DbClientContract } from '../types'; -import { getDefaultModelMeta, resolveField } from './model-meta'; +import { resolveField } from './model-meta'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; -import { ModelMeta } from './types'; +import { CommonEnhancementOptions, ModelMeta } from './types'; import { enumerate, getModelFields } from './utils'; /** * Options for @see withOmit */ -export type WithOmitOptions = { +export interface WithOmitOptions extends CommonEnhancementOptions { /** * Model metadata */ modelMeta?: ModelMeta; -}; +} /** * Gets an enhanced Prisma client that supports @omit attribute. */ export function withOmit(prisma: DbClient, options?: WithOmitOptions): DbClient { - const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(); + const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); return makeProxy( prisma, _modelMeta, diff --git a/packages/runtime/src/enhancements/password.ts b/packages/runtime/src/enhancements/password.ts index e6cb513df..154e14c83 100644 --- a/packages/runtime/src/enhancements/password.ts +++ b/packages/runtime/src/enhancements/password.ts @@ -3,27 +3,27 @@ import { hash } from 'bcryptjs'; import { DEFAULT_PASSWORD_SALT_LENGTH } from '../constants'; +import { getDefaultModelMeta } from '../loader'; import { DbClientContract, PrismaWriteActionType } from '../types'; -import { getDefaultModelMeta } from './model-meta'; -import { NestedWriteVisitor } from './nested-write-vistor'; +import { NestedWriteVisitor } from './nested-write-visitor'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; -import { ModelMeta } from './types'; +import { CommonEnhancementOptions, ModelMeta } from './types'; /** * Options for @see withPassword */ -export type WithPasswordOptions = { +export interface WithPasswordOptions extends CommonEnhancementOptions { /** - * Model metatadata + * Model metadata */ modelMeta?: ModelMeta; -}; +} /** * Gets an enhanced Prisma client that supports @password attribute. */ export function withPassword(prisma: DbClient, options?: WithPasswordOptions): DbClient { - const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(); + const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); return makeProxy( prisma, _modelMeta, diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts index d91d6b88c..dc3e9cf30 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/policy/handler.ts @@ -7,7 +7,7 @@ import { CrudFailureReason, PRISMA_TX_FLAG } from '../../constants'; import { AuthUser, DbClientContract, DbOperations, FieldInfo, PolicyOperationKind } from '../../types'; import { ModelDataVisitor } from '../model-data-visitor'; import { resolveField } from '../model-meta'; -import { NestedWriteVisitor, NestedWriteVisitorContext } from '../nested-write-vistor'; +import { NestedWriteVisitor, NestedWriteVisitorContext } from '../nested-write-visitor'; import { PrismaProxyHandler } from '../proxy'; import type { ModelMeta, PolicyDef, ZodSchemas } from '../types'; import { enumerate, formatObject, getIdFields, prismaClientValidationError } from '../utils'; @@ -221,7 +221,7 @@ export class PolicyProxyHandler implements Pr // static input policy check for top-level create data const inputCheck = this.utils.checkInputGuard(this.model, args.data, 'create'); if (inputCheck === false) { - throw this.utils.deniedByPolicy(this.model, 'create'); + throw this.utils.deniedByPolicy(this.model, 'create', undefined, CrudFailureReason.ACCESS_POLICY_VIOLATION); } const hasNestedCreateOrConnect = await this.hasNestedCreateOrConnect(args); @@ -451,7 +451,8 @@ export class PolicyProxyHandler implements Pr model, 'create', `input failed validation: ${fromZodError(parseResult.error)}`, - CrudFailureReason.DATA_VALIDATION_VIOLATION + CrudFailureReason.DATA_VALIDATION_VIOLATION, + parseResult.error ); } } @@ -474,7 +475,12 @@ export class PolicyProxyHandler implements Pr for (const item of enumerate(args.data)) { const inputCheck = this.utils.checkInputGuard(this.model, item, 'create'); if (inputCheck === false) { - throw this.utils.deniedByPolicy(this.model, 'create'); + throw this.utils.deniedByPolicy( + this.model, + 'create', + undefined, + CrudFailureReason.ACCESS_POLICY_VIOLATION + ); } else if (inputCheck === true) { this.validateCreateInputSchema(this.model, item); } else if (inputCheck === undefined) { diff --git a/packages/runtime/src/enhancements/policy/index.ts b/packages/runtime/src/enhancements/policy/index.ts index afd548750..5cb49c113 100644 --- a/packages/runtime/src/enhancements/policy/index.ts +++ b/packages/runtime/src/enhancements/policy/index.ts @@ -1,14 +1,13 @@ /* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import path from 'path'; import semver from 'semver'; import { PRISMA_MINIMUM_VERSION } from '../../constants'; +import { getDefaultModelMeta, getDefaultPolicy, getDefaultZodSchemas } from '../../loader'; import { AuthUser, DbClientContract } from '../../types'; import { hasAllFields } from '../../validation'; -import { getDefaultModelMeta } from '../model-meta'; import { makeProxy } from '../proxy'; -import type { ModelMeta, PolicyDef, ZodSchemas } from '../types'; +import type { CommonEnhancementOptions, ModelMeta, PolicyDef, ZodSchemas } from '../types'; import { getIdFields } from '../utils'; import { PolicyProxyHandler } from './handler'; @@ -22,7 +21,7 @@ export type WithPolicyContext = { /** * Options for @see withPolicy */ -export type WithPolicyOptions = { +export interface WithPolicyOptions extends CommonEnhancementOptions { /** * Policy definition */ @@ -42,7 +41,7 @@ export type WithPolicyOptions = { * Whether to log Prisma query */ logPrismaQuery?: boolean; -}; +} /** * Gets an enhanced Prisma client with access policy check. @@ -68,9 +67,9 @@ export function withPolicy( ); } - const _policy = options?.policy ?? getDefaultPolicy(); - const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(); - const _zodSchemas = options?.zodSchemas ?? getDefaultZodSchemas(); + const _policy = options?.policy ?? getDefaultPolicy(options?.loadPath); + const _modelMeta = options?.modelMeta ?? getDefaultModelMeta(options?.loadPath); + const _zodSchemas = options?.zodSchemas ?? getDefaultZodSchemas(options?.loadPath); // validate user context if (context?.user) { @@ -103,40 +102,3 @@ export function withPolicy( 'policy' ); } - -function getDefaultPolicy(): PolicyDef { - try { - return require('.zenstack/policy').default; - } catch { - if (process.env.ZENSTACK_TEST === '1') { - try { - // special handling for running as tests, try resolving relative to CWD - return require(path.join(process.cwd(), 'node_modules', '.zenstack', 'policy')).default; - } catch { - throw new Error( - 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' - ); - } - } - throw new Error( - 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' - ); - } -} - -function getDefaultZodSchemas(): ZodSchemas | undefined { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('.zenstack/zod'); - } catch { - if (process.env.ZENSTACK_TEST === '1') { - try { - // special handling for running as tests, try resolving relative to CWD - return require(path.join(process.cwd(), 'node_modules', '.zenstack', 'zod')); - } catch { - return undefined; - } - } - return undefined; - } -} diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts index e16008299..a27d689b4 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/policy/policy-utils.ts @@ -3,6 +3,7 @@ import deepcopy from 'deepcopy'; import { lowerCaseFirst } from 'lower-case-first'; import { upperCaseFirst } from 'upper-case-first'; +import { ZodError } from 'zod'; import { fromZodError } from 'zod-validation-error'; import { CrudFailureReason, @@ -16,9 +17,10 @@ import { import { AuthUser, DbClientContract, DbOperations, FieldInfo, PolicyOperationKind } from '../../types'; import { getVersion } from '../../version'; import { getFields, resolveField } from '../model-meta'; -import { NestedWriteVisitorContext } from '../nested-write-vistor'; +import { NestedWriteVisitorContext } from '../nested-write-visitor'; import type { InputCheckFunc, ModelMeta, PolicyDef, ReadFieldCheckFunc, ZodSchemas } from '../types'; import { + enumerate, formatObject, getIdFields, getModelFields, @@ -54,14 +56,28 @@ export class PolicyUtil { * Creates a conjunction of a list of query conditions. */ and(...conditions: (boolean | object | undefined)[]): object { - return this.reduce({ AND: conditions }); + const filtered = conditions.filter((c) => c !== undefined); + if (filtered.length === 0) { + return this.makeTrue(); + } else if (filtered.length === 1) { + return this.reduce(filtered[0]); + } else { + return this.reduce({ AND: filtered }); + } } /** * Creates a disjunction of a list of query conditions. */ or(...conditions: (boolean | object | undefined)[]): object { - return this.reduce({ OR: conditions }); + const filtered = conditions.filter((c) => c !== undefined); + if (filtered.length === 0) { + return this.makeFalse(); + } else if (filtered.length === 1) { + return this.reduce(filtered[0]); + } else { + return this.reduce({ OR: filtered }); + } } /** @@ -116,48 +132,75 @@ export class PolicyUtil { return this.makeFalse(); } - if ('AND' in condition && Array.isArray(condition.AND)) { - const children = condition.AND.map((c: any) => this.reduce(c)).filter( - (c) => c !== undefined && !this.isTrue(c) - ); - if (children.length === 0) { - return this.makeTrue(); - } else if (children.some((c) => this.isFalse(c))) { - return this.makeFalse(); - } else if (children.length === 1) { - return children[0]; - } else { - return { AND: children }; - } + if (condition === null) { + return condition; } - if ('OR' in condition && Array.isArray(condition.OR)) { - const children = condition.OR.map((c: any) => this.reduce(c)).filter( - (c) => c !== undefined && !this.isFalse(c) - ); - if (children.length === 0) { - return this.makeFalse(); - } else if (children.some((c) => this.isTrue(c))) { - return this.makeTrue(); - } else if (children.length === 1) { - return children[0]; - } else { - return { OR: children }; + const result: any = {}; + for (const [key, value] of Object.entries(condition)) { + if (value === null || value === undefined) { + result[key] = value; + continue; } - } - if ('NOT' in condition && condition.NOT !== null && typeof condition.NOT === 'object') { - const child = this.reduce(condition.NOT); - if (this.isTrue(child)) { - return this.makeFalse(); - } else if (this.isFalse(child)) { - return this.makeTrue(); - } else { - return { NOT: child }; + switch (key) { + case 'AND': { + const children = enumerate(value) + .map((c: any) => this.reduce(c)) + .filter((c) => c !== undefined && !this.isTrue(c)); + if (children.length === 0) { + result[key] = []; // true + } else if (children.some((c) => this.isFalse(c))) { + result['OR'] = []; // false + } else { + if (!this.isTrue({ AND: result[key] })) { + // use AND only if it's not already true + result[key] = !Array.isArray(value) && children.length === 1 ? children[0] : children; + } + } + break; + } + + case 'OR': { + const children = enumerate(value) + .map((c: any) => this.reduce(c)) + .filter((c) => c !== undefined && !this.isFalse(c)); + if (children.length === 0) { + result[key] = []; // false + } else if (children.some((c) => this.isTrue(c))) { + result['AND'] = []; // true + } else { + if (!this.isFalse({ OR: result[key] })) { + // use OR only if it's not already false + result[key] = !Array.isArray(value) && children.length === 1 ? children[0] : children; + } + } + break; + } + + case 'NOT': { + result[key] = this.reduce(value); + break; + } + + default: { + const booleanKeys = ['AND', 'OR', 'NOT', 'is', 'isNot', 'none', 'every', 'some']; + if ( + typeof value === 'object' && + value && + // recurse only if the value has at least one boolean key + Object.keys(value).some((k) => booleanKeys.includes(k)) + ) { + result[key] = this.reduce(value); + } else { + result[key] = value; + } + break; + } } } - return condition; + return result; } //#endregion @@ -349,18 +392,18 @@ export class PolicyUtil { operation: PolicyOperationKind ) { const guard = this.getAuthGuard(db, fieldInfo.type, operation); + + // is|isNot and flat fields conditions are mutually exclusive + if (payload.is || payload.isNot) { if (payload.is) { this.injectGuardForRelationFields(db, fieldInfo.type, payload.is, operation); - // turn "is" into: { is: { AND: [ originalIs, guard ] } - payload.is = this.and(payload.is, guard); } if (payload.isNot) { this.injectGuardForRelationFields(db, fieldInfo.type, payload.isNot, operation); - // turn "isNot" into: { isNot: { AND: [ originalIsNot, { NOT: guard } ] } } - payload.isNot = this.and(payload.isNot, this.not(guard)); - delete payload.isNot; } + // merge guard with existing "is": { is: [originalIs, guard] } + payload.is = this.and(payload.is, guard); } else { this.injectGuardForRelationFields(db, fieldInfo.type, payload, operation); // turn direct conditions into: { is: { AND: [ originalConditions, guard ] } } @@ -588,7 +631,12 @@ export class PolicyUtil { ) { let guard = this.getAuthGuard(db, model, operation, preValue); if (this.isFalse(guard)) { - throw this.deniedByPolicy(model, operation, `entity ${formatObject(uniqueFilter)} failed policy check`); + throw this.deniedByPolicy( + model, + operation, + `entity ${formatObject(uniqueFilter)} failed policy check`, + CrudFailureReason.ACCESS_POLICY_VIOLATION + ); } if (operation === 'update' && args) { @@ -601,7 +649,8 @@ export class PolicyUtil { 'update', `entity ${formatObject(uniqueFilter)} failed update policy check for field "${ fieldUpdateGuard.rejectedByField - }"` + }"`, + CrudFailureReason.ACCESS_POLICY_VIOLATION ); } else if (fieldUpdateGuard.guard) { // merge @@ -636,7 +685,12 @@ export class PolicyUtil { } const result = await db[model].findFirst(query); if (!result) { - throw this.deniedByPolicy(model, operation, `entity ${formatObject(uniqueFilter)} failed policy check`); + throw this.deniedByPolicy( + model, + operation, + `entity ${formatObject(uniqueFilter)} failed policy check`, + CrudFailureReason.ACCESS_POLICY_VIOLATION + ); } if (schema) { @@ -651,7 +705,8 @@ export class PolicyUtil { model, operation, `entities ${JSON.stringify(uniqueFilter)} failed validation: [${error}]`, - CrudFailureReason.DATA_VALIDATION_VIOLATION + CrudFailureReason.DATA_VALIDATION_VIOLATION, + parseResult.error ); } } @@ -678,7 +733,7 @@ export class PolicyUtil { tryReject(db: Record, model: string, operation: PolicyOperationKind) { const guard = this.getAuthGuard(db, model, operation); if (this.isFalse(guard)) { - throw this.deniedByPolicy(model, operation); + throw this.deniedByPolicy(model, operation, undefined, CrudFailureReason.ACCESS_POLICY_VIOLATION); } } @@ -832,11 +887,26 @@ export class PolicyUtil { //#region Errors - deniedByPolicy(model: string, operation: PolicyOperationKind, extra?: string, reason?: CrudFailureReason) { + deniedByPolicy( + model: string, + operation: PolicyOperationKind, + extra?: string, + reason?: CrudFailureReason, + zodErrors?: ZodError + ) { + const args: any = { clientVersion: getVersion(), code: PrismaErrorCode.CONSTRAINED_FAILED, meta: {} }; + if (reason) { + args.meta.reason = reason; + } + + if (zodErrors) { + args.meta.zodErrors = zodErrors; + } + return prismaClientKnownRequestError( this.db, `denied by policy: ${model} entities failed '${operation}' check${extra ? ', ' + extra : ''}`, - { clientVersion: getVersion(), code: PrismaErrorCode.CONSTRAINED_FAILED, meta: { reason } } + args ); } @@ -848,9 +918,7 @@ export class PolicyUtil { } validationError(message: string) { - return prismaClientValidationError(this.db, message, { - clientVersion: getVersion(), - }); + return prismaClientValidationError(this.db, message); } unknownError(message: string) { @@ -1062,7 +1130,6 @@ export class PolicyUtil { throw new Error('invalid where clause'); } - extra = this.reduce(extra); if (this.isTrue(extra)) { return; } diff --git a/packages/runtime/src/enhancements/types.ts b/packages/runtime/src/enhancements/types.ts index a3d0e6a6f..6645951db 100644 --- a/packages/runtime/src/enhancements/types.ts +++ b/packages/runtime/src/enhancements/types.ts @@ -9,6 +9,16 @@ import { HAS_FIELD_LEVEL_POLICY_FLAG, } from '../constants'; +/** + * Common options for PrismaClient enhancements + */ +export interface CommonEnhancementOptions { + /** + * Path for loading CLI-generated code + */ + loadPath?: string; +} + /** * Metadata for a model-level unique constraint * e.g.: @@unique([a, b]) diff --git a/packages/runtime/src/enhancements/utils.ts b/packages/runtime/src/enhancements/utils.ts index 5032bfef9..faabed365 100644 --- a/packages/runtime/src/enhancements/utils.ts +++ b/packages/runtime/src/enhancements/utils.ts @@ -119,12 +119,12 @@ function loadPrismaModule(prisma: any) { } } -export function prismaClientValidationError(prisma: DbClientContract, ...args: unknown[]) { +export function prismaClientValidationError(prisma: DbClientContract, message: string) { if (!_PrismaClientValidationError) { const _prisma = loadPrismaModule(prisma); _PrismaClientValidationError = _prisma.PrismaClientValidationError; } - throw new _PrismaClientValidationError(...args); + throw new _PrismaClientValidationError(message, { clientVersion: prisma._clientVersion }); } export function prismaClientKnownRequestError(prisma: DbClientContract, ...args: unknown[]) { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a964d72ed..57df37ee4 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,7 @@ export * from './constants'; export * from './enhancements'; export * from './error'; +export * from './loader'; export * from './types'; export * from './validation'; export * from './version'; diff --git a/packages/runtime/src/loader.ts b/packages/runtime/src/loader.ts new file mode 100644 index 000000000..0b90a5b26 --- /dev/null +++ b/packages/runtime/src/loader.ts @@ -0,0 +1,78 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import path from 'path'; +import { DEFAULT_RUNTIME_LOAD_PATH } from './constants'; +import { ModelMeta, PolicyDef, ZodSchemas } from './enhancements'; + +/** + * Load model metadata. + * + * @param loadPath The path to load model metadata from. If not provided, + * will use default load path. + */ +export function getDefaultModelMeta(loadPath: string | undefined): ModelMeta { + const toLoad = loadPath ? path.resolve(loadPath, 'model-meta') : `${DEFAULT_RUNTIME_LOAD_PATH}/model-meta`; + try { + // normal load + return require(toLoad).default; + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(toLoad)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', toLoad)).default; + } catch { + throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); + } + } + throw new Error('Model meta cannot be loaded. Please make sure "zenstack generate" has been run.'); + } +} + +/** + * Load access policies. + * + * @param loadPath The path to load access policies from. If not provided, + * will use default load path. + */ +export function getDefaultPolicy(loadPath: string | undefined): PolicyDef { + const toLoad = loadPath ? path.resolve(loadPath, 'policy') : `${DEFAULT_RUNTIME_LOAD_PATH}/policy`; + try { + return require(toLoad).default; + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(toLoad)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', toLoad)).default; + } catch { + throw new Error( + 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' + ); + } + } + throw new Error( + 'Policy definition cannot be loaded from default location. Please make sure "zenstack generate" has been run.' + ); + } +} + +/** + * Load zod schemas. + * + * @param loadPath The path to load zod schemas from. If not provided, + * will use default load path. + */ +export function getDefaultZodSchemas(loadPath: string | undefined): ZodSchemas | undefined { + const toLoad = loadPath ? path.resolve(loadPath, 'zod') : `${DEFAULT_RUNTIME_LOAD_PATH}/zod`; + try { + return require(toLoad); + } catch { + if (process.env.ZENSTACK_TEST === '1' && !path.isAbsolute(toLoad)) { + try { + // special handling for running as tests, try resolving relative to CWD + return require(path.join(process.cwd(), 'node_modules', toLoad)); + } catch { + return undefined; + } + } + return undefined; + } +} diff --git a/packages/schema/package.json b/packages/schema/package.json index d285a3268..a8a6d1425 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-beta.21", + "version": "1.0.0-beta.22", "author": { "name": "ZenStack Team" }, @@ -98,6 +98,7 @@ "semver": "^7.3.8", "sleep-promise": "^9.1.0", "strip-color": "^0.1.0", + "tiny-invariant": "^1.3.1", "ts-morph": "^16.0.0", "ts-pattern": "^4.3.0", "upper-case-first": "^2.0.2", diff --git a/packages/schema/src/cli/actions/generate.ts b/packages/schema/src/cli/actions/generate.ts new file mode 100644 index 000000000..ead57803f --- /dev/null +++ b/packages/schema/src/cli/actions/generate.ts @@ -0,0 +1,75 @@ +import { PluginError } from '@zenstackhq/sdk'; +import colors from 'colors'; +import path from 'path'; +import { CliError } from '../cli-error'; +import { + checkNewVersion, + checkRequiredPackage, + getZenStackPackages, + loadDocument, + requiredPrismaVersion, +} from '../cli-util'; +import { PluginRunner, PluginRunnerOptions } from '../plugin-runner'; + +type Options = { + schema: string; + output?: string; + dependencyCheck: boolean; + versionCheck: boolean; + compile: boolean; + defaultPlugins: boolean; +}; + +/** + * CLI action for generating code from schema + */ +export async function generate(projectPath: string, options: Options) { + if (options.dependencyCheck) { + checkRequiredPackage('prisma', requiredPrismaVersion); + checkRequiredPackage('@prisma/client', requiredPrismaVersion); + } + + // check for multiple versions of Zenstack packages + const packages = getZenStackPackages(projectPath); + if (packages) { + const versions = new Set(packages.map((p) => p.version)); + if (versions.size > 1) { + console.warn( + colors.yellow( + 'WARNING: Multiple versions of Zenstack packages detected. Run "zenstack info" to see details.' + ) + ); + } + } + + const tasks = [runPlugins(options)]; + + if (options.versionCheck) { + tasks.push(checkNewVersion()); + } + + await Promise.all(tasks); +} + +async function runPlugins(options: Options) { + const model = await loadDocument(options.schema); + + const runnerOpts: PluginRunnerOptions = { + schema: model, + schemaPath: path.resolve(options.schema), + defaultPlugins: options.defaultPlugins, + output: options.output, + compile: options.compile, + }; + + try { + await new PluginRunner().run(runnerOpts); + } catch (err) { + if (err instanceof PluginError) { + console.error(colors.red(`${err.plugin}: ${err.message}`)); + throw new CliError(err.message); + } else { + throw err; + } + } +} diff --git a/packages/schema/src/cli/actions/index.ts b/packages/schema/src/cli/actions/index.ts new file mode 100644 index 000000000..19f6fcec2 --- /dev/null +++ b/packages/schema/src/cli/actions/index.ts @@ -0,0 +1,3 @@ +export * from './generate'; +export * from './info'; +export * from './init'; diff --git a/packages/schema/src/cli/actions/info.ts b/packages/schema/src/cli/actions/info.ts new file mode 100644 index 000000000..bb839eee5 --- /dev/null +++ b/packages/schema/src/cli/actions/info.ts @@ -0,0 +1,44 @@ +import colors from 'colors'; +import getLatestVersion from 'get-latest-version'; +import ora from 'ora'; +import semver from 'semver'; +import { getZenStackPackages } from '../cli-util'; + +/** + * CLI action for getting information about installed ZenStack packages + */ +export async function info(projectPath: string) { + const packages = getZenStackPackages(projectPath); + if (!packages) { + console.error('Unable to locate package.json. Are you in a valid project directory?'); + return; + } + + console.log('Installed ZenStack Packages:'); + const versions = new Set(); + for (const { pkg, version } of packages) { + versions.add(version); + console.log(` ${colors.green(pkg.padEnd(20))}\t${version}`); + } + + if (versions.size > 1) { + console.warn(colors.yellow('WARNING: Multiple versions of Zenstack packages detected. This may cause issues.')); + } else if (versions.size > 0) { + const spinner = ora('Checking npm registry').start(); + const latest = await getLatestVersion('zenstack'); + + if (!latest) { + spinner.fail('unable to check for latest version'); + } else { + spinner.succeed(); + const version = [...versions][0]; + if (semver.gt(latest, version)) { + console.log(`A newer version of Zenstack is available: ${latest}.`); + } else if (semver.gt(version, latest)) { + console.log('You are using a pre-release version of Zenstack.'); + } else { + console.log('You are using the latest version of Zenstack.'); + } + } + } +} diff --git a/packages/schema/src/cli/actions/init.ts b/packages/schema/src/cli/actions/init.ts new file mode 100644 index 000000000..9d798b865 --- /dev/null +++ b/packages/schema/src/cli/actions/init.ts @@ -0,0 +1,77 @@ +import colors from 'colors'; +import fs from 'fs'; +import path from 'path'; +import { PackageManagers, ensurePackage, installPackage } from '../../utils/pkg-utils'; +import { getVersion } from '../../utils/version-utils'; +import { CliError } from '../cli-error'; +import { checkNewVersion } from '../cli-util'; + +type Options = { + prisma: string | undefined; + packageManager: PackageManagers | undefined; + versionCheck: boolean; + tag?: string; +}; + +/** + * CLI action for initializing an existing project + */ +export async function init(projectPath: string, options: Options) { + if (!fs.existsSync(projectPath)) { + console.error(`Path does not exist: ${projectPath}`); + throw new CliError('project path does not exist'); + } + + const defaultPrismaSchemaLocation = './prisma/schema.prisma'; + let prismaSchema = options.prisma; + if (prismaSchema) { + if (!fs.existsSync(prismaSchema)) { + console.error(`Prisma schema file does not exist: ${prismaSchema}`); + throw new CliError('prisma schema does not exist'); + } + } else if (fs.existsSync(defaultPrismaSchemaLocation)) { + prismaSchema = defaultPrismaSchemaLocation; + } + + const zmodelFile = path.join(projectPath, './schema.zmodel'); + let sampleModelGenerated = false; + + if (fs.existsSync(zmodelFile)) { + console.warn(`ZenStack model already exists at ${zmodelFile}, not generating a new one.`); + } else { + if (prismaSchema) { + // copy over schema.prisma + fs.copyFileSync(prismaSchema, zmodelFile); + } else { + // create a new model + const starterContent = fs.readFileSync(path.join(__dirname, '../../res/starter.zmodel'), 'utf-8'); + fs.writeFileSync(zmodelFile, starterContent); + sampleModelGenerated = true; + } + } + + ensurePackage('prisma', true, options.packageManager, 'latest', projectPath); + ensurePackage('@prisma/client', false, options.packageManager, 'latest', projectPath); + + const tag = options.tag ?? getVersion(); + installPackage('zenstack', true, options.packageManager, tag, projectPath); + installPackage('@zenstackhq/runtime', false, options.packageManager, tag, projectPath); + + if (sampleModelGenerated) { + console.log(`Sample model generated at: ${colors.blue(zmodelFile)} + +Please check the following guide on how to model your app: + https://zenstack.dev/#/modeling-your-app.`); + } else if (prismaSchema) { + console.log( + `Your current Prisma schema "${prismaSchema}" has been copied to "${zmodelFile}". +Moving forward please edit this file and run "zenstack generate" to regenerate Prisma schema.` + ); + } + + console.log(colors.green('\nProject initialized successfully!')); + + if (options.versionCheck) { + await checkNewVersion(); + } +} diff --git a/packages/schema/src/cli/cli-util.ts b/packages/schema/src/cli/cli-util.ts index 21e36481d..1f57da695 100644 --- a/packages/schema/src/cli/cli-util.ts +++ b/packages/schema/src/cli/cli-util.ts @@ -1,85 +1,21 @@ import { isDataSource, isPlugin, Model } from '@zenstackhq/language/ast'; -import { getLiteral, PluginError } from '@zenstackhq/sdk'; +import { getLiteral } from '@zenstackhq/sdk'; import colors from 'colors'; import fs from 'fs'; import getLatestVersion from 'get-latest-version'; import { AstNode, getDocument, LangiumDocument, LangiumDocuments, Mutable } from 'langium'; import { NodeFileSystem } from 'langium/node'; -import ora from 'ora'; import path from 'path'; import semver from 'semver'; import { URI } from 'vscode-uri'; import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from '../language-server/constants'; import { createZModelServices, ZModelServices } from '../language-server/zmodel-module'; -import { Context } from '../types'; import { mergeBaseModel, resolveImport, resolveTransitiveImports } from '../utils/ast-utils'; -import { ensurePackage, installPackage, PackageManagers } from '../utils/pkg-utils'; import { getVersion } from '../utils/version-utils'; import { CliError } from './cli-error'; -import { PluginRunner } from './plugin-runner'; -/** - * Initializes an existing project for ZenStack - */ -export async function initProject( - projectPath: string, - prismaSchema: string | undefined, - packageManager: PackageManagers | undefined, - tag?: string -) { - if (!fs.existsSync(projectPath)) { - console.error(`Path does not exist: ${projectPath}`); - throw new CliError('project path does not exist'); - } - - const defaultPrismaSchemaLocation = './prisma/schema.prisma'; - if (prismaSchema) { - if (!fs.existsSync(prismaSchema)) { - console.error(`Prisma schema file does not exist: ${prismaSchema}`); - throw new CliError('prisma schema does not exist'); - } - } else if (fs.existsSync(defaultPrismaSchemaLocation)) { - prismaSchema = defaultPrismaSchemaLocation; - } - - const zmodelFile = path.join(projectPath, './schema.zmodel'); - let sampleModelGenerated = false; - - if (fs.existsSync(zmodelFile)) { - console.warn(`ZenStack model already exists at ${zmodelFile}, not generating a new one.`); - } else { - if (prismaSchema) { - // copy over schema.prisma - fs.copyFileSync(prismaSchema, zmodelFile); - } else { - // create a new model - const starterContent = fs.readFileSync(path.join(__dirname, '../res/starter.zmodel'), 'utf-8'); - fs.writeFileSync(zmodelFile, starterContent); - sampleModelGenerated = true; - } - } - - ensurePackage('prisma', true, packageManager, 'latest', projectPath); - ensurePackage('@prisma/client', false, packageManager, 'latest', projectPath); - - tag = tag ?? getVersion(); - installPackage('zenstack', true, packageManager, tag, projectPath); - installPackage('@zenstackhq/runtime', false, packageManager, tag, projectPath); - - if (sampleModelGenerated) { - console.log(`Sample model generated at: ${colors.blue(zmodelFile)} - -Please check the following guide on how to model your app: - https://zenstack.dev/#/modeling-your-app.`); - } else if (prismaSchema) { - console.log( - `Your current Prisma schema "${prismaSchema}" has been copied to "${zmodelFile}". -Moving forward please edit this file and run "zenstack generate" to regenerate Prisma schema.` - ); - } - - console.log(colors.green('\nProject initialized successfully!')); -} +// required minimal version of Prisma +export const requiredPrismaVersion = '4.8.0'; /** * Loads a zmodel document from a file. @@ -239,74 +175,59 @@ export async function getPluginDocuments(services: ZModelServices, fileName: str return result; } -export async function runPlugins(options: { schema: string; packageManager: PackageManagers | undefined }) { - const model = await loadDocument(options.schema); - - const context: Context = { - schema: model, - schemaPath: path.resolve(options.schema), - outDir: path.dirname(options.schema), - }; - - try { - await new PluginRunner().run(context); - } catch (err) { - if (err instanceof PluginError) { - console.error(colors.red(`${err.plugin}: ${err.message}`)); - throw new CliError(err.message); - } else { - throw err; - } - } -} - -export async function dumpInfo(projectPath: string) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let pkgJson: any; +export function getZenStackPackages(projectPath: string) { + let pkgJson: { dependencies: Record; devDependencies: Record }; const resolvedPath = path.resolve(projectPath); try { pkgJson = require(path.join(resolvedPath, 'package.json')); } catch { - console.error('Unable to locate package.json. Are you in a valid project directory?'); - return; + return undefined; } + const packages = [ - 'zenstack', ...Object.keys(pkgJson.dependencies ?? {}).filter((p) => p.startsWith('@zenstackhq/')), ...Object.keys(pkgJson.devDependencies ?? {}).filter((p) => p.startsWith('@zenstackhq/')), ]; - const versions = new Set(); - for (const pkg of packages) { + const result = packages.map((pkg) => { try { const resolved = require.resolve(`${pkg}/package.json`, { paths: [resolvedPath] }); // eslint-disable-next-line @typescript-eslint/no-var-requires - const version = require(resolved).version; - versions.add(version); - console.log(` ${colors.green(pkg.padEnd(20))}\t${version}`); + return { pkg, version: require(resolved).version }; } catch { - // noop + return { pkg, version: undefined }; } + }); + + result.splice(0, 0, { pkg: 'zenstack', version: getVersion() }); + + return result; +} + +export function checkRequiredPackage(packageName: string, minVersion?: string) { + let packageVersion: string; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + packageVersion = require(`${packageName}/package.json`).version; + } catch (error) { + console.error(colors.red(`${packageName} not found, please install it`)); + throw new CliError(`${packageName} not found`); } - if (versions.size > 1) { - console.warn(colors.yellow('WARNING: Multiple versions of Zenstack packages detected. This may cause issues.')); - } else if (versions.size > 0) { - const spinner = ora('Checking npm registry').start(); - const latest = await getLatestVersion('zenstack'); - - if (!latest) { - spinner.fail('unable to check for latest version'); - } else { - spinner.succeed(); - const version = [...versions][0]; - if (semver.gt(latest, version)) { - console.log(`A newer version of Zenstack is available: ${latest}.`); - } else if (semver.gt(version, latest)) { - console.log('You are using a pre-release version of Zenstack.'); - } else { - console.log('You are using the latest version of Zenstack.'); - } - } + if (minVersion && semver.lt(packageVersion, minVersion)) { + console.error( + colors.red( + `${packageName} needs to be above ${minVersion}, the installed version is ${packageVersion}, please upgrade it` + ) + ); + throw new CliError(`${packageName} version is too low`); + } +} + +export async function checkNewVersion() { + const currVersion = getVersion(); + const latestVersion = await getLatestVersion('zenstack'); + if (latestVersion && semver.gt(latestVersion, currVersion)) { + console.log(`A newer version ${colors.cyan(latestVersion)} is available.`); } } diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index a3b511340..ae8f901cf 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -3,33 +3,20 @@ import { ZModelLanguageMetaData } from '@zenstackhq/language/module'; import colors from 'colors'; import { Command, Option } from 'commander'; import fs from 'fs'; -import * as semver from 'semver'; import telemetry from '../telemetry'; -import { PackageManagers } from '../utils/pkg-utils'; import { getVersion } from '../utils/version-utils'; -import { CliError } from './cli-error'; -import { dumpInfo, initProject, runPlugins } from './cli-util'; +import * as actions from './actions'; import { loadConfig } from './config'; -// required minimal version of Prisma -export const requiredPrismaVersion = '4.0.0'; - const DEFAULT_CONFIG_FILE = 'zenstack.config.json'; -export const initAction = async ( - projectPath: string, - options: { - prisma: string | undefined; - packageManager: PackageManagers | undefined; - tag?: string; - } -): Promise => { +export const initAction = async (projectPath: string, options: Parameters[1]): Promise => { await telemetry.trackSpan( 'cli:command:start', 'cli:command:complete', 'cli:command:error', { command: 'init' }, - () => initProject(projectPath, options.prisma, options.packageManager, options.tag) + () => actions.init(projectPath, options) ); }; @@ -39,48 +26,20 @@ export const infoAction = async (projectPath: string): Promise => { 'cli:command:complete', 'cli:command:error', { command: 'info' }, - () => dumpInfo(projectPath) + () => actions.info(projectPath) ); }; -export const generateAction = async (options: { - schema: string; - packageManager: PackageManagers | undefined; - dependencyCheck: boolean; -}): Promise => { - if (options.dependencyCheck) { - checkRequiredPackage('prisma', requiredPrismaVersion); - checkRequiredPackage('@prisma/client', requiredPrismaVersion); - } +export const generateAction = async (options: Parameters[1]): Promise => { await telemetry.trackSpan( 'cli:command:start', 'cli:command:complete', 'cli:command:error', { command: 'generate' }, - () => runPlugins(options) + () => actions.generate(process.cwd(), options) ); }; -const checkRequiredPackage = (packageName: string, minVersion?: string) => { - let packageVersion: string; - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - packageVersion = require(`${packageName}/package.json`).version; - } catch (error) { - console.error(colors.red(`${packageName} not found, please install it`)); - throw new CliError(`${packageName} not found`); - } - - if (minVersion && semver.lt(packageVersion, minVersion)) { - console.error( - colors.red( - `${packageName} needs to be above ${minVersion}, the installed version is ${packageVersion}, please upgrade it` - ) - ); - throw new CliError(`${packageName} version is too low`); - } -}; - export function createProgram() { const program = new Command('zenstack'); @@ -102,13 +61,12 @@ export function createProgram() { ); const configOption = new Option('-c, --config [file]', 'config file'); - const pmOption = new Option('-p, --package-manager ', 'package manager to use').choices([ 'npm', 'yarn', 'pnpm', ]); - + const noVersionCheckOption = new Option('--no-version-check', 'do not check for new version'); const noDependencyCheck = new Option('--no-dependency-check', 'do not check if dependencies are installed'); program @@ -123,7 +81,8 @@ export function createProgram() { .addOption(configOption) .addOption(pmOption) .addOption(new Option('--prisma ', 'location of Prisma schema file to bootstrap from')) - .addOption(new Option('--tag [tag]', 'the NPM package tag to use when installing dependencies')) + .addOption(new Option('--tag ', 'the NPM package tag to use when installing dependencies')) + .addOption(noVersionCheckOption) .argument('[path]', 'project path', '.') .action(initAction); @@ -131,8 +90,11 @@ export function createProgram() { .command('generate') .description('Run code generation.') .addOption(schemaOption) + .addOption(new Option('-o, --output ', 'default output directory for built-in plugins')) .addOption(configOption) - .addOption(pmOption) + .addOption(new Option('--no-default-plugins', 'do not run default plugins')) + .addOption(new Option('--no-compile', 'do not compile the output of built-in plugins')) + .addOption(noVersionCheckOption) .addOption(noDependencyCheck) .action(generateAction); diff --git a/packages/schema/src/cli/plugin-runner.ts b/packages/schema/src/cli/plugin-runner.ts index 96819cdfb..b963e3da8 100644 --- a/packages/schema/src/cli/plugin-runner.ts +++ b/packages/schema/src/cli/plugin-runner.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-var-requires */ import type { DMMF } from '@prisma/generator-helper'; -import { isPlugin, Plugin } from '@zenstackhq/language/ast'; +import { isPlugin, Model, Plugin } from '@zenstackhq/language/ast'; import { getDataModels, getDMMF, @@ -19,9 +19,7 @@ import ora from 'ora'; import path from 'path'; import { ensureDefaultOutputFolder } from '../plugins/plugin-utils'; import telemetry from '../telemetry'; -import type { Context } from '../types'; import { getVersion } from '../utils/version-utils'; -import { config } from './config'; type PluginInfo = { name: string; @@ -32,6 +30,14 @@ type PluginInfo = { module: any; }; +export type PluginRunnerOptions = { + schema: Model; + schemaPath: string; + output?: string; + defaultPlugins: boolean; + compile: boolean; +}; + /** * ZenStack plugin runner */ @@ -39,16 +45,16 @@ export class PluginRunner { /** * Runs a series of nested generators */ - async run(context: Context): Promise { + async run(options: PluginRunnerOptions): Promise { const version = getVersion(); console.log(colors.bold(`⌛️ ZenStack CLI v${version}, running plugins`)); - ensureDefaultOutputFolder(); + ensureDefaultOutputFolder(options); const plugins: PluginInfo[] = []; - const pluginDecls = context.schema.declarations.filter((d): d is Plugin => isPlugin(d)); + const pluginDecls = options.schema.declarations.filter((d): d is Plugin => isPlugin(d)); - let prismaOutput = resolvePath('./prisma/schema.prisma', { schemaPath: context.schemaPath, name: '' }); + let prismaOutput = resolvePath('./prisma/schema.prisma', { schemaPath: options.schemaPath, name: '' }); for (const pluginDecl of pluginDecls) { const pluginProvider = this.getPluginProvider(pluginDecl); @@ -73,59 +79,35 @@ export class PluginRunner { const dependencies = this.getPluginDependencies(pluginModule); const pluginName = this.getPluginName(pluginModule, pluginProvider); - const options: PluginOptions = { schemaPath: context.schemaPath, name: pluginName }; + const pluginOptions: PluginOptions = { schemaPath: options.schemaPath, name: pluginName }; pluginDecl.fields.forEach((f) => { const value = getLiteral(f.value) ?? getLiteralArray(f.value); if (value === undefined) { throw new PluginError(pluginName, `Invalid option value for ${f.name}`); } - options[f.name] = value; + pluginOptions[f.name] = value; }); plugins.push({ name: pluginName, provider: pluginProvider, dependencies, - options, + options: pluginOptions, run: pluginModule.default as PluginFunction, module: pluginModule, }); - if (pluginProvider === '@core/prisma' && typeof options.output === 'string') { + if (pluginProvider === '@core/prisma' && typeof pluginOptions.output === 'string') { // record custom prisma output path - prismaOutput = resolvePath(options.output, options); + prismaOutput = resolvePath(pluginOptions.output, pluginOptions); } } - // make sure prerequisites are included - const corePlugins: Array<{ provider: string; options?: Record }> = [ - { provider: '@core/prisma' }, - { provider: '@core/model-meta' }, - { provider: '@core/access-policy' }, - ]; - - if (getDataModels(context.schema).some((model) => hasValidationAttributes(model))) { - // '@core/zod' plugin is auto-enabled if there're validation rules - corePlugins.push({ provider: '@core/zod', options: { modelOnly: true } }); - } - - // core plugins introduced by dependencies - plugins - .flatMap((p) => p.dependencies) - .forEach((dep) => { - if (dep.startsWith('@core/')) { - const existing = corePlugins.find((p) => p.provider === dep); - if (existing) { - // reset options to default - existing.options = undefined; - } else { - // add core dependency - corePlugins.push({ provider: dep }); - } - } - }); + // get core plugins that need to be enabled + const corePlugins = this.calculateCorePlugins(options, plugins); + // shift/insert core plugins to the front for (const corePlugin of corePlugins.reverse()) { const existingIdx = plugins.findIndex((p) => p.provider === corePlugin.provider); if (existingIdx >= 0) { @@ -141,7 +123,7 @@ export class PluginRunner { name: pluginName, provider: corePlugin.provider, dependencies: [], - options: { schemaPath: context.schemaPath, name: pluginName, ...corePlugin.options }, + options: { schemaPath: options.schemaPath, name: pluginName, ...corePlugin.options }, run: pluginModule.default, module: pluginModule, }); @@ -161,12 +143,17 @@ export class PluginRunner { } } + if (plugins.length === 0) { + console.log(colors.yellow('No plugins configured.')); + return; + } + const warnings: string[] = []; let dmmf: DMMF.Document | undefined = undefined; - for (const { name, provider, run, options } of plugins) { + for (const { name, provider, run, options: pluginOptions } of plugins) { // const start = Date.now(); - await this.runPlugin(name, run, context, options, dmmf, warnings); + await this.runPlugin(name, run, options, pluginOptions, dmmf, warnings); // console.log(`✅ Plugin ${colors.bold(name)} (${provider}) completed in ${Date.now() - start}ms`); if (provider === '@core/prisma') { // load prisma DMMF @@ -175,7 +162,6 @@ export class PluginRunner { }); } } - console.log(colors.green(colors.bold('\n👻 All plugins completed successfully!'))); warnings.forEach((w) => console.warn(colors.yellow(w))); @@ -183,6 +169,57 @@ export class PluginRunner { console.log(`Don't forget to restart your dev server to let the changes take effect.`); } + private calculateCorePlugins(options: PluginRunnerOptions, plugins: PluginInfo[]) { + const corePlugins: Array<{ provider: string; options?: Record }> = []; + + if (options.defaultPlugins) { + corePlugins.push( + { provider: '@core/prisma' }, + { provider: '@core/model-meta' }, + { provider: '@core/access-policy' } + ); + } else if (plugins.length > 0) { + // "@core/prisma" plugin is always enabled if any plugin is configured + corePlugins.push({ provider: '@core/prisma' }); + } + + // "@core/access-policy" has implicit requirements + if ([...plugins, ...corePlugins].find((p) => p.provider === '@core/access-policy')) { + // make sure "@core/model-meta" is enabled + if (!corePlugins.find((p) => p.provider === '@core/model-meta')) { + corePlugins.push({ provider: '@core/model-meta' }); + } + + // '@core/zod' plugin is auto-enabled by "@core/access-policy" + // if there're validation rules + if (!corePlugins.find((p) => p.provider === '@core/zod') && this.hasValidation(options.schema)) { + corePlugins.push({ provider: '@core/zod', options: { modelOnly: true } }); + } + } + + // core plugins introduced by dependencies + plugins + .flatMap((p) => p.dependencies) + .forEach((dep) => { + if (dep.startsWith('@core/')) { + const existing = corePlugins.find((p) => p.provider === dep); + if (existing) { + // reset options to default + existing.options = undefined; + } else { + // add core dependency + corePlugins.push({ provider: dep }); + } + } + }); + + return corePlugins; + } + + private hasValidation(schema: Model) { + return getDataModels(schema).some((model) => hasValidationAttributes(model)); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any private getPluginName(pluginModule: any, pluginProvider: string): string { return typeof pluginModule.name === 'string' ? (pluginModule.name as string) : pluginProvider; @@ -200,7 +237,7 @@ export class PluginRunner { private async runPlugin( name: string, run: PluginFunction, - context: Context, + runnerOptions: PluginRunnerOptions, options: PluginOptions, dmmf: DMMF.Document | undefined, warnings: string[] @@ -216,7 +253,10 @@ export class PluginRunner { options, }, async () => { - let result = run(context.schema, options, dmmf, config); + let result = run(runnerOptions.schema, options, dmmf, { + output: runnerOptions.output, + compile: runnerOptions.compile, + }); if (result instanceof Promise) { result = await result; } diff --git a/packages/schema/src/plugins/access-policy/index.ts b/packages/schema/src/plugins/access-policy/index.ts index c47f6e11d..cbdcbd64f 100644 --- a/packages/schema/src/plugins/access-policy/index.ts +++ b/packages/schema/src/plugins/access-policy/index.ts @@ -1,9 +1,10 @@ -import { Model } from '@zenstackhq/language/ast'; -import { PluginOptions } from '@zenstackhq/sdk'; +import { PluginFunction } from '@zenstackhq/sdk'; import PolicyGenerator from './policy-guard-generator'; export const name = 'Access Policy'; -export default async function run(model: Model, options: PluginOptions) { - return new PolicyGenerator().generate(model, options); -} +const run: PluginFunction = async (model, options, _dmmf, globalOptions) => { + return new PolicyGenerator().generate(model, options, globalOptions); +}; + +export default run; diff --git a/packages/schema/src/plugins/access-policy/policy-guard-generator.ts b/packages/schema/src/plugins/access-policy/policy-guard-generator.ts index 6cd602ea6..b6ac99576 100644 --- a/packages/schema/src/plugins/access-policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/access-policy/policy-guard-generator.ts @@ -3,6 +3,7 @@ import { DataModelAttribute, DataModelField, DataModelFieldAttribute, + Enum, Expression, MemberAccessExpr, Model, @@ -29,6 +30,7 @@ import { import { ExpressionContext, PluginError, + PluginGlobalOptions, PluginOptions, RUNTIME_PACKAGE, analyzePolicies, @@ -39,6 +41,7 @@ import { getPrismaClientImportSpec, hasAttribute, hasValidationAttributes, + isEnumFieldReference, isForeignKeyField, isFromStdlib, isFutureExpr, @@ -63,8 +66,8 @@ import { ExpressionWriter, FALSE, TRUE } from './expression-writer'; * Generates source file that contains Prisma query guard objects used for injecting database queries */ export default class PolicyGenerator { - async generate(model: Model, options: PluginOptions) { - let output = options.output ? (options.output as string) : getDefaultOutputFolder(); + async generate(model: Model, options: PluginOptions, globalOptions?: PluginGlobalOptions) { + let output = options.output ? (options.output as string) : getDefaultOutputFolder(globalOptions); if (!output) { throw new PluginError(options.name, `Unable to determine output path, not running plugin`); } @@ -87,7 +90,7 @@ export default class PolicyGenerator { // import enums const prismaImport = getPrismaClientImportSpec(model, output); - for (const e of model.declarations.filter((d) => isEnum(d))) { + for (const e of model.declarations.filter((d) => isEnum(d) && this.isEnumReferenced(model, d))) { sf.addImportDeclaration({ namedImports: [{ name: e.name }], moduleSpecifier: prismaImport, @@ -145,7 +148,14 @@ export default class PolicyGenerator { sf.addStatements('export default policy'); - const shouldCompile = options.compile !== false; + let shouldCompile = true; + if (typeof options.compile === 'boolean') { + // explicit override + shouldCompile = options.compile; + } else if (globalOptions) { + shouldCompile = globalOptions.compile; + } + if (!shouldCompile || options.preserveTsFiles === true) { // save ts files await saveProject(project); @@ -155,6 +165,20 @@ export default class PolicyGenerator { } } + private isEnumReferenced(model: Model, decl: Enum): unknown { + return streamAllContents(model).some((node) => { + if (isDataModelField(node) && node.type.reference?.ref === decl) { + // referenced as field type + return true; + } + if (isEnumFieldReference(node) && node.target.ref?.$container === decl) { + // enum field is referenced + return true; + } + return false; + }); + } + private getPolicyExpressions(target: DataModel | DataModelField, kind: PolicyKind, operation: PolicyOperationKind) { const attributes = target.attributes as (DataModelAttribute | DataModelFieldAttribute)[]; const attrName = isDataModel(target) ? `@@${kind}` : `@${kind}`; diff --git a/packages/schema/src/plugins/model-meta/index.ts b/packages/schema/src/plugins/model-meta/index.ts index 7049c9957..8c4432db5 100644 --- a/packages/schema/src/plugins/model-meta/index.ts +++ b/packages/schema/src/plugins/model-meta/index.ts @@ -8,7 +8,6 @@ import { isNumberLiteral, isReferenceExpr, isStringLiteral, - Model, ReferenceExpr, } from '@zenstackhq/language/ast'; import type { RuntimeAttribute } from '@zenstackhq/runtime'; @@ -22,7 +21,7 @@ import { hasAttribute, isIdField, PluginError, - PluginOptions, + PluginFunction, resolved, resolvePath, saveProject, @@ -34,8 +33,8 @@ import { getDefaultOutputFolder } from '../plugin-utils'; export const name = 'Model Metadata'; -export default async function run(model: Model, options: PluginOptions) { - let output = options.output ? (options.output as string) : getDefaultOutputFolder(); +const run: PluginFunction = async (model, options, _dmmf, globalOptions) => { + let output = options.output ? (options.output as string) : getDefaultOutputFolder(globalOptions); if (!output) { throw new PluginError(options.name, `Unable to determine output path, not running plugin`); } @@ -53,7 +52,15 @@ export default async function run(model: Model, options: PluginOptions) { }); sf.addStatements('export default metadata;'); - const shouldCompile = options.compile !== false; + let shouldCompile = true; + if (typeof options.compile === 'boolean') { + // explicit override + shouldCompile = options.compile; + } else if (globalOptions) { + // from CLI or config file + shouldCompile = globalOptions.compile; + } + if (!shouldCompile || options.preserveTsFiles === true) { // save ts files await saveProject(project); @@ -61,7 +68,7 @@ export default async function run(model: Model, options: PluginOptions) { if (shouldCompile) { await emitProject(project); } -} +}; function generateModelMetadata(dataModels: DataModel[], writer: CodeBlockWriter) { writer.block(() => { @@ -256,3 +263,5 @@ function generateForeignKeyMapping(field: DataModelField) { }); return result; } + +export default run; diff --git a/packages/schema/src/plugins/plugin-utils.ts b/packages/schema/src/plugins/plugin-utils.ts index 9245eed3a..b1a988617 100644 --- a/packages/schema/src/plugins/plugin-utils.ts +++ b/packages/schema/src/plugins/plugin-utils.ts @@ -1,6 +1,8 @@ -import type { PolicyOperationKind } from '@zenstackhq/runtime'; +import { DEFAULT_RUNTIME_LOAD_PATH, type PolicyOperationKind } from '@zenstackhq/runtime'; +import { PluginGlobalOptions } from '@zenstackhq/sdk'; import fs from 'fs'; import path from 'path'; +import { PluginRunnerOptions } from '../cli/plugin-runner'; export const ALL_OPERATION_KINDS: PolicyOperationKind[] = ['create', 'update', 'postUpdate', 'read', 'delete']; @@ -24,19 +26,37 @@ export function getNodeModulesFolder(startPath?: string): string | undefined { /** * Ensure the default output folder is initialized. */ -export function ensureDefaultOutputFolder() { - const output = getDefaultOutputFolder(); +export function ensureDefaultOutputFolder(options: PluginRunnerOptions) { + const output = options.output ? path.resolve(options.output) : getDefaultOutputFolder(); if (output && !fs.existsSync(output)) { fs.mkdirSync(output, { recursive: true }); - fs.writeFileSync(path.join(output, 'package.json'), JSON.stringify({ name: '.zenstack', version: '1.0.0' })); + if (!options.output) { + const pkgJson = { + name: '.zenstack', + version: '1.0.0', + exports: { + './zod': { + default: './zod/index.js', + types: './zod/index.d.ts', + }, + }, + }; + fs.writeFileSync(path.join(output, 'package.json'), JSON.stringify(pkgJson, undefined, 4)); + } } + + return output; } /** * Gets the default node_modules/.zenstack output folder for plugins. * @returns */ -export function getDefaultOutputFolder() { +export function getDefaultOutputFolder(globalOptions?: PluginGlobalOptions) { + if (typeof globalOptions?.output === 'string') { + return path.resolve(globalOptions.output); + } + // Find the real runtime module path, it might be a symlink in pnpm let runtimeModulePath = require.resolve('@zenstackhq/runtime'); @@ -53,5 +73,5 @@ export function getDefaultOutputFolder() { runtimeModulePath = path.join(runtimeModulePath, '..'); } const modulesFolder = getNodeModulesFolder(runtimeModulePath); - return modulesFolder ? path.join(modulesFolder, '.zenstack') : undefined; + return modulesFolder ? path.join(modulesFolder, DEFAULT_RUNTIME_LOAD_PATH) : undefined; } diff --git a/packages/schema/src/plugins/prisma/index.ts b/packages/schema/src/plugins/prisma/index.ts index 6ffc0a6a8..3a96cf40f 100644 --- a/packages/schema/src/plugins/prisma/index.ts +++ b/packages/schema/src/plugins/prisma/index.ts @@ -1,15 +1,10 @@ -import type { DMMF } from '@prisma/generator-helper'; -import { Model } from '@zenstackhq/language/ast'; -import { PluginOptions } from '@zenstackhq/sdk'; +import { PluginFunction } from '@zenstackhq/sdk'; import PrismaSchemaGenerator from './schema-generator'; export const name = 'Prisma'; -export default async function run( - model: Model, - options: PluginOptions, - _dmmf?: DMMF.Document, - config?: Record -) { - return new PrismaSchemaGenerator().generate(model, options, config); -} +const run: PluginFunction = async (model, options, _dmmf, _globalOptions) => { + return new PrismaSchemaGenerator().generate(model, options); +}; + +export default run; diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index ea7a1436c..7406bff3e 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -81,7 +81,7 @@ export default class PrismaSchemaGenerator { `; - async generate(model: Model, options: PluginOptions, _config?: Record) { + async generate(model: Model, options: PluginOptions) { const warnings: string[] = []; const prismaVersion = getPrismaVersion(); diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index 402c43fbc..854fa91b7 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -1,5 +1,6 @@ import { ConnectorType, DMMF } from '@prisma/generator-helper'; import { + PluginGlobalOptions, PluginOptions, createProject, emitProject, @@ -25,10 +26,15 @@ import Transformer from './transformer'; import removeDir from './utils/removeDir'; import { makeFieldSchema, makeValidationRefinements } from './utils/schema-gen'; -export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.Document) { +export async function generate( + model: Model, + options: PluginOptions, + dmmf: DMMF.Document, + globalOptions?: PluginGlobalOptions +) { let output = options.output as string; if (!output) { - const defaultOutputFolder = getDefaultOutputFolder(); + const defaultOutputFolder = getDefaultOutputFolder(globalOptions); if (defaultOutputFolder) { output = path.join(defaultOutputFolder, 'zod'); } else { @@ -45,6 +51,9 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. const outputObjectTypes = prismaClientDmmf.schema.outputObjectTypes.prisma; const models: DMMF.Model[] = prismaClientDmmf.datamodel.models; + // whether Prisma's Unchecked* series of input types should be generated + const generateUnchecked = options.noUncheckedInput !== true; + const project = createProject(); // common schemas @@ -71,7 +80,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. Transformer.provider = dataSourceProvider; addMissingInputObjectTypes(inputObjectTypes, outputObjectTypes, models); const aggregateOperationSupport = resolveAggregateOperationSupport(inputObjectTypes); - await generateObjectSchemas(inputObjectTypes, project, output, model); + await generateObjectSchemas(inputObjectTypes, project, output, model, generateUnchecked); // input schemas const transformer = new Transformer({ @@ -82,7 +91,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. zmodel: model, inputObjectTypes, }); - await transformer.generateInputSchemas(); + await transformer.generateInputSchemas(generateUnchecked); } // create barrel file @@ -93,7 +102,15 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. project.createSourceFile(path.join(output, 'index.ts'), exports.join(';\n'), { overwrite: true }); // emit - const shouldCompile = options.compile !== false; + let shouldCompile = true; + if (typeof options.compile === 'boolean') { + // explicit override + shouldCompile = options.compile; + } else if (globalOptions) { + // from CLI or config file + shouldCompile = globalOptions.compile; + } + if (!shouldCompile || options.preserveTsFiles === true) { // save ts files await saveProject(project); @@ -146,14 +163,18 @@ async function generateObjectSchemas( inputObjectTypes: DMMF.InputType[], project: Project, output: string, - zmodel: Model + zmodel: Model, + generateUnchecked: boolean ) { const moduleNames: string[] = []; for (let i = 0; i < inputObjectTypes.length; i += 1) { const fields = inputObjectTypes[i]?.fields; const name = inputObjectTypes[i]?.name; + if (!generateUnchecked && name.includes('Unchecked')) { + continue; + } const transformer = new Transformer({ name, fields, project, zmodel, inputObjectTypes }); - const moduleName = transformer.generateObjectSchema(); + const moduleName = transformer.generateObjectSchema(generateUnchecked); moduleNames.push(moduleName); } project.createSourceFile( @@ -236,7 +257,11 @@ async function generateModelSchema(model: DataModel, project: Project, output: s // compile "@@validate" to ".refine" const refinements = makeValidationRefinements(model); if (refinements.length > 0) { - writer.writeLine(`function refine(schema: z.ZodType) { return schema${refinements.join('\n')}; }`); + writer.writeLine( + `function refine(schema: z.ZodType) { return schema${refinements.join( + '\n' + )}; }` + ); } // model schema diff --git a/packages/schema/src/plugins/zod/index.ts b/packages/schema/src/plugins/zod/index.ts index 80d454533..b2b43cb40 100644 --- a/packages/schema/src/plugins/zod/index.ts +++ b/packages/schema/src/plugins/zod/index.ts @@ -1,10 +1,12 @@ -import type { DMMF } from '@prisma/generator-helper'; -import { PluginOptions } from '@zenstackhq/sdk'; -import { Model } from '@zenstackhq/sdk/ast'; +import { PluginFunction } from '@zenstackhq/sdk'; +import invariant from 'tiny-invariant'; import { generate } from './generator'; export const name = 'Zod'; -export default async function run(model: Model, options: PluginOptions, dmmf: DMMF.Document) { - return generate(model, options, dmmf); -} +const run: PluginFunction = async (model, options, dmmf, globalOptions) => { + invariant(dmmf); + return generate(model, options, dmmf, globalOptions); +}; + +export default run; diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index 9d5bf9e20..275461984 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -76,8 +76,8 @@ export default class Transformer { return `export const ${name}Schema = ${schema}`; } - generateObjectSchema() { - const zodObjectSchemaFields = this.generateObjectSchemaFields(); + generateObjectSchema(generateUnchecked: boolean) { + const zodObjectSchemaFields = this.generateObjectSchemaFields(generateUnchecked); const objectSchema = this.prepareObjectSchema(zodObjectSchemaFields); const filePath = path.join(Transformer.outputPath, `objects/${this.name}.schema.ts`); @@ -86,9 +86,9 @@ export default class Transformer { return `${this.name}.schema`; } - generateObjectSchemaFields() { + generateObjectSchemaFields(generateUnchecked: boolean) { const zodObjectSchemaFields = this.fields - .map((field) => this.generateObjectSchemaField(field)) + .map((field) => this.generateObjectSchemaField(field, generateUnchecked)) .flatMap((item) => item) .map((item) => { const [zodStringWithMainType, field, skipValidators] = item; @@ -102,7 +102,10 @@ export default class Transformer { return zodObjectSchemaFields; } - generateObjectSchemaField(field: PrismaDMMF.SchemaArg): [string, PrismaDMMF.SchemaArg, boolean][] { + generateObjectSchemaField( + field: PrismaDMMF.SchemaArg, + generateUnchecked: boolean + ): [string, PrismaDMMF.SchemaArg, boolean][] { const lines = field.inputTypes; if (lines.length === 0) { @@ -110,6 +113,10 @@ export default class Transformer { } let alternatives = lines.reduce((result, inputType) => { + if (!generateUnchecked && typeof inputType.type === 'string' && inputType.type.includes('Unchecked')) { + return result; + } + if (inputType.type === 'String') { result.push(this.wrapWithZodValidators('z.string()', field, inputType)); } else if (inputType.type === 'Int' || inputType.type === 'Float') { @@ -377,7 +384,7 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; return wrapped; } - async generateInputSchemas() { + async generateInputSchemas(generateUnchecked: boolean) { const globalExports: string[] = []; for (const modelOperation of this.modelOperations) { @@ -460,10 +467,17 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; if (createOne) { imports.push( - `import { ${modelName}CreateInputObjectSchema } from '../objects/${modelName}CreateInput.schema'`, - `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'` + `import { ${modelName}CreateInputObjectSchema } from '../objects/${modelName}CreateInput.schema'` ); - codeBody += `create: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema]) }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'` + ); + } + const dataSchema = generateUnchecked + ? `z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema])` + : `${modelName}CreateInputObjectSchema`; + codeBody += `create: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema} }),`; operations.push(['create', origModelName]); } @@ -494,20 +508,34 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; if (updateOne) { imports.push( `import { ${modelName}UpdateInputObjectSchema } from '../objects/${modelName}UpdateInput.schema'`, - `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'`, `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'` ); - codeBody += `update: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema]), where: ${modelName}WhereUniqueInputObjectSchema }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'` + ); + } + const dataSchema = generateUnchecked + ? `z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema])` + : `${modelName}UpdateInputObjectSchema`; + codeBody += `update: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} data: ${dataSchema}, where: ${modelName}WhereUniqueInputObjectSchema }),`; operations.push(['update', origModelName]); } if (updateMany) { imports.push( `import { ${modelName}UpdateManyMutationInputObjectSchema } from '../objects/${modelName}UpdateManyMutationInput.schema'`, - `import { ${modelName}UncheckedUpdateManyInputObjectSchema } from '../objects/${modelName}UncheckedUpdateManyInput.schema'`, `import { ${modelName}WhereInputObjectSchema } from '../objects/${modelName}WhereInput.schema'` ); - codeBody += `updateMany: z.object({ data: z.union([${modelName}UpdateManyMutationInputObjectSchema, ${modelName}UncheckedUpdateManyInputObjectSchema]), where: ${modelName}WhereInputObjectSchema.optional() }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedUpdateManyInputObjectSchema } from '../objects/${modelName}UncheckedUpdateManyInput.schema'` + ); + } + const dataSchema = generateUnchecked + ? `z.union([${modelName}UpdateManyMutationInputObjectSchema, ${modelName}UncheckedUpdateManyInputObjectSchema])` + : `${modelName}UpdateManyMutationInputObjectSchema`; + codeBody += `updateMany: z.object({ data: ${dataSchema}, where: ${modelName}WhereInputObjectSchema.optional() }),`; operations.push(['updateMany', origModelName]); } @@ -515,11 +543,21 @@ export const ${this.name}ObjectSchema: SchemaType = ${schema} as SchemaType;`; imports.push( `import { ${modelName}WhereUniqueInputObjectSchema } from '../objects/${modelName}WhereUniqueInput.schema'`, `import { ${modelName}CreateInputObjectSchema } from '../objects/${modelName}CreateInput.schema'`, - `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'`, - `import { ${modelName}UpdateInputObjectSchema } from '../objects/${modelName}UpdateInput.schema'`, - `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'` + `import { ${modelName}UpdateInputObjectSchema } from '../objects/${modelName}UpdateInput.schema'` ); - codeBody += `upsert: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema, create: z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema]), update: z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema]) }),`; + if (generateUnchecked) { + imports.push( + `import { ${modelName}UncheckedCreateInputObjectSchema } from '../objects/${modelName}UncheckedCreateInput.schema'`, + `import { ${modelName}UncheckedUpdateInputObjectSchema } from '../objects/${modelName}UncheckedUpdateInput.schema'` + ); + } + const createSchema = generateUnchecked + ? `z.union([${modelName}CreateInputObjectSchema, ${modelName}UncheckedCreateInputObjectSchema])` + : `${modelName}CreateInputObjectSchema`; + const updateSchema = generateUnchecked + ? `z.union([${modelName}UpdateInputObjectSchema, ${modelName}UncheckedUpdateInputObjectSchema])` + : `${modelName}UpdateInputObjectSchema`; + codeBody += `upsert: z.object({ ${selectZodSchemaLineLazy} ${includeZodSchemaLineLazy} where: ${modelName}WhereUniqueInputObjectSchema, create: ${createSchema}, update: ${updateSchema} }),`; operations.push(['upsert', origModelName]); } diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts deleted file mode 100644 index 436b2ec8b..000000000 --- a/packages/schema/src/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Model } from '@zenstackhq/language/ast'; - -export interface Context { - schema: Model; - schemaPath: string; - outDir: string; -} - -export interface Generator { - get name(): string; - get startMessage(): string; - get successMessage(): string; - generate(context: Context): Promise; -} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0054ca4ca..5417ef3b7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-beta.21", + "version": "1.0.0-beta.22", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { @@ -20,7 +20,7 @@ "license": "MIT", "dependencies": { "@prisma/generator-helper": "^5.0.0", - "@prisma/internals": "4.10.0", + "@prisma/internals": "^4.16.0", "@prisma/internals-v5": "npm:@prisma/internals@^5.0.0", "@zenstackhq/language": "workspace:*", "@zenstackhq/runtime": "workspace:*", diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index b79829900..c19fdfc42 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -7,12 +7,39 @@ import { Model } from '@zenstackhq/language/ast'; export type OptionValue = string | number | boolean; /** - * Plugin configuration oiptions + * Plugin configuration options */ -export type PluginOptions = { provider?: string; schemaPath: string; name: string } & Record< - string, - OptionValue | OptionValue[] ->; +export type PluginOptions = { + /*** + * The provider package + */ + provider?: string; + + /** + * The path of the ZModel schema + */ + schemaPath: string; + + /** + * The name of the plugin + */ + name: string; +} & Record; + +/** + * Global options that apply to all plugins + */ +export type PluginGlobalOptions = { + /** + * Default output directory + */ + output?: string; + + /** + * Whether to compile the generated code + */ + compile: boolean; +}; /** * Plugin entry point function definition @@ -21,7 +48,7 @@ export type PluginFunction = ( model: Model, options: PluginOptions, dmmf?: DMMF.Document, - config?: Record + globalOptions?: PluginGlobalOptions ) => Promise | string[] | Promise | void; /** diff --git a/packages/server/package.json b/packages/server/package.json index ac8422a7f..9e9a53c55 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "1.0.0-beta.21", + "version": "1.0.0-beta.22", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", @@ -18,7 +18,11 @@ "linkDirectory": true }, "keywords": [ - "fastify", "express", "nextjs", "sveltekit", "nuxtjs" + "fastify", + "express", + "nextjs", + "sveltekit", + "nuxtjs" ], "author": "", "license": "MIT", @@ -40,6 +44,7 @@ "@types/body-parser": "^1.19.2", "@types/express": "^4.17.17", "@types/jest": "^29.5.0", + "@types/node": "^18.0.0", "@types/supertest": "^2.0.12", "@zenstackhq/testtools": "workspace:*", "body-parser": "^1.20.2", @@ -57,6 +62,7 @@ "typescript": "^4.9.4" }, "exports": { + "./package.json": "./package.json", "./api/rest": "./api/rest/index.js", "./api/rpc": "./api/rpc/index.js", "./express": "./express/index.js", diff --git a/packages/server/src/api/base.ts b/packages/server/src/api/base.ts index d7e986b3d..ba385f31c 100644 --- a/packages/server/src/api/base.ts +++ b/packages/server/src/api/base.ts @@ -1,4 +1,53 @@ -import { ModelMeta, getDefaultModelMeta } from '@zenstackhq/runtime'; +import { DbClientContract, ModelMeta, ZodSchemas, getDefaultModelMeta } from '@zenstackhq/runtime'; +import { LoggerConfig } from '../types'; + +/** + * API request context + */ +export type RequestContext = { + /** + * The PrismaClient instance + */ + prisma: DbClientContract; + + /** + * The HTTP method + */ + method: string; + + /** + * The request endpoint path (excluding any prefix) + */ + path: string; + + /** + * The query parameters + */ + query?: Record; + + /** + * The request body object + */ + requestBody?: unknown; + + /** + * Model metadata. By default loaded from the @see loadPath path. You can pass + * it in explicitly to override. + */ + modelMeta?: ModelMeta; + + /** + * Zod schemas for validating create and update payloads. By default loaded from + * the @see loadPath path. You can pass it in explicitly to override. + */ + zodSchemas?: ZodSchemas; + + /** + * Logging configuration. Set to `null` to disable logging. + * If unset or set to `undefined`, log will be output to console. + */ + logger?: LoggerConfig; +}; /** * Base class for API handlers @@ -9,7 +58,7 @@ export abstract class APIHandlerBase { constructor() { try { - this.defaultModelMeta = getDefaultModelMeta(); + this.defaultModelMeta = getDefaultModelMeta(undefined); } catch { // noop } diff --git a/packages/server/src/api/rest/index.ts b/packages/server/src/api/rest/index.ts index fd12d6e32..b85d86bcf 100644 --- a/packages/server/src/api/rest/index.ts +++ b/packages/server/src/api/rest/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { ModelMeta, ZodSchemas } from '@zenstackhq/runtime'; import { + CrudFailureReason, DbClientContract, FieldInfo, PrismaErrorCode, @@ -14,10 +15,10 @@ import SuperJSON from 'superjson'; import { Linker, Paginator, Relator, Serializer, SerializerOptions } from 'ts-japi'; import { upperCaseFirst } from 'upper-case-first'; import UrlPattern from 'url-pattern'; -import z from 'zod'; +import z, { ZodError } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import { LoggerConfig, RequestContext, Response } from '../../types'; -import { APIHandlerBase } from '../base'; +import { LoggerConfig, Response } from '../../types'; +import { APIHandlerBase, RequestContext } from '../base'; import { logWarning, registerCustomSerializers } from '../utils'; const urlPatterns = { @@ -224,7 +225,7 @@ class RequestHandler extends APIHandlerBase { }: RequestContext): Promise { modelMeta = modelMeta ?? this.defaultModelMeta; if (!modelMeta) { - throw new Error('Model meta is not provided or loaded from default location'); + throw new Error('Model metadata is not provided or loaded from default location'); } if (!this.serializers) { @@ -694,7 +695,15 @@ class RequestHandler extends APIHandlerBase { if (payloadSchema) { const parsed = payloadSchema.safeParse(attributes); if (!parsed.success) { - return { error: this.makeError('invalidPayload', fromZodError(parsed.error).message) }; + return { + error: this.makeError( + 'invalidPayload', + fromZodError(parsed.error).message, + undefined, + CrudFailureReason.DATA_VALIDATION_VIOLATION, + parsed.error + ), + }; } } } @@ -721,7 +730,7 @@ class RequestHandler extends APIHandlerBase { const createPayload: any = { data: { ...attributes } }; - // turn relashionship payload into Prisma connect objects + // turn relationship payload into Prisma connect objects if (relationships) { for (const [key, data] of Object.entries(relationships)) { if (!data?.data) { @@ -796,7 +805,13 @@ class RequestHandler extends APIHandlerBase { // zod-parse payload const parsed = this.updateSingleRelationSchema.safeParse(requestBody); if (!parsed.success) { - return this.makeError('invalidPayload', fromZodError(parsed.error).message); + return this.makeError( + 'invalidPayload', + fromZodError(parsed.error).message, + undefined, + CrudFailureReason.DATA_VALIDATION_VIOLATION, + parsed.error + ); } if (parsed.data.data === null) { @@ -823,7 +838,13 @@ class RequestHandler extends APIHandlerBase { // zod-parse payload const parsed = this.updateCollectionRelationSchema.safeParse(requestBody); if (!parsed.success) { - return this.makeError('invalidPayload', fromZodError(parsed.error).message); + return this.makeError( + 'invalidPayload', + fromZodError(parsed.error).message, + undefined, + CrudFailureReason.DATA_VALIDATION_VIOLATION, + parsed.error + ); } // create -> connect, delete -> disconnect, update -> set @@ -1556,7 +1577,13 @@ class RequestHandler extends APIHandlerBase { private handlePrismaError(err: unknown) { if (isPrismaClientKnownRequestError(err)) { if (err.code === PrismaErrorCode.CONSTRAINED_FAILED) { - return this.makeError('forbidden', undefined, 403, err.meta?.reason as string); + return this.makeError( + 'forbidden', + undefined, + 403, + err.meta?.reason as string, + err.meta?.zodErrors as ZodError + ); } else if (err.code === 'P2025' || err.code === 'P2018') { return this.makeError('notFound'); } else { @@ -1581,19 +1608,35 @@ class RequestHandler extends APIHandlerBase { } } - private makeError(code: keyof typeof this.errors, detail?: string, status?: number, reason?: string) { + private makeError( + code: keyof typeof this.errors, + detail?: string, + status?: number, + reason?: string, + zodErrors?: ZodError + ) { + const error: any = { + status: status ?? this.errors[code].status, + code: paramCase(code), + title: this.errors[code].title, + }; + + if (detail) { + error.detail = detail; + } + + if (reason) { + error.reason = reason; + } + + if (zodErrors) { + error.zodErrors = zodErrors; + } + return { status: status ?? this.errors[code].status, body: { - errors: [ - { - status: status ?? this.errors[code].status, - code: paramCase(code), - title: this.errors[code].title, - detail: detail || this.errors[code].detail, - reason, - }, - ], + errors: [error], }, }; } diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index b82995b97..983e79154 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -1,4 +1,5 @@ import { + CrudFailureReason, DbOperations, PrismaErrorCode, ZodSchemas, @@ -8,9 +9,10 @@ import { } from '@zenstackhq/runtime'; import SuperJSON from 'superjson'; import { upperCaseFirst } from 'upper-case-first'; +import { ZodError } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import { RequestContext, Response } from '../../types'; -import { APIHandlerBase } from '../base'; +import { Response } from '../../types'; +import { APIHandlerBase, RequestContext } from '../base'; import { logError, registerCustomSerializers } from '../utils'; registerCustomSerializers(); @@ -37,7 +39,7 @@ class RequestHandler extends APIHandlerBase { }: RequestContext): Promise { modelMeta = modelMeta ?? this.defaultModelMeta; if (!modelMeta) { - throw new Error('Model meta is not provided or loaded from default location'); + throw new Error('Model metadata is not provided or loaded from default location'); } const parts = path.split('/').filter((p) => !!p); @@ -126,9 +128,9 @@ class RequestHandler extends APIHandlerBase { return { status: 400, body: this.makeError('invalid operation: ' + op) }; } - const { error, data: parsedArgs } = await this.processRequestPayload(args, model, dbOp, zodSchemas); + const { error, zodErrors, data: parsedArgs } = await this.processRequestPayload(args, model, dbOp, zodSchemas); if (error) { - return { status: 400, body: this.makeError(error) }; + return { status: 400, body: this.makeError(error, CrudFailureReason.DATA_VALIDATION_VIOLATION, zodErrors) }; } try { @@ -153,18 +155,20 @@ class RequestHandler extends APIHandlerBase { return { status: resCode, body: response }; } catch (err) { if (isPrismaClientKnownRequestError(err)) { - logError(logger, err.code, err.message); const status = ERROR_STATUS_MAPPING[err.code] ?? 400; - const rejectedByPolicy = err.code === PrismaErrorCode.CONSTRAINED_FAILED ? true : undefined; + + const { error } = this.makeError( + err.message, + err.meta?.reason as string, + err.meta?.zodErrors as ZodError + ); return { status, body: { error: { + ...error, prisma: true, - rejectedByPolicy, code: err.code, - message: err.message, - reason: err.meta?.reason, }, }, }; @@ -190,8 +194,19 @@ class RequestHandler extends APIHandlerBase { } } - private makeError(message: string) { - return { error: { message: message } }; + private makeError(message: string, reason?: string, zodErrors?: ZodError) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const error: any = { message, reason }; + if (reason === CrudFailureReason.ACCESS_POLICY_VIOLATION || reason === CrudFailureReason.RESULT_NOT_READABLE) { + error.rejectedByPolicy = true; + } + if (reason === CrudFailureReason.DATA_VALIDATION_VIOLATION) { + error.rejectedByValidation = true; + } + if (zodErrors) { + error.zodErrors = zodErrors; + } + return { error }; } private async processRequestPayload( @@ -224,12 +239,16 @@ class RequestHandler extends APIHandlerBase { if (zodSchema) { const parseResult = zodSchema.safeParse(args); if (parseResult.success) { - return { data: args, error: undefined }; + return { data: args, error: undefined, zodErrors: undefined }; } else { - return { data: undefined, error: fromZodError(parseResult.error).message }; + return { + data: undefined, + error: fromZodError(parseResult.error).message, + zodErrors: parseResult.error, + }; } } else { - return { data: args, error: undefined }; + return { data: args, error: undefined, zodErrors: undefined }; } } diff --git a/packages/server/src/express/middleware.ts b/packages/server/src/express/middleware.ts index b17d3f030..cdf5a3c6e 100644 --- a/packages/server/src/express/middleware.ts +++ b/packages/server/src/express/middleware.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import type { Handler, Request, Response } from 'express'; import RPCAPIHandler from '../api/rpc'; +import { loadAssets } from '../shared'; import { AdapterBaseOptions } from '../types'; /** @@ -30,16 +30,7 @@ export interface MiddlewareOptions extends AdapterBaseOptions { * Creates an Express middleware for handling CRUD requests. */ const factory = (options: MiddlewareOptions): Handler => { - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - try { - zodSchemas = require('@zenstackhq/runtime/zod'); - } catch { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler || RPCAPIHandler(); if (options.useSuperJson !== undefined) { @@ -74,7 +65,7 @@ const factory = (options: MiddlewareOptions): Handler => { query, requestBody: request.body, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/fastify/plugin.ts b/packages/server/src/fastify/plugin.ts index 4b86eec4b..480c4ba8d 100644 --- a/packages/server/src/fastify/plugin.ts +++ b/packages/server/src/fastify/plugin.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; import fp from 'fastify-plugin'; import RPCApiHandler from '../api/rpc'; import { logInfo } from '../api/utils'; +import { loadAssets } from '../shared'; import { AdapterBaseOptions } from '../types'; /** @@ -29,17 +29,9 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d const prefix = options.prefix ?? ''; logInfo(options.logger, `ZenStackPlugin installing routes at prefix: ${prefix}`); - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); - const requestHanler = options.handler ?? RPCApiHandler(); + const requestHandler = options.handler ?? RPCApiHandler(); if (options.useSuperJson !== undefined) { console.warn( 'The option "useSuperJson" is deprecated. The server APIs automatically use superjson for serialization.' @@ -50,17 +42,17 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d const prisma = (await options.getPrisma(request, reply)) as DbClientContract; if (!prisma) { reply.status(500).send({ message: 'unable to get prisma from request context' }); - return; + return reply; } try { - const response = await requestHanler({ + const response = await requestHandler({ method: request.method, path: (request.params as any)['*'], query: request.query as Record, requestBody: request.body, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); @@ -68,6 +60,8 @@ const pluginHandler: FastifyPluginCallback = (fastify, options, d } catch (err) { reply.status(500).send({ message: `An unhandled error occurred: ${err}` }); } + + return reply; }); done(); diff --git a/packages/server/src/next/app-route-handler.ts b/packages/server/src/next/app-route-handler.ts index 58f78244f..538f4ceb5 100644 --- a/packages/server/src/next/app-route-handler.ts +++ b/packages/server/src/next/app-route-handler.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import { NextRequest, NextResponse } from 'next/server'; import { AppRouteRequestHandlerOptions } from '.'; import RPCAPIHandler from '../api/rpc'; +import { loadAssets } from '../shared'; type Context = { params: { path: string[] } }; @@ -17,15 +17,7 @@ type Context = { params: { path: string[] } }; export default function factory( options: AppRouteRequestHandlerOptions ): (req: NextRequest, context: Context) => Promise { - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler || RPCAPIHandler(); if (options.useSuperJson !== undefined) { @@ -69,7 +61,7 @@ export default function factory( query, requestBody, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/next/pages-route-handler.ts b/packages/server/src/next/pages-route-handler.ts index 2baf0f1a4..bd2fbf643 100644 --- a/packages/server/src/next/pages-route-handler.ts +++ b/packages/server/src/next/pages-route-handler.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import { NextApiRequest, NextApiResponse } from 'next'; import { PagesRouteRequestHandlerOptions } from '.'; import RPCAPIHandler from '../api/rpc'; +import { loadAssets } from '../shared'; /** * Creates a Next.js API endpoint (traditional "pages" route) request handler which encapsulates Prisma CRUD operations. @@ -15,15 +15,7 @@ import RPCAPIHandler from '../api/rpc'; export default function factory( options: PagesRouteRequestHandlerOptions ): (req: NextApiRequest, res: NextApiResponse) => Promise { - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler || RPCAPIHandler(); if (options.useSuperJson !== undefined) { @@ -52,7 +44,7 @@ export default function factory( query: req.query as Record, requestBody: req.body, prisma, - modelMeta: options.modelMeta, + modelMeta, zodSchemas, logger: options.logger, }); diff --git a/packages/server/src/shared.ts b/packages/server/src/shared.ts new file mode 100644 index 000000000..6001fbbaa --- /dev/null +++ b/packages/server/src/shared.ts @@ -0,0 +1,20 @@ +import { ZodSchemas, getDefaultModelMeta, getDefaultZodSchemas } from '@zenstackhq/runtime'; +import { AdapterBaseOptions } from './types'; + +export function loadAssets(options: AdapterBaseOptions) { + // model metadata + const modelMeta = options.modelMeta ?? getDefaultModelMeta(options.loadPath); + + // zod schemas + let zodSchemas: ZodSchemas | undefined; + if (typeof options.zodSchemas === 'object') { + zodSchemas = options.zodSchemas; + } else if (options.zodSchemas === true) { + zodSchemas = getDefaultZodSchemas(options.loadPath); + if (!zodSchemas) { + throw new Error('Unable to load zod schemas from default location'); + } + } + + return { modelMeta, zodSchemas }; +} diff --git a/packages/server/src/sveltekit/handler.ts b/packages/server/src/sveltekit/handler.ts index f45eaf9db..be1d831d8 100644 --- a/packages/server/src/sveltekit/handler.ts +++ b/packages/server/src/sveltekit/handler.ts @@ -1,8 +1,8 @@ import type { Handle, RequestEvent } from '@sveltejs/kit'; -import type { ZodSchemas } from '@zenstackhq/runtime'; import { DbClientContract } from '@zenstackhq/runtime'; import RPCApiHandler from '../api/rpc'; import { logInfo } from '../api/utils'; +import { loadAssets } from '../shared'; import { AdapterBaseOptions } from '../types'; /** @@ -26,15 +26,7 @@ export interface HandlerOptions extends AdapterBaseOptions { export default function createHandler(options: HandlerOptions): Handle { logInfo(options.logger, `ZenStackHandler installing routes at prefix: ${options.prefix}`); - let zodSchemas: ZodSchemas | undefined; - if (typeof options.zodSchemas === 'object') { - zodSchemas = options.zodSchemas; - } else if (options.zodSchemas === true) { - zodSchemas = require('@zenstackhq/runtime/zod'); - if (!zodSchemas) { - throw new Error('Unable to load zod schemas from default location'); - } - } + const { modelMeta, zodSchemas } = loadAssets(options); const requestHandler = options.handler ?? RPCApiHandler(); if (options.useSuperJson !== undefined) { @@ -73,8 +65,8 @@ export default function createHandler(options: HandlerOptions): Handle { query, requestBody, prisma, + modelMeta, zodSchemas, - modelMeta: options.modelMeta, }); return new Response(JSON.stringify(r.body), { diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index ed307ab2e..dc72fea25 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,5 +1,5 @@ import type { ModelMeta, ZodSchemas } from '@zenstackhq/runtime'; -import { DbClientContract } from '@zenstackhq/runtime'; +import { RequestContext } from './api/base'; type LoggerMethod = (message: string, code?: string) => void; @@ -13,57 +13,6 @@ export type LoggerConfig = { error?: LoggerMethod; }; -/** - * API request context - */ -export type RequestContext = { - /** - * The PrismaClient instance - */ - prisma: DbClientContract; - - /** - * The HTTP method - */ - method: string; - - /** - * The request endpoint path (excluding any prefix) - */ - path: string; - - /** - * The query parameters - */ - query?: Record; - - /** - * The request body object - */ - requestBody?: unknown; - - /** - * Model metadata. By default loaded from the standard output location - * of the `@zenstackhq/model-meta` plugin. You can pass it in explicitly - * if you configured the plugin to output to a different location. - */ - modelMeta?: ModelMeta; - - /** - * Zod schemas for validating create and update payloads. By default - * loaded from the standard output location of the `@zenstackhq/zod` - * plugin. You can pass it in explicitly if you configured the plugin - * to output to a different location. - */ - zodSchemas?: ZodSchemas; - - /** - * Logging configuration. Set to `null` to disable logging. - * If unset or set to `undefined`, log will be output to console. - */ - logger?: LoggerConfig; -}; - /** * API response */ @@ -99,6 +48,11 @@ export interface AdapterBaseOptions { */ zodSchemas?: ZodSchemas | boolean; + /** + * Path to load model metadata and zod schemas from. Defaults to `node_modules/.zenstack`. + */ + loadPath?: string; + /** * Api request handler function. Can be created using `@zenstackhq/server/api/rest` or `@zenstackhq/server/api/rpc` factory functions. * Defaults to RPC-style API handler created with `/api/rpc`. @@ -107,6 +61,8 @@ export interface AdapterBaseOptions { /** * Whether to use superjson for serialization/deserialization. Defaults to `false`. + * + * @deprecated Not needed anymore and will be removed in a future release. */ useSuperJson?: boolean; } diff --git a/packages/server/tests/adapter/express.test.ts b/packages/server/tests/adapter/express.test.ts index 518f7ccd0..14ec66f84 100644 --- a/packages/server/tests/adapter/express.test.ts +++ b/packages/server/tests/adapter/express.test.ts @@ -85,6 +85,32 @@ describe('Express adapter tests - rpc handler', () => { expect(r.body.data.count).toBe(1); }); + it('custom load path', async () => { + const { prisma } = await loadSchema(schema, { output: './zen' }); + + const app = express(); + app.use(bodyParser.json()); + app.use('/api', ZenStackMiddleware({ getPrisma: () => prisma, loadPath: './zen', zodSchemas: true })); + + const r = await request(app) + .post('/api/user/create') + .send({ + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }); + + expect(r.status).toBe(201); + }); + it('invalid path or args', async () => { const { prisma, zodSchemas } = await loadSchema(schema); diff --git a/packages/server/tests/adapter/fastify.test.ts b/packages/server/tests/adapter/fastify.test.ts index a67480d7a..4e4775d50 100644 --- a/packages/server/tests/adapter/fastify.test.ts +++ b/packages/server/tests/adapter/fastify.test.ts @@ -112,6 +112,38 @@ describe('Fastify adapter tests - rpc handler', () => { expect(r.json().data.count).toBe(1); }); + it('custom load path', async () => { + const { prisma } = await loadSchema(schema, { output: './zen' }); + + const app = fastify(); + app.register(ZenStackFastifyPlugin, { + prefix: '/api', + getPrisma: () => prisma, + loadPath: './zen', + zodSchemas: true, + handler: RPC(), + }); + + const r = await app.inject({ + method: 'POST', + url: '/api/user/create', + payload: { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }, + }); + expect(r.statusCode).toBe(201); + }); + it('invalid path or args', async () => { const { prisma, zodSchemas } = await loadSchema(schema); diff --git a/packages/server/tests/adapter/next.test.ts b/packages/server/tests/adapter/next.test.ts index c125a7801..4715273d8 100644 --- a/packages/server/tests/adapter/next.test.ts +++ b/packages/server/tests/adapter/next.test.ts @@ -162,6 +162,25 @@ model M { expect(await prisma.m.count()).toBe(0); }); + it('custom load path', async () => { + const model = ` +model M { + id String @id @default(cuid()) + value Int +} + `; + + const { prisma } = await loadSchema(model, { output: './zen' }); + + await makeTestClient('/m/create', { getPrisma: () => prisma, zodSchemas: true, loadPath: './zen' }) + .post('/') + .send({ data: { id: '1', value: 1 } }) + .expect(201) + .expect((resp) => { + expect(resp.body.data.value).toBe(1); + }); + }); + it('access policy crud', async () => { const model = ` model M { diff --git a/packages/server/tests/adapter/sveltekit.test.ts b/packages/server/tests/adapter/sveltekit.test.ts index c56128498..534378987 100644 --- a/packages/server/tests/adapter/sveltekit.test.ts +++ b/packages/server/tests/adapter/sveltekit.test.ts @@ -78,6 +78,34 @@ describe('SvelteKit adapter tests - rpc handler', () => { expect(r.status).toBe(200); expect((await unmarshal(r)).data.count).toBe(1); }); + + it('custom load path', async () => { + const { prisma } = await loadSchema(schema, { output: './zen' }); + + const handler = SvelteKitHandler({ + prefix: '/api', + getPrisma: () => prisma, + zodSchemas: true, + loadPath: './zen', + }); + + const r = await handler( + makeRequest('POST', '/api/user/create', { + include: { posts: true }, + data: { + id: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { title: 'post1', published: true, viewCount: 1 }, + { title: 'post2', published: false, viewCount: 2 }, + ], + }, + }, + }) + ); + expect(r.status).toBe(201); + }); }); describe('SvelteKit adapter tests - rest handler', () => { diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts index 277a5d35e..7b084ef8a 100644 --- a/packages/server/tests/api/rest.test.ts +++ b/packages/server/tests/api/rest.test.ts @@ -7,18 +7,24 @@ import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; import makeHandler from '../../src/api/rest'; -let prisma: any; -let zodSchemas: any; -let modelMeta: ModelMeta; -let handler: (any: any) => Promise<{ status: number; body: any }>; +describe('REST server tests', () => { + let prisma: any; + let zodSchemas: any; + let modelMeta: ModelMeta; + let handler: (any: any) => Promise<{ status: number; body: any }>; -describe('REST server tests - regular prisma', () => { - const schema = ` + beforeEach(async () => { + run('npx prisma migrate reset --force'); + run('npx prisma db push'); + }); + + describe('REST server tests - regular prisma', () => { + const schema = ` model User { myId String @id @default(cuid()) createdAt DateTime @default (now()) updatedAt DateTime @updatedAt - email String @unique + email String @unique @email posts Post[] profile Profile? } @@ -58,116 +64,164 @@ describe('REST server tests - regular prisma', () => { } `; - beforeAll(async () => { - const params = await loadSchema(schema); + beforeAll(async () => { + const params = await loadSchema(schema); - prisma = params.prisma; - zodSchemas = params.zodSchemas; - modelMeta = params.modelMeta; + prisma = params.prisma; + 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}`) }); - }); + 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'); - }); + describe('CRUD', () => { + describe('GET', () => { + it('invalid type, id, relationship', async () => { + let r = await handler({ + method: 'get', + path: '/foo', + prisma, + }); + expect(r.status).toBe(404); - describe('CRUD', () => { - describe('GET', () => { - it('invalid type, id, relationship', async () => { - let r = await handler({ - method: 'get', - path: '/foo', - prisma, - }); - expect(r.status).toBe(404); + r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); + expect(r.status).toBe(404); - r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, - }); - expect(r.status).toBe(404); - - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { title: 'Post1' }, + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, }, - }, - }); + }); - r = await handler({ - method: 'get', - path: '/user/user1/relationships/foo', - prisma, - }); - expect(r.status).toBe(404); + r = await handler({ + method: 'get', + path: '/user/user1/relationships/foo', + prisma, + }); + expect(r.status).toBe(404); - r = await handler({ - method: 'get', - path: '/user/user1/foo', - prisma, + r = await handler({ + method: 'get', + path: '/user/user1/foo', + prisma, + }); + expect(r.status).toBe(404); }); - expect(r.status).toBe(404); - }); - it('returns an empty array when no item exists', async () => { - const r = await handler({ - method: 'get', - path: '/user', - prisma, - }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - data: [], - links: { - self: 'http://localhost/api/user', - }, + it('returns an empty array when no item exists', async () => { + const r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: [], + links: { + self: 'http://localhost/api/user', + }, + }); }); - }); - it('returns all items when there are some in the database', async () => { - // Create users first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { title: 'Post1' }, + it('returns all items when there are some in the database', async () => { + // Create users first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { title: 'Post1' }, + }, }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { title: 'Post2' }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { title: 'Post2' }, + }, }, - }, - }); + }); + + const r = await handler({ + method: 'get', + path: '/user', + prisma, + }); - const r = await handler({ - method: 'get', - path: '/user', - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user', + }, + meta: { + total: 2, + }, + data: [ + { + type: 'user', + id: 'user1', + attributes: { email: 'user1@abc.com' }, + links: { + self: 'http://localhost/api/user/user1', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, + data: [{ type: 'post', id: 1 }], + }, + }, + }, + { + type: 'user', + id: 'user2', + attributes: { email: 'user2@abc.com' }, + links: { + self: 'http://localhost/api/user/user2', + }, + relationships: { + posts: { + links: { + self: 'http://localhost/api/user/user2/relationships/posts', + related: 'http://localhost/api/user/user2/posts', + }, + data: [{ type: 'post', id: 2 }], + }, + }, + }, + ], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user', - }, - meta: { - total: 2, - }, - data: [ - { + it('returns a single item when the ID is specified', async () => { + // Create a user first + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, + }); + + const r = await handler({ + method: 'get', + path: '/user/user1', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' }, @@ -184,1131 +238,1108 @@ describe('REST server tests - regular prisma', () => { }, }, }, - { - type: 'user', - id: 'user2', - attributes: { email: 'user2@abc.com' }, - links: { - self: 'http://localhost/api/user/user2', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user2/relationships/posts', - related: 'http://localhost/api/user/user2/posts', - }, - data: [{ type: 'post', id: 2 }], - }, - }, - }, - ], + }); }); - }); - it('returns a single item when the ID is specified', async () => { - // Create a user first - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { title: 'Post1' } } }, - }); + it('fetch a related resource', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); - const r = await handler({ - method: 'get', - path: '/user/user1', - prisma, - }); + const r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - data: { - type: 'user', - id: 'user1', - attributes: { email: 'user1@abc.com' }, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ links: { - self: 'http://localhost/api/user/user1', + self: 'http://localhost/api/user/user1/posts', }, - relationships: { - posts: { + data: [ + { + type: 'post', + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', + self: 'http://localhost/api/post/1', + }, + relationships: { + author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, + }, }, - data: [{ type: 'post', id: 1 }], }, - }, - }, + ], + }); }); - }); - it('fetch a related resource', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, + it('fetch a relationship', async () => { + // Create a user first + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, }, - }, - }); + }); - const r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, + const r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + prisma, + }); + + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'post', id: 1 }], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/posts', - }, - data: [ - { - type: 'post', - id: 1, - attributes: { - title: 'Post1', - authorId: 'user1', - published: false, - viewCount: 0, + it('returns 404 if the specified ID does not exist', async () => { + const r = await handler({ + method: 'get', + path: '/user/nonexistentuser', + prisma, + }); + + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', }, - links: { - self: 'http://localhost/api/post/1', + ], + }); + }); + + it('toplevel filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/1/relationships/author', - related: 'http://localhost/api/post/1/author', - }, - }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, }, }, - ], - }); - }); + }); - it('fetch a relationship', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); + // id filter + let r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user2' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + // String filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email]']: 'user1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@abc' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$contains]']: '1@bc' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - const r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - prisma, - }); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'user1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$startsWith]']: 'ser1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [{ type: 'post', id: 1 }], - }); - }); + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user1' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$endsWith]']: '1@abc' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // Int filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gt]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$gte]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lt]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - it('returns 404 if the specified ID does not exist', async () => { - const r = await handler({ - method: 'get', - path: '/user/nonexistentuser', - prisma, - }); + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount$lte]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // Boolean filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // deep to-one filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author][email]']: 'user1@abc.com' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + + // deep to-many filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts][published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + + // filter to empty + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user3' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // to-many relation collection filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '2' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 'user2' }); + + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[posts]']: '1,2,3' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + + // multi filter + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // to-one relation filter + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[author]']: 'user1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // invalid filter field + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); - expect(r.status).toBe(404); - expect(r.body).toEqual({ - errors: [ - { - code: 'not-found', - status: 404, - title: 'Resource not found', - }, - ], + // invalid filter value + r = await handler({ + method: 'get', + path: '/post', + query: { ['filter[viewCount]']: 'a' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-value', + title: 'Invalid value for type', + }, + ], + }); + + // invalid filter operation + r = await handler({ + method: 'get', + path: '/user', + query: { ['filter[email$foo]']: '1' }, + prisma, + }); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-filter', + title: 'Invalid filter', + }, + ], + }); }); - }); - it('toplevel filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, + it('related data filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, }, - }, - }); + }); - // id filter - let r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user2' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user2' }); - - // String filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email]']: 'user1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$contains]']: '1@abc' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$contains]']: '1@bc' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$startsWith]']: 'user1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$startsWith]']: 'ser1' }, - prisma, + r = await handler({ + method: 'get', + path: '/user/user2/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); }); - expect(r.body.data).toHaveLength(0); - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$endsWith]']: '1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user1' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$endsWith]']: '1@abc' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // Int filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$gt]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$gte]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$lt]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + it('relationship filtering', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1' }, + }, + }, + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + }, + }, + }); - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount$lte]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // Boolean filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // deep to-one filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author][email]']: 'user1@abc.com' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - - // deep to-many filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts][published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - - // filter to empty - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user3' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // to-many relation collection filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts]']: '2' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 'user2' }); - - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[posts]']: '1,2,3' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - - // multi filter - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[id]']: 'user1', ['filter[posts]']: '2' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // to-one relation filter - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[author]']: 'user1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // invalid filter field - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[foo]']: '1' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-filter', - title: 'Invalid filter', - }, - ], + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'get', + path: '/user/user2/relationships/posts', + query: { ['filter[viewCount]']: '1' }, + prisma, + }); + expect(r.body.data).toHaveLength(1); }); - // invalid filter value - r = await handler({ - method: 'get', - path: '/post', - query: { ['filter[viewCount]']: 'a' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-value', - title: 'Invalid value for type', + it('toplevel sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', viewCount: 1, published: true }, + }, }, - ], - }); - - // invalid filter operation - r = await handler({ - method: 'get', - path: '/user', - query: { ['filter[email$foo]']: '1' }, - prisma, - }); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-filter', - title: 'Invalid filter', + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', viewCount: 2, published: false }, + }, }, - ], - }); - }); + }); - it('related data filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, - }, - }, - }); + // basic sorting + let r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // basic sorting desc + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation id + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // by relation field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-author.email' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // multi-field sorting + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'published,viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount,published' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + r = await handler({ + method: 'get', + path: '/post', + query: { sort: '-viewCount,-published' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // invalid field + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); - let r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); + // sort with collection + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'comments' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); - r = await handler({ - method: 'get', - path: '/user/user2/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, + // sort with regular field in the middle + r = await handler({ + method: 'get', + path: '/post', + query: { sort: 'viewCount.foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-sort', + }, + ], + }); }); - expect(r.body.data).toHaveLength(1); - }); - it('relationship filtering', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 1, published: true }, + it('related data sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { + id: 1, + title: 'Post1', + viewCount: 1, + published: true, + setting: { create: { boost: 1 } }, + }, + { + id: 2, + title: 'Post2', + viewCount: 2, + published: false, + setting: { create: { boost: 2 } }, + }, + ], + }, }, - }, - }); - - let r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'get', - path: '/user/user2/relationships/posts', - query: { ['filter[viewCount]']: '1' }, - prisma, - }); - expect(r.body.data).toHaveLength(1); - }); + }); - it('toplevel sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1', viewCount: 1, published: true }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { id: 2, title: 'Post2', viewCount: 2, published: false }, - }, - }, + // asc + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // desc + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // relation field + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { sort: '-setting.boost' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); }); - // basic sorting - let r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // basic sorting desc - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // by relation id - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-author' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // by relation field - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-author.email' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // multi-field sorting - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'published,viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount,published' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - r = await handler({ - method: 'get', - path: '/post', - query: { sort: '-viewCount,-published' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // invalid field - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', + it('relationship sorting', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { + id: 1, + title: 'Post1', + viewCount: 1, + published: true, + setting: { create: { boost: 1 } }, + }, + { + id: 2, + title: 'Post2', + viewCount: 2, + published: false, + setting: { create: { boost: 2 } }, + }, + ], + }, }, - ], - }); + }); - // sort with collection - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'comments' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', - }, - ], + // asc + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: 'viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 1 }); + + // desc + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: '-viewCount' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); + + // relation field + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { sort: '-setting.boost' }, + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data[0]).toMatchObject({ id: 2 }); }); - // sort with regular field in the middle - r = await handler({ - method: 'get', - path: '/post', - query: { sort: 'viewCount.foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-sort', + it('including', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, + }, + profile: { + create: { gender: 'male' }, + }, }, - ], - }); - }); - - it('related data sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: [ - { - id: 1, - title: 'Post1', - viewCount: 1, - published: true, - setting: { create: { boost: 1 } }, - }, - { + }); + await prisma.user.create({ + data: { + myId: 'user2', + email: 'user2@abc.com', + posts: { + create: { id: 2, title: 'Post2', - viewCount: 2, - published: false, - setting: { create: { boost: 2 } }, - }, - ], - }, - }, - }); - - // asc - let r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // desc - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // relation field - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { sort: '-setting.boost' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - }); - - it('relationship sorting', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: [ - { - id: 1, - title: 'Post1', viewCount: 1, published: true, - setting: { create: { boost: 1 } }, + comments: { create: { content: 'Comment2' } }, }, - { - id: 2, - title: 'Post2', - viewCount: 2, - published: false, - setting: { create: { boost: 2 } }, - }, - ], - }, - }, - }); - - // asc - let r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { sort: 'viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 1 }); - - // desc - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { sort: '-viewCount' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - - // relation field - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { sort: '-setting.boost' }, - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data[0]).toMatchObject({ id: 2 }); - }); - - it('including', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: { id: 1, title: 'Post1', comments: { create: { content: 'Comment1' } } }, - }, - profile: { - create: { gender: 'male' }, - }, - }, - }); - await prisma.user.create({ - data: { - myId: 'user2', - email: 'user2@abc.com', - posts: { - create: { - id: 2, - title: 'Post2', - viewCount: 1, - published: true, - comments: { create: { content: 'Comment2' } }, }, }, - }, - }); + }); - // collection query include - let r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts' }, - prisma, - }); - expect(r.body.included).toHaveLength(2); - expect(r.body.included[0]).toMatchObject({ - type: 'post', - id: 1, - attributes: { title: 'Post1' }, - }); + // collection query include + let r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts' }, + prisma, + }); + expect(r.body.included).toHaveLength(2); + expect(r.body.included[0]).toMatchObject({ + type: 'post', + id: 1, + attributes: { title: 'Post1' }, + }); - // single query include - r = await handler({ - method: 'get', - path: '/user/user1', - query: { include: 'posts' }, - prisma, - }); - expect(r.body.included).toHaveLength(1); - expect(r.body.included[0]).toMatchObject({ - type: 'post', - id: 1, - attributes: { title: 'Post1' }, - }); + // single query include + r = await handler({ + method: 'get', + path: '/user/user1', + query: { include: 'posts' }, + prisma, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'post', + id: 1, + attributes: { title: 'Post1' }, + }); - // related query include - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { include: 'posts.comments' }, - prisma, - }); - expect(r.body.included).toHaveLength(1); - expect(r.body.included[0]).toMatchObject({ - type: 'comment', - attributes: { content: 'Comment1' }, - }); + // related query include + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments' }, + prisma, + }); + expect(r.body.included).toHaveLength(1); + expect(r.body.included[0]).toMatchObject({ + type: 'comment', + attributes: { content: 'Comment1' }, + }); - // related query include with filter - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { include: 'posts.comments', ['filter[published]']: 'true' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - - // deep include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts.comments' }, - prisma, - }); - expect(r.body.included).toHaveLength(3); - expect(r.body.included[2]).toMatchObject({ - type: 'comment', - attributes: { content: 'Comment1' }, - }); + // related query include with filter + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { include: 'posts.comments', ['filter[published]']: 'true' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + + // deep include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments' }, + prisma, + }); + expect(r.body.included).toHaveLength(3); + expect(r.body.included[2]).toMatchObject({ + type: 'comment', + attributes: { content: 'Comment1' }, + }); - // multiple include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'posts.comments,profile' }, - prisma, - }); - expect(r.body.included).toHaveLength(4); - const profile = r.body.included.find((item: any) => item.type === 'profile'); - expect(profile).toMatchObject({ - type: 'profile', - attributes: { gender: 'male' }, - }); + // multiple include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'posts.comments,profile' }, + prisma, + }); + expect(r.body.included).toHaveLength(4); + const profile = r.body.included.find((item: any) => item.type === 'profile'); + expect(profile).toMatchObject({ + type: 'profile', + attributes: { gender: 'male' }, + }); - // invalid include - r = await handler({ - method: 'get', - path: '/user', - query: { include: 'foo' }, - prisma, - }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [{ status: 400, code: 'unsupported-relationship' }], + // invalid include + r = await handler({ + method: 'get', + path: '/user', + query: { include: 'foo' }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [{ status: 400, code: 'unsupported-relationship' }], + }); }); - }); - it('toplevel pagination', async () => { - for (const i of Array(5).keys()) { - await prisma.user.create({ - data: { - myId: `user${i}`, - email: `user${i}@abc.com`, - }, + it('toplevel pagination', async () => { + for (const i of Array(5).keys()) { + await prisma.user.create({ + data: { + myId: `user${i}`, + email: `user${i}@abc.com`, + }, + }); + } + + // limit only + let r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: null, + next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', }); - } - - // limit only - let r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.meta.total).toBe(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=3', - last: 'http://localhost/api/user?page%5Boffset%5D=3', - prev: null, - next: 'http://localhost/api/user?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); - // limit & offset - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '3', ['page[offset]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.meta.total).toBe(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=3', - last: 'http://localhost/api/user?page%5Boffset%5D=3', - prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', - next: null, - }); + // limit & offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '3', ['page[offset]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.meta.total).toBe(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=3', + last: 'http://localhost/api/user?page%5Boffset%5D=3', + prev: 'http://localhost/api/user?page%5Boffset%5D=0&page%5Blimit%5D=3', + next: null, + }); - // limit trimmed - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '10' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); + // limit trimmed + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '10' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); - // offset overflow - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[offset]']: '10' }, - prisma, - }); - expect(r.body.data).toHaveLength(0); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); + // offset overflow + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '10' }, + prisma, + }); + expect(r.body.data).toHaveLength(0); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); - // minus offset - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[offset]']: '-1' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, - }); + // minus offset + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[offset]']: '-1' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); - // zero limit - r = await handler({ - method: 'get', - path: '/user', - query: { ['page[limit]']: '0' }, - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - first: 'http://localhost/api/user?page%5Blimit%5D=5', - last: 'http://localhost/api/user?page%5Boffset%5D=0', - prev: null, - next: null, + // zero limit + r = await handler({ + method: 'get', + path: '/user', + query: { ['page[limit]']: '0' }, + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + first: 'http://localhost/api/user?page%5Blimit%5D=5', + last: 'http://localhost/api/user?page%5Boffset%5D=0', + prev: null, + next: null, + }); }); - }); - it('related data pagination', async () => { - await prisma.user.create({ - data: { - myId: `user1`, - email: `user1@abc.com`, - posts: { - create: [...Array(10).keys()].map((i) => ({ - id: i, - title: `Post${i}`, - })), + it('related data pagination', async () => { + await prisma.user.create({ + data: { + myId: `user1`, + email: `user1@abc.com`, + posts: { + create: [...Array(10).keys()].map((i) => ({ + id: i, + title: `Post${i}`, + })), + }, }, - }, - }); + }); - // default limiting - let r = await handler({ - method: 'get', - path: '/user/user1/posts', - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/posts', - first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=5', - last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5', - prev: null, - next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', - }); + // default limiting + let r = await handler({ + method: 'get', + path: '/user/user1/posts', + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=5', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5', + prev: null, + next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', + }); - // explicit limiting - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/posts', - first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', - prev: null, - next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); + // explicit limiting + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', + prev: null, + next: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); - // offset - r = await handler({ - method: 'get', - path: '/user/user1/posts', - query: { ['page[limit]']: '3', ['page[offset]']: '8' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/posts', - first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', - prev: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', - next: null, + // offset + r = await handler({ + method: 'get', + path: '/user/user1/posts', + query: { ['page[limit]']: '3', ['page[offset]']: '8' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/posts', + first: 'http://localhost/api/user/user1/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=9', + prev: 'http://localhost/api/user/user1/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', + next: null, + }); }); - }); - it('relationship pagination', async () => { - await prisma.user.create({ - data: { - myId: `user1`, - email: `user1@abc.com`, - posts: { - create: [...Array(10).keys()].map((i) => ({ - id: i, - title: `Post${i}`, - })), + it('relationship pagination', async () => { + await prisma.user.create({ + data: { + myId: `user1`, + email: `user1@abc.com`, + posts: { + create: [...Array(10).keys()].map((i) => ({ + id: i, + title: `Post${i}`, + })), + }, }, - }, - }); + }); - // default limiting - let r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - prisma, - }); - expect(r.body.data).toHaveLength(5); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/relationships/posts', - first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=5', - last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5', - prev: null, - next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', - }); + // default limiting + let r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + prisma, + }); + expect(r.body.data).toHaveLength(5); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=5', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5', + prev: null, + next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=5', + }); - // explicit limiting - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { ['page[limit]']: '3' }, - prisma, - }); - expect(r.body.data).toHaveLength(3); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/relationships/posts', - first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', - prev: null, - next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', - }); + // explicit limiting + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['page[limit]']: '3' }, + prisma, + }); + expect(r.body.data).toHaveLength(3); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', + prev: null, + next: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=3&page%5Blimit%5D=3', + }); - // offset - r = await handler({ - method: 'get', - path: '/user/user1/relationships/posts', - query: { ['page[limit]']: '3', ['page[offset]']: '8' }, - prisma, - }); - expect(r.body.data).toHaveLength(2); - expect(r.body.links).toMatchObject({ - self: 'http://localhost/api/user/user1/relationships/posts', - first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', - last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', - prev: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', - next: null, + // offset + r = await handler({ + method: 'get', + path: '/user/user1/relationships/posts', + query: { ['page[limit]']: '3', ['page[offset]']: '8' }, + prisma, + }); + expect(r.body.data).toHaveLength(2); + expect(r.body.links).toMatchObject({ + self: 'http://localhost/api/user/user1/relationships/posts', + first: 'http://localhost/api/user/user1/relationships/posts?page%5Blimit%5D=3', + last: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=9', + prev: 'http://localhost/api/user/user1/relationships/posts?page%5Boffset%5D=5&page%5Blimit%5D=3', + next: null, + }); }); }); - }); - describe('POST', () => { - it('creates an item without relation', async () => { - const r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { myId: 'user1', email: 'user1@abc.com' } }, - }, - prisma, - }); + describe('POST', () => { + it('creates an item without relation', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { myId: 'user1', email: 'user1@abc.com' } }, + }, + prisma, + }); - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - jsonapi: { version: '1.1' }, - data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, + data: { type: 'user', id: 'user1', attributes: { email: 'user1@abc.com' } }, + }); }); - }); - it('creates an item with collection relations', async () => { - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + it('creates an item with collection relations', async () => { + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { myId: 'user1', email: 'user1@abc.com' }, + relationships: { + posts: { + data: [ + { type: 'post', id: 1 }, + { type: 'post', id: 2 }, + ], + }, + }, + }, + }, + prisma, + }); - const r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + jsonapi: { version: '1.1' }, data: { type: 'user', - attributes: { myId: 'user1', email: 'user1@abc.com' }, + id: 'user1', + attributes: { + email: 'user1@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, relationships: { posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, data: [ { type: 'post', id: 1 }, { type: 'post', id: 2 }, @@ -1316,207 +1347,209 @@ describe('REST server tests - regular prisma', () => { }, }, }, - }, - prisma, + }); }); - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - jsonapi: { version: '1.1' }, - data: { - type: 'user', - id: 'user1', - attributes: { - email: 'user1@abc.com', - }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', + it('creates an item with single relation', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); + + const r = await handler({ + method: 'post', + path: '/post', + query: {}, + requestBody: { + data: { + type: 'post', + attributes: { title: 'Post1' }, + relationships: { + author: { + data: { type: 'user', id: 'user1' }, + }, }, - data: [ - { type: 'post', id: 1 }, - { type: 'post', id: 2 }, - ], }, }, - }, - }); - }); - - it('creates an item with single relation', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com' }, - }); + prisma, + }); - const r = await handler({ - method: 'post', - path: '/post', - query: {}, - requestBody: { + expect(r.status).toBe(201); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1', + }, data: { type: 'post', - attributes: { title: 'Post1' }, + id: 1, + attributes: { + title: 'Post1', + authorId: 'user1', + published: false, + viewCount: 0, + }, + links: { + self: 'http://localhost/api/post/1', + }, relationships: { author: { + links: { + self: 'http://localhost/api/post/1/relationships/author', + related: 'http://localhost/api/post/1/author', + }, data: { type: 'user', id: 'user1' }, }, }, }, - }, - prisma, + }); }); - expect(r.status).toBe(201); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/post/1', - }, - data: { - type: 'post', - id: 1, - attributes: { - title: 'Post1', - authorId: 'user1', - published: false, - viewCount: 0, - }, - links: { - self: 'http://localhost/api/post/1', - }, - relationships: { - author: { - links: { - self: 'http://localhost/api/post/1/relationships/author', - related: 'http://localhost/api/post/1/author', - }, - data: { type: 'user', id: 'user1' }, - }, + it('create single relation disallowed', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + const r = await handler({ + method: 'post', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { type: 'user', id: 'user1' }, }, - }, - }); - }); + prisma, + }); - it('create single relation disallowed', async () => { - await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-verb', + title: 'The HTTP verb is not supported', + }, + ], + }); }); - const r = await handler({ - method: 'post', - path: '/post/1/relationships/author', - query: {}, - requestBody: { - data: { type: 'user', id: 'user1' }, - }, - prisma, - }); + it('create a collection of relations', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-verb', - title: 'The HTTP verb is not supported', + const r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [ + { type: 'post', id: 1 }, + { type: 'post', id: 2 }, + ], }, - ], - }); - }); - - it('create a collection of relations', async () => { - await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + prisma, + }); - const r = await handler({ - method: 'post', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, data: [ { type: 'post', id: 1 }, { type: 'post', id: 2 }, ], - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [ - { type: 'post', id: 1 }, - { type: 'post', id: 2 }, - ], - }); - }); + it('create relation for nonexistent entity', async () => { + let r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 1 }], + }, + prisma, + }); - it('create relation for nonexistent entity', async () => { - let r = await handler({ - method: 'post', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { - data: [{ type: 'post', id: 1 }], - }, - prisma, - }); + expect(r.status).toBe(404); - expect(r.status).toBe(404); + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com' }, + }); - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com' }, - }); + r = await handler({ + method: 'post', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { data: [{ type: 'post', id: 1 }] }, + prisma, + }); - r = await handler({ - method: 'post', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { data: [{ type: 'post', id: 1 }] }, - prisma, + expect(r.status).toBe(404); }); - - expect(r.status).toBe(404); }); - }); - describe('PUT', () => { - it('updates an item if it exists', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - }, - }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + describe('PUT', () => { + it('updates an item if it exists', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + }, + }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); + + const r = await handler({ + method: 'put', + path: '/user/user1', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { email: 'user2@abc.com' }, + relationships: { + posts: { + data: [ + { type: 'post', id: 1 }, + { type: 'post', id: 2 }, + ], + }, + }, + }, + }, + prisma, + }); - const r = await handler({ - method: 'put', - path: '/user/user1', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1', + }, data: { type: 'user', - attributes: { email: 'user2@abc.com' }, + id: 'user1', + attributes: { + email: 'user2@abc.com', + }, + links: { + self: 'http://localhost/api/user/user1', + }, relationships: { posts: { + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + related: 'http://localhost/api/user/user1/posts', + }, data: [ { type: 'post', id: 1 }, { type: 'post', id: 2 }, @@ -1524,324 +1557,312 @@ describe('REST server tests - regular prisma', () => { }, }, }, - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1', - }, - data: { - type: 'user', - id: 'user1', - attributes: { - email: 'user2@abc.com', - }, - links: { - self: 'http://localhost/api/user/user1', - }, - relationships: { - posts: { - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - related: 'http://localhost/api/user/user1/posts', - }, - data: [ - { type: 'post', id: 1 }, - { type: 'post', id: 2 }, - ], + it('returns 404 if the user does not exist', async () => { + const r = await handler({ + method: 'put', + path: '/user/nonexistentuser', + query: {}, + requestBody: { + data: { + type: 'user', + attributes: { email: 'user2@abc.com' }, }, }, - }, - }); - }); + prisma, + }); - it('returns 404 if the user does not exist', async () => { - const r = await handler({ - method: 'put', - path: '/user/nonexistentuser', - query: {}, - requestBody: { - data: { - type: 'user', - attributes: { email: 'user2@abc.com' }, - }, - }, - prisma, + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', + }, + ], + }); }); - expect(r.status).toBe(404); - expect(r.body).toEqual({ - errors: [ - { - code: 'not-found', - status: 404, - title: 'Resource not found', - }, - ], - }); - }); + it('update a single relation', async () => { + await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); - it('update a single relation', async () => { - await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, - }); + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'user', + id: 'user1', + }, + }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + jsonapi: { + version: '1.1', + }, + links: { + self: 'http://localhost/api/post/1/relationships/author', + }, data: { type: 'user', id: 'user1', }, - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - jsonapi: { - version: '1.1', - }, - links: { - self: 'http://localhost/api/post/1/relationships/author', - }, - data: { - type: 'user', - id: 'user1', - }, - }); - }); + it('remove a single relation', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); - it('remove a single relation', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); + const r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { data: null }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { data: null }, - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/post/1/relationships/author', + }, + data: null, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/post/1/relationships/author', - }, - data: null, - }); - }); + it('update a collection of relations', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); + await prisma.post.create({ + data: { id: 2, title: 'Post2' }, + }); - it('update a collection of relations', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); - await prisma.post.create({ - data: { id: 2, title: 'Post2' }, - }); + const r = await handler({ + method: 'patch', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 2 }], + }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, data: [{ type: 'post', id: 2 }], - }, - prisma, + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [{ type: 'post', id: 2 }], - }); - }); + it('update a collection of relations to empty', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); - it('update a collection of relations to empty', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); + const r = await handler({ + method: 'patch', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { data: [] }, + prisma, + }); - const r = await handler({ - method: 'patch', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { data: [] }, - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [], - }); - }); + it('update relation for nonexistent entity', async () => { + let r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'user', + id: 'user1', + }, + }, + prisma, + }); + expect(r.status).toBe(404); - it('update relation for nonexistent entity', async () => { - let r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { - data: { - type: 'user', - id: 'user1', + await prisma.post.create({ + data: { id: 1, title: 'Post1' }, + }); + + r = await handler({ + method: 'patch', + path: '/post/1/relationships/author', + query: {}, + requestBody: { + data: { + type: 'user', + id: 'user1', + }, }, - }, - prisma, - }); - expect(r.status).toBe(404); + prisma, + }); - await prisma.post.create({ - data: { id: 1, title: 'Post1' }, + expect(r.status).toBe(404); }); + }); - r = await handler({ - method: 'patch', - path: '/post/1/relationships/author', - query: {}, - requestBody: { + describe('DELETE', () => { + it('deletes an item if it exists', async () => { + // Create a user first + await prisma.user.create({ data: { - type: 'user', - id: 'user1', + myId: 'user1', + email: 'user1@abc.com', }, - }, - prisma, - }); - - expect(r.status).toBe(404); - }); - }); + }); - describe('DELETE', () => { - it('deletes an item if it exists', async () => { - // Create a user first - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - }, - }); + const r = await handler({ + method: 'delete', + path: '/user/user1', + prisma, + }); - const r = await handler({ - method: 'delete', - path: '/user/user1', - prisma, + expect(r.status).toBe(204); + expect(r.body).toBeUndefined(); }); - expect(r.status).toBe(204); - expect(r.body).toBeUndefined(); - }); + it('returns 404 if the user does not exist', async () => { + const r = await handler({ + method: 'delete', + path: '/user/nonexistentuser', + prisma, + }); - it('returns 404 if the user does not exist', async () => { - const r = await handler({ - method: 'delete', - path: '/user/nonexistentuser', - prisma, + expect(r.status).toBe(404); + expect(r.body).toEqual({ + errors: [ + { + code: 'not-found', + status: 404, + title: 'Resource not found', + }, + ], + }); }); - expect(r.status).toBe(404); - expect(r.body).toEqual({ - errors: [ - { - code: 'not-found', - status: 404, - title: 'Resource not found', - }, - ], - }); - }); + it('delete single relation disallowed', async () => { + await prisma.user.create({ + data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, + }); - it('delete single relation disallowed', async () => { - await prisma.user.create({ - data: { myId: 'user1', email: 'user1@abc.com', posts: { create: { id: 1, title: 'Post1' } } }, - }); + const r = await handler({ + method: 'delete', + path: '/post/1/relationships/author', + query: {}, + prisma, + }); - const r = await handler({ - method: 'delete', - path: '/post/1/relationships/author', - query: {}, - prisma, + expect(r.status).toBe(400); + expect(r.body).toMatchObject({ + errors: [ + { + status: 400, + code: 'invalid-verb', + title: 'The HTTP verb is not supported', + }, + ], + }); }); - expect(r.status).toBe(400); - expect(r.body).toMatchObject({ - errors: [ - { - status: 400, - code: 'invalid-verb', - title: 'The HTTP verb is not supported', + it('delete a collection of relations', async () => { + await prisma.user.create({ + data: { + myId: 'user1', + email: 'user1@abc.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, }, - ], - }); - }); + }); - it('delete a collection of relations', async () => { - await prisma.user.create({ - data: { - myId: 'user1', - email: 'user1@abc.com', - posts: { - create: [ - { id: 1, title: 'Post1' }, - { id: 2, title: 'Post2' }, - ], + const r = await handler({ + method: 'delete', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 1 }], }, - }, - }); + prisma, + }); - const r = await handler({ - method: 'delete', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { - data: [{ type: 'post', id: 1 }], - }, - prisma, + expect(r.status).toBe(200); + expect(r.body).toMatchObject({ + jsonapi: { + version: '1.1', + }, + links: { + self: 'http://localhost/api/user/user1/relationships/posts', + }, + data: [{ type: 'post', id: 2 }], + }); }); - expect(r.status).toBe(200); - expect(r.body).toMatchObject({ - jsonapi: { - version: '1.1', - }, - links: { - self: 'http://localhost/api/user/user1/relationships/posts', - }, - data: [{ type: 'post', id: 2 }], + it('delete relations for nonexistent entity', async () => { + const r = await handler({ + method: 'delete', + path: '/user/user1/relationships/posts', + query: {}, + requestBody: { + data: [{ type: 'post', id: 1 }], + }, + prisma, + }); + expect(r.status).toBe(404); }); }); - it('delete relations for nonexistent entity', async () => { - const r = await handler({ - method: 'delete', - path: '/user/user1/relationships/posts', - query: {}, - requestBody: { - data: [{ type: 'post', id: 1 }], - }, - prisma, + describe('validation error', () => { + it('creates an item without relation', async () => { + const r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { myId: 'user1', email: 'user1.com' } }, + }, + prisma, + }); + + expect(r.status).toBe(400); + expect(r.body.errors[0].code).toBe('invalid-payload'); + expect(r.body.errors[0].reason).toBe(CrudFailureReason.DATA_VALIDATION_VIOLATION); + expect(r.body.errors[0].zodErrors).toBeTruthy(); }); - expect(r.status).toBe(404); }); }); }); -}); -describe('REST server tests - enhanced prisma', () => { - const schema = ` + describe('REST server tests - enhanced prisma', () => { + const schema = ` model Foo { id Int @id value Int @@ -1858,63 +1879,61 @@ describe('REST server tests - enhanced prisma', () => { } `; - beforeAll(async () => { - const params = await loadSchema(schema); - - prisma = withPolicy(params.prisma, undefined, params); - 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}`) }); - }); + beforeAll(async () => { + const params = await loadSchema(schema); - beforeEach(async () => { - run('npx prisma migrate reset --force'); - run('npx prisma db push'); - }); + prisma = withPolicy(params.prisma, undefined, params); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; - it('update policy rejection test', async () => { - let r = await handler({ - method: 'post', - path: '/foo', - query: {}, - requestBody: { - data: { type: 'foo', attributes: { id: 1, value: 0 } }, - }, - prisma, + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - expect(r.status).toBe(201); - - r = await handler({ - method: 'put', - path: '/foo/1', - query: {}, - requestBody: { - data: { type: 'foo', attributes: { value: 1 } }, - }, - prisma, + + it('update policy rejection test', async () => { + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { id: 1, value: 0 } }, + }, + prisma, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + data: { type: 'foo', attributes: { value: 1 } }, + }, + prisma, + }); + expect(r.status).toBe(403); + expect(r.body.errors[0].code).toBe('forbidden'); + expect(r.body.errors[0].reason).toBe(CrudFailureReason.ACCESS_POLICY_VIOLATION); }); - expect(r.status).toBe(403); - }); - it('read-back policy rejection test', async () => { - const r = await handler({ - method: 'post', - path: '/bar', - query: {}, - requestBody: { - data: { type: 'bar', attributes: { id: 1, value: 0 } }, - }, - prisma, + it('read-back policy rejection test', async () => { + const r = await handler({ + method: 'post', + path: '/bar', + query: {}, + requestBody: { + data: { type: 'bar', attributes: { id: 1, value: 0 } }, + }, + prisma, + }); + expect(r.status).toBe(403); + expect(r.body.errors[0].reason).toBe(CrudFailureReason.RESULT_NOT_READABLE); }); - expect(r.status).toBe(403); - expect(r.body.errors[0].reason).toBe(CrudFailureReason.RESULT_NOT_READABLE); }); -}); -describe('REST server tests - NextAuth project regression', () => { - const schema = ` + describe('REST server tests - NextAuth project regression', () => { + const schema = ` model Post { id String @id @default(cuid()) title String @@ -1973,66 +1992,62 @@ describe('REST server tests - NextAuth project regression', () => { } `; - beforeAll(async () => { - const params = await loadSchema(schema); - - prisma = withPolicy(params.prisma, undefined, params); - 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}`) }); - }); + beforeAll(async () => { + const params = await loadSchema(schema); - beforeEach(async () => { - run('npx prisma migrate reset --force'); - run('npx prisma db push'); - }); + prisma = withPolicy(params.prisma, undefined, params); + zodSchemas = params.zodSchemas; + modelMeta = params.modelMeta; - it('crud test', async () => { - let r = await handler({ - method: 'get', - path: '/user', - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(0); - - r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, - }, - prisma, + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); }); - expect(r.status).toBe(201); - r = await handler({ - method: 'get', - path: '/user', - prisma, - }); - expect(r.status).toBe(200); - expect(r.body.data).toHaveLength(1); - - r = await handler({ - method: 'post', - path: '/user', - query: {}, - requestBody: { - data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, - }, - prisma, + it('crud test', async () => { + let r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(0); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, + }, + prisma, + }); + expect(r.status).toBe(201); + + r = await handler({ + method: 'get', + path: '/user', + prisma, + }); + expect(r.status).toBe(200); + expect(r.body.data).toHaveLength(1); + + r = await handler({ + method: 'post', + path: '/user', + query: {}, + requestBody: { + data: { type: 'user', attributes: { email: 'user1@abc.com', password: '1234' } }, + }, + prisma, + }); + expect(r.status).toBe(400); + expect(r.body.errors[0].prismaCode).toBe('P2002'); }); - expect(r.status).toBe(400); - expect(r.body.errors[0].prismaCode).toBe('P2002'); }); -}); -describe('REST server tests - field type coverage', () => { - const schema = ` + describe('REST server tests - field type coverage', () => { + const schema = ` model Foo { id Int @id string String @@ -2054,131 +2069,133 @@ describe('REST server tests - field type coverage', () => { } `; - it('field types', async () => { - const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); - - const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); - handler = (args) => _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); - - await prisma.bar.create({ data: { id: 1, bytes: Buffer.from([7, 8, 9]) } }); - - const decimalValue1 = new Decimal('0.046875'); - const decimalValue2 = new Decimal('0.0146875'); - - const createAttrs = { - string: 'string', - int: 123, - bigInt: BigInt(534543543534), - date: new Date(), - float: 1.23, - decimal: decimalValue1, - boolean: true, - bytes: Buffer.from([1, 2, 3, 4]), - }; - - const { json: createPayload, meta: createMeta } = SuperJSON.serialize({ - data: { - type: 'foo', - attributes: { id: 1, ...createAttrs }, - relationships: { - bars: { - data: [{ type: 'bar', id: 1 }], + it('field types', async () => { + const { prisma, zodSchemas, modelMeta } = await loadSchema(schema); + + const _handler = makeHandler({ endpoint: 'http://localhost/api', pageSize: 5 }); + handler = (args) => + _handler({ ...args, zodSchemas, modelMeta, url: new URL(`http://localhost/${args.path}`) }); + + await prisma.bar.create({ data: { id: 1, bytes: Buffer.from([7, 8, 9]) } }); + + const decimalValue1 = new Decimal('0.046875'); + const decimalValue2 = new Decimal('0.0146875'); + + const createAttrs = { + string: 'string', + int: 123, + bigInt: BigInt(534543543534), + date: new Date(), + float: 1.23, + decimal: decimalValue1, + boolean: true, + bytes: Buffer.from([1, 2, 3, 4]), + }; + + const { json: createPayload, meta: createMeta } = SuperJSON.serialize({ + data: { + type: 'foo', + attributes: { id: 1, ...createAttrs }, + relationships: { + bars: { + data: [{ type: 'bar', id: 1 }], + }, }, }, - }, - }); + }); - let r = await handler({ - method: 'post', - path: '/foo', - query: {}, - requestBody: { - ...(createPayload as any), - meta: { - serialization: createMeta, + let r = await handler({ + method: 'post', + path: '/foo', + query: {}, + requestBody: { + ...(createPayload as any), + meta: { + serialization: createMeta, + }, }, - }, - prisma, - }); - expect(r.status).toBe(201); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - let serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - let deserialized: any = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - let data = deserialized.data.attributes; - expect(typeof data.bigInt).toBe('bigint'); - expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); - expect(data.date instanceof Date).toBeTruthy(); - expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); - - const updateAttrs = { - bigInt: BigInt(1534543543534), - date: new Date(), - decimal: decimalValue2, - bytes: Buffer.from([5, 2, 3, 4]), - }; - const { json: updatePayload, meta: updateMeta } = SuperJSON.serialize({ - data: { - type: 'foo', - attributes: updateAttrs, - }, - }); + prisma, + }); + expect(r.status).toBe(201); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + let serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + let deserialized: any = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + let data = deserialized.data.attributes; + expect(typeof data.bigInt).toBe('bigint'); + expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); + expect(data.date instanceof Date).toBeTruthy(); + expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); + + const updateAttrs = { + bigInt: BigInt(1534543543534), + date: new Date(), + decimal: decimalValue2, + bytes: Buffer.from([5, 2, 3, 4]), + }; + const { json: updatePayload, meta: updateMeta } = SuperJSON.serialize({ + data: { + type: 'foo', + attributes: updateAttrs, + }, + }); - r = await handler({ - method: 'put', - path: '/foo/1', - query: {}, - requestBody: { - ...(updatePayload as any), - meta: { - serialization: updateMeta, + r = await handler({ + method: 'put', + path: '/foo/1', + query: {}, + requestBody: { + ...(updatePayload as any), + meta: { + serialization: updateMeta, + }, }, - }, - prisma, - }); - expect(r.status).toBe(200); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - - serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - data = deserialized.data.attributes; - expect(data.bigInt).toEqual(updateAttrs.bigInt); - expect(data.date).toEqual(updateAttrs.date); - expect(data.decimal.equals(updateAttrs.decimal)).toBeTruthy(); - expect(data.bytes.toString('base64')).toEqual(updateAttrs.bytes.toString('base64')); - - r = await handler({ - method: 'get', - path: '/foo/1', - query: {}, - prisma, - }); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - data = deserialized.data.attributes; - expect(typeof data.bigInt).toBe('bigint'); - expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); - expect(data.date instanceof Date).toBeTruthy(); - expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); - - r = await handler({ - method: 'get', - path: '/foo', - query: { include: 'bars' }, - prisma, + prisma, + }); + expect(r.status).toBe(200); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + data = deserialized.data.attributes; + expect(data.bigInt).toEqual(updateAttrs.bigInt); + expect(data.date).toEqual(updateAttrs.date); + expect(data.decimal.equals(updateAttrs.decimal)).toBeTruthy(); + expect(data.bytes.toString('base64')).toEqual(updateAttrs.bytes.toString('base64')); + + r = await handler({ + method: 'get', + path: '/foo/1', + query: {}, + prisma, + }); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + data = deserialized.data.attributes; + expect(typeof data.bigInt).toBe('bigint'); + expect(Buffer.isBuffer(data.bytes)).toBeTruthy(); + expect(data.date instanceof Date).toBeTruthy(); + expect(Decimal.isDecimal(data.decimal)).toBeTruthy(); + + r = await handler({ + method: 'get', + path: '/foo', + query: { include: 'bars' }, + prisma, + }); + // result is serializable + expect(JSON.stringify(r.body)).toBeTruthy(); + serializationMeta = r.body.meta.serialization; + expect(serializationMeta).toBeTruthy(); + deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); + const included = deserialized.included[0]; + expect(Buffer.isBuffer(included.attributes.bytes)).toBeTruthy(); }); - // result is serializable - expect(JSON.stringify(r.body)).toBeTruthy(); - serializationMeta = r.body.meta.serialization; - expect(serializationMeta).toBeTruthy(); - deserialized = SuperJSON.deserialize({ json: r.body, meta: serializationMeta }); - const included = deserialized.included[0]; - expect(Buffer.isBuffer(included.attributes.bytes)).toBeTruthy(); }); }); diff --git a/packages/server/tests/api/rpc.test.ts b/packages/server/tests/api/rpc.test.ts index 1f9b6ed69..5d7708745 100644 --- a/packages/server/tests/api/rpc.test.ts +++ b/packages/server/tests/api/rpc.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /// -import type { ZodSchemas } from '@zenstackhq/runtime'; +import { CrudFailureReason, type ZodSchemas } from '@zenstackhq/runtime'; import { loadSchema } from '@zenstackhq/testtools'; import { Decimal } from 'decimal.js'; import SuperJSON from 'superjson'; @@ -10,11 +10,15 @@ import { schema } from '../utils'; describe('RPC API Handler Tests', () => { let prisma: any; + let enhance: any; + let modelMeta: any; let zodSchemas: any; beforeAll(async () => { const params = await loadSchema(schema, { fullZod: true }); prisma = params.prisma; + enhance = params.enhance; + modelMeta = params.modelMeta; zodSchemas = params.zodSchemas; }); @@ -127,6 +131,31 @@ describe('RPC API Handler Tests', () => { expect(r.data.count).toBe(1); }); + it('policy violation', async () => { + await prisma.user.create({ + data: { + id: '1', + email: 'user1@abc.com', + posts: { create: { id: '1', title: 'post1', published: true } }, + }, + }); + + const handleRequest = makeHandler(); + + const r = await handleRequest({ + method: 'put', + path: '/post/update', + requestBody: { + where: { id: '1' }, + data: { title: 'post2' }, + }, + prisma: enhance(), + }); + expect(r.status).toBe(403); + expect(r.error.rejectedByPolicy).toBeTruthy(); + expect(r.error.reason).toBe(CrudFailureReason.ACCESS_POLICY_VIOLATION); + }); + it('validation error', async () => { let handleRequest = makeHandler(); @@ -366,18 +395,18 @@ describe('RPC API Handler Tests', () => { expect(r.status).toBe(200); expect(r.data).toBeNull(); }); -}); -function makeHandler(zodSchemas?: ZodSchemas) { - const _handler = RPCAPIHandler(); - return async (args: any) => { - const r = await _handler({ ...args, url: new URL(`http://localhost/${args.path}`), zodSchemas }); - return { - status: r.status, - body: r.body as any, - data: (r.body as any).data, - error: (r.body as any).error, - meta: (r.body as any).meta, + function makeHandler(zodSchemas?: ZodSchemas) { + const _handler = RPCAPIHandler(); + return async (args: any) => { + const r = await _handler({ ...args, url: new URL(`http://localhost/${args.path}`), modelMeta, zodSchemas }); + return { + status: r.status, + body: r.body as any, + data: (r.body as any).data, + error: (r.body as any).error, + meta: (r.body as any).meta, + }; }; - }; -} + } +}); diff --git a/packages/server/tests/utils.ts b/packages/server/tests/utils.ts index b46055b57..d1e0a0ffc 100644 --- a/packages/server/tests/utils.ts +++ b/packages/server/tests/utils.ts @@ -7,6 +7,9 @@ model User { updatedAt DateTime @updatedAt email String @unique posts Post[] + + @@allow('all', auth() == this) + @@allow('read', true) } model Post { @@ -18,6 +21,9 @@ model Post { authorId String? published Boolean @default(false) viewCount Int @default(0) + + @@allow('all', author == auth()) + @@allow('read', published) } `; diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 2672ba39c..1d28ed278 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "1.0.0-beta.21", + "version": "1.0.0-beta.22", "description": "ZenStack Test Tools", "main": "index.js", "publishConfig": { diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 6a6a80137..d230d6c77 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -98,10 +98,12 @@ export type SchemaLoadOptions = { extraDependencies?: string[]; compile?: boolean; customSchemaFilePath?: string; + output?: string; logPrismaQuery?: boolean; provider?: 'sqlite' | 'postgresql'; dbUrl?: string; pulseApiKey?: string; + getPrismaOnly?: boolean; }; const defaultOptions: SchemaLoadOptions = { @@ -177,12 +179,14 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) { run('npm install'); + const outputArg = opt.output ? ` --output ${opt.output}` : ''; + if (opt.customSchemaFilePath) { - run(`npx zenstack generate --schema ${zmodelPath} --no-dependency-check`, { + run(`npx zenstack generate --schema ${zmodelPath} --no-dependency-check${outputArg}`, { NODE_PATH: './node_modules', }); } else { - run('npx zenstack generate --no-dependency-check', { NODE_PATH: './node_modules' }); + run(`npx zenstack generate --no-dependency-check${outputArg}`, { NODE_PATH: './node_modules' }); } if (opt.pushDb) { @@ -220,6 +224,17 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) { run('npx tsc --project tsconfig.json'); } + if (options?.getPrismaOnly) { + return { + prisma, + projectDir: projectRoot, + withPolicy: undefined as any, + withOmit: undefined as any, + withPassword: undefined as any, + enhance: undefined as any, + }; + } + let policy: any; let modelMeta: any; let zodSchemas: any; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e590778..5379c7236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,7 +112,7 @@ importers: version: 8.35.0 jest: specifier: ^29.5.0 - version: 29.5.0(@types/node@18.0.0) + version: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) pluralize: specifier: ^8.0.0 version: 8.0.0 @@ -124,7 +124,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.5 - version: 29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) + version: 29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5) typescript: specifier: ^4.9.5 version: 4.9.5 @@ -189,7 +189,7 @@ importers: version: 2.4.1 jest: specifier: ^29.5.0 - version: 29.5.0(@types/node@18.0.0) + version: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) react: specifier: 18.2.0 version: 18.2.0 @@ -269,7 +269,7 @@ importers: version: 2.4.1 jest: specifier: ^29.5.0 - version: 29.5.0(@types/node@18.0.0) + version: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) react: specifier: 18.2.0 version: 18.2.0 @@ -343,7 +343,7 @@ importers: version: 2.4.1 jest: specifier: ^29.5.0 - version: 29.5.0(@types/node@18.0.0) + version: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) next: specifier: ^13.4.7 version: 13.4.7(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) @@ -488,6 +488,9 @@ importers: strip-color: specifier: ^0.1.0 version: 0.1.0 + tiny-invariant: + specifier: ^1.3.1 + version: 1.3.1 ts-morph: specifier: ^16.0.0 version: 16.0.0 @@ -593,7 +596,7 @@ importers: version: 0.2.1 ts-jest: specifier: ^29.0.3 - version: 29.0.3(@babel/core@7.22.9)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4) + version: 29.0.3(@babel/core@7.22.5)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4) ts-node: specifier: ^10.9.1 version: 10.9.1(@types/node@18.0.0)(typescript@4.8.4) @@ -614,8 +617,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 '@prisma/internals': - specifier: 4.10.0 - version: 4.10.0 + specifier: ^4.16.0 + version: 4.16.2 '@prisma/internals-v5': specifier: npm:@prisma/internals@^5.0.0 version: /@prisma/internals@5.0.0 @@ -703,6 +706,9 @@ importers: '@types/jest': specifier: ^29.5.0 version: 29.5.0 + '@types/node': + specifier: ^18.0.0 + version: 18.0.0 '@types/supertest': specifier: ^2.0.12 version: 2.0.12 @@ -732,7 +738,7 @@ importers: version: 3.0.0 jest: specifier: ^29.5.0 - version: 29.5.0(@types/node@18.0.0) + version: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) next: specifier: ^13.4.5 version: 13.4.5(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) @@ -2916,44 +2922,6 @@ packages: engines: {node: '>=8.0.0'} dev: false - /@opentelemetry/core@1.14.0(@opentelemetry/api@1.4.1): - resolution: {integrity: sha512-MnMZ+sxsnlzloeuXL2nm5QcNczt/iO82UOeQQDHhV83F2fP3sgntW2evvtoxJki0MBLxEsh5ADD7PR/Hn5uzjw==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.5.0' - dependencies: - '@opentelemetry/api': 1.4.1 - '@opentelemetry/semantic-conventions': 1.14.0 - dev: false - - /@opentelemetry/resources@1.14.0(@opentelemetry/api@1.4.1): - resolution: {integrity: sha512-qRfWIgBxxl3z47E036Aey0Lj2ZjlFb27Q7Xnj1y1z/P293RXJZGLtcfn/w8JF7v1Q2hs3SDGxz7Wb9Dko1YUQA==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.5.0' - dependencies: - '@opentelemetry/api': 1.4.1 - '@opentelemetry/core': 1.14.0(@opentelemetry/api@1.4.1) - '@opentelemetry/semantic-conventions': 1.14.0 - dev: false - - /@opentelemetry/sdk-trace-base@1.14.0(@opentelemetry/api@1.4.1): - resolution: {integrity: sha512-NzRGt3PS+HPKfQYMb6Iy8YYc5OKA73qDwci/6ujOIvyW9vcqBJSWbjZ8FeLEAmuatUB5WrRhEKu9b0sIiIYTrQ==} - engines: {node: '>=14'} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.5.0' - dependencies: - '@opentelemetry/api': 1.4.1 - '@opentelemetry/core': 1.14.0(@opentelemetry/api@1.4.1) - '@opentelemetry/resources': 1.14.0(@opentelemetry/api@1.4.1) - '@opentelemetry/semantic-conventions': 1.14.0 - dev: false - - /@opentelemetry/semantic-conventions@1.14.0: - resolution: {integrity: sha512-rJfCY8rCWz3cb4KI6pEofnytvMPuj3YLQwoscCCYZ5DkdiPjo15IQ0US7+mjcWy9H3fcZIzf2pbJZ7ck/h4tug==} - engines: {node: '>=14'} - dev: false - /@paralleldrive/cuid2@2.2.0: resolution: {integrity: sha512-CVQDpPIUHrUGGLdrMGz1NmqZvqmsB2j2rCIQEu1EvxWjlFh4fhvEGmgR409cY20/67/WlJsggenq0no3p3kYsw==} dependencies: @@ -2985,10 +2953,10 @@ packages: resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==} dev: true - /@prisma/debug@4.10.0: - resolution: {integrity: sha512-rxVOZKsEyjlQCwN/pkkJO7wEdARt1yRyukSjLa+BF2QTvy2+VgtBmrfys4WDQSnj3jVWeHMpi5GeAoJjKkSKyA==} + /@prisma/debug@4.16.2: + resolution: {integrity: sha512-7L7WbG0qNNZYgLpsVB8rCHCXEyHFyIycRlRDNwkVfjQmACC2OW6AWCYCbfdjQhkF/t7+S3njj8wAWAocSs+Brw==} dependencies: - '@types/debug': 4.1.7 + '@types/debug': 4.1.8 debug: 4.3.4 strip-ansi: 6.0.1 transitivePeerDependencies: @@ -3005,30 +2973,8 @@ packages: - supports-color dev: false - /@prisma/engine-core@4.10.0: - resolution: {integrity: sha512-uKkUXcpqGX4LbTwnvLsEBhl0gfcfSE5jYWQqastFqoO+lVIEiVZ9aoDrDDy7ZUhjtCLmejsgVb7mXmCzA3nhMg==} - dependencies: - '@opentelemetry/api': 1.4.1 - '@opentelemetry/sdk-trace-base': 1.14.0(@opentelemetry/api@1.4.1) - '@prisma/debug': 4.10.0 - '@prisma/engines': 4.10.0 - '@prisma/generator-helper': 4.10.0 - '@prisma/get-platform': 4.10.0 - chalk: 4.1.2 - execa: 5.1.1 - get-stream: 6.0.1 - indent-string: 4.0.0 - new-github-issue-url: 0.2.1 - p-retry: 4.6.2 - strip-ansi: 6.0.1 - ts-pattern: 4.1.3 - undici: 5.16.0 - transitivePeerDependencies: - - supports-color - dev: false - - /@prisma/engines@4.10.0: - resolution: {integrity: sha512-ZPPo7q+nQZdTlPFedS7mFXPE3oZ2kWtTh3GO4sku0XQ8ikLqEyinuTPJbQCw/8qel2xglIEQicsK6yI4Jgh20A==} + /@prisma/engines@4.16.2: + resolution: {integrity: sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==} requiresBuild: true dev: false @@ -3037,19 +2983,19 @@ packages: requiresBuild: true dev: false - /@prisma/fetch-engine@4.10.0: - resolution: {integrity: sha512-DZaBIstNiubIuZHX3ul2piltE98MkPHelsCKub9NWqprdgTAd/1TBJQT2CVuVP1V5d+0JT0Nd+FmQY+oCWb5rw==} + /@prisma/fetch-engine@4.16.2: + resolution: {integrity: sha512-lnCnHcOaNn0kw8qTJbVcNhyfIf5Lus2GFXbj3qpkdKEIB9xLgqkkuTP+35q1xFaqwQ0vy4HFpdRUpFP7njE15g==} dependencies: - '@prisma/debug': 4.10.0 - '@prisma/get-platform': 4.10.0 - chalk: 4.1.2 + '@prisma/debug': 4.16.2 + '@prisma/get-platform': 4.16.2 execa: 5.1.1 find-cache-dir: 3.3.2 - fs-extra: 11.1.0 + fs-extra: 11.1.1 hasha: 5.2.2 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - node-fetch: 2.6.9 + http-proxy-agent: 7.0.0 + https-proxy-agent: 7.0.0 + kleur: 4.1.5 + node-fetch: 2.6.11 p-filter: 2.1.0 p-map: 4.0.0 p-retry: 4.6.2 @@ -3087,13 +3033,13 @@ packages: - supports-color dev: false - /@prisma/generator-helper@4.10.0: - resolution: {integrity: sha512-NkQOfZpHUjVjqJ7NN2FymHSLkGd/E0fz5c3RkyESKvQqBy2sFBxt+aFxGsUbUy3FfwvkckC04HdQOXpisAko0A==} + /@prisma/generator-helper@4.16.2: + resolution: {integrity: sha512-bMOH7y73Ui7gpQrioFeavMQA+Tf8ksaVf8Nhs9rQNzuSg8SSV6E9baczob0L5KGZTSgYoqnrRxuo03kVJYrnIg==} dependencies: - '@prisma/debug': 4.10.0 + '@prisma/debug': 4.16.2 '@types/cross-spawn': 6.0.2 - chalk: 4.1.2 cross-spawn: 7.0.3 + kleur: 4.1.5 transitivePeerDependencies: - supports-color dev: false @@ -3109,19 +3055,19 @@ packages: - supports-color dev: false - /@prisma/get-platform@4.10.0: - resolution: {integrity: sha512-QBgHnMe1CtFoioyH9C9cniC4TENFlhQuxWWxJQwWw8fkxvz/WgDwxJ+Xu3Jk8JrfP5iUVzqv+7VbZVKfMObakg==} + /@prisma/get-platform@4.16.2: + resolution: {integrity: sha512-fnDey1/iSefHJRMB+w243BhWENf+paRouPMdCqIVqu8dYkR1NqhldblsSUC4Zr2sKS7Ta2sK4OLdt9IH+PZTfw==} dependencies: - '@prisma/debug': 4.10.0 - chalk: 4.1.2 + '@prisma/debug': 4.16.2 escape-string-regexp: 4.0.0 execa: 5.1.1 fs-jetpack: 5.1.0 + kleur: 4.1.5 replace-string: 3.1.0 strip-ansi: 6.0.1 tempy: 1.0.1 terminal-link: 2.1.1 - ts-pattern: 4.1.3 + ts-pattern: 4.3.0 transitivePeerDependencies: - supports-color dev: false @@ -3143,42 +3089,43 @@ packages: - supports-color dev: false - /@prisma/internals@4.10.0: - resolution: {integrity: sha512-Inex2YHZ0oaIxoGGjBX0N2w3TAVx7m7SXF0ru1kbvB05wqum1yyquBnsyca5A4DBANf3uSa3ZyQvx+Ksl4gIgw==} + /@prisma/internals@4.16.2: + resolution: {integrity: sha512-/3OiSADA3RRgsaeEE+MDsBgL6oAMwddSheXn6wtYGUnjERAV/BmF5bMMLnTykesQqwZ1s8HrISrJ0Vf6cjOxMg==} dependencies: - '@prisma/debug': 4.10.0 - '@prisma/engine-core': 4.10.0 - '@prisma/engines': 4.10.0 - '@prisma/fetch-engine': 4.10.0 - '@prisma/generator-helper': 4.10.0 - '@prisma/get-platform': 4.10.0 - '@prisma/prisma-fmt-wasm': 4.10.0-84.ca7fcef713137fa11029d519a9780db130cca91d + '@antfu/ni': 0.21.4 + '@opentelemetry/api': 1.4.1 + '@prisma/debug': 4.16.2 + '@prisma/engines': 4.16.2 + '@prisma/fetch-engine': 4.16.2 + '@prisma/generator-helper': 4.16.2 + '@prisma/get-platform': 4.16.2 + '@prisma/prisma-fmt-wasm': 4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81 archiver: 5.3.1 arg: 5.0.2 - chalk: 4.1.2 - checkpoint-client: 1.1.23 + checkpoint-client: 1.1.24 cli-truncate: 2.1.0 dotenv: 16.0.3 escape-string-regexp: 4.0.0 execa: 5.1.1 find-up: 5.0.0 - fp-ts: 2.13.1 - fs-extra: 11.1.0 + fp-ts: 2.16.0 + fs-extra: 11.1.1 fs-jetpack: 5.1.0 global-dirs: 3.0.1 globby: 11.1.0 - has-yarn: 2.1.0 + indent-string: 4.0.0 is-windows: 1.0.2 is-wsl: 2.2.0 + kleur: 4.1.5 new-github-issue-url: 0.2.1 - node-fetch: 2.6.9 + node-fetch: 2.6.11 + npm-packlist: 5.1.3 open: 7.4.2 - ora: 5.4.1 p-map: 4.0.0 prompts: 2.4.2 read-pkg-up: 7.0.1 replace-string: 3.1.0 - resolve: 1.22.1 + resolve: 1.22.2 string-width: 4.2.3 strip-ansi: 6.0.1 strip-indent: 3.0.0 @@ -3244,8 +3191,8 @@ packages: - supports-color dev: false - /@prisma/prisma-fmt-wasm@4.10.0-84.ca7fcef713137fa11029d519a9780db130cca91d: - resolution: {integrity: sha512-AkNNfsOnbfmGYFWAk6mFckuznyi4FvQDrK8NKQKC2DV068K//eAdr149upmypS90uOM3y74KS+b32ZyQwThzyA==} + /@prisma/prisma-fmt-wasm@4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81: + resolution: {integrity: sha512-g090+dEH7wrdCw359+8J9+TGH84qK28V/dxwINjhhNCtju9lej99z9w/AVsJP9UhhcCPS4psYz4iu8d53uxVpA==} dev: false /@prisma/prisma-schema-wasm@4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584: @@ -3332,7 +3279,7 @@ packages: sirv: 2.0.3 svelte: 4.0.5 undici: 5.22.1 - vite: 4.4.4 + vite: 4.4.4(@types/node@18.0.0) transitivePeerDependencies: - supports-color dev: true @@ -3348,7 +3295,7 @@ packages: '@sveltejs/vite-plugin-svelte': 2.4.2(svelte@4.0.5)(vite@4.4.4) debug: 4.3.4 svelte: 4.0.5 - vite: 4.4.4 + vite: 4.4.4(@types/node@18.0.0) transitivePeerDependencies: - supports-color dev: true @@ -3367,7 +3314,7 @@ packages: magic-string: 0.30.0 svelte: 4.0.5 svelte-hmr: 0.15.2(svelte@4.0.5) - vite: 4.4.4 + vite: 4.4.4(@types/node@18.0.0) vitefu: 0.2.4(vite@4.4.4) transitivePeerDependencies: - supports-color @@ -3443,11 +3390,6 @@ packages: svelte: 3.59.2 dev: true - /@tootallnate/once@2.0.0: - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} - engines: {node: '>= 10'} - dev: false - /@trpc/client@10.32.0(@trpc/server@10.32.0): resolution: {integrity: sha512-hrj6XV84nE6DH5AmsPJ0JzIRcx3f6zU0tvS705DnZtNBOoMk7Lx+wX+KCDxj4DxLsTXC+roAxZxWwAB6/LGsXA==} peerDependencies: @@ -3500,7 +3442,7 @@ packages: /@ts-morph/common@0.17.0: resolution: {integrity: sha512-RMSSvSfs9kb0VzkvQ2NWobwnj7TxCA9vI/IjR9bDHqgAyVbu2T0DN4wiKVqomyDWqO7dPr/tErSfq7urQ1Q37g==} dependencies: - fast-glob: 3.2.12 + fast-glob: 3.3.0 minimatch: 5.1.6 mkdirp: 1.0.4 path-browserify: 1.0.1 @@ -3595,12 +3537,6 @@ packages: '@types/node': 18.0.0 dev: false - /@types/debug@4.1.7: - resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} - dependencies: - '@types/ms': 0.7.31 - dev: false - /@types/debug@4.1.8: resolution: {integrity: sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==} dependencies: @@ -4807,6 +4743,7 @@ packages: engines: {node: '>=10.16.0'} dependencies: streamsearch: 1.1.0 + dev: true /bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -4930,20 +4867,6 @@ packages: resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} dev: true - /checkpoint-client@1.1.23: - resolution: {integrity: sha512-NrGvMiH1fUcZwrYc0Z+YTn7q9ysV0kPgVyDKZ5jrfIerFJuSllvyGsY7bHeQSEiljaIaUP1Q/xutZ8q1s7PGzg==} - dependencies: - ci-info: 3.3.0 - env-paths: 2.2.1 - fast-write-atomic: 0.2.1 - make-dir: 3.1.0 - ms: 2.1.3 - node-fetch: 2.6.7 - uuid: 8.3.2 - transitivePeerDependencies: - - encoding - dev: false - /checkpoint-client@1.1.24: resolution: {integrity: sha512-nIOlLhDS7MKs4tUzS3LCm+sE1NgTCVnVrXlD0RRxaoEkkLu8LIWSUNiNWai6a+LK5unLzTyZeTCYX1Smqy0YoA==} dependencies: @@ -5021,10 +4944,6 @@ packages: dev: true optional: true - /ci-info@3.3.0: - resolution: {integrity: sha512-riT/3vI5YpVH6/qomlDnJow6TBee2PBKSEpx3O32EGPYbWGIRsIlGRms3Sm74wYE1JMo8RnO04Hb12+v1J5ICw==} - dev: false - /ci-info@3.8.0: resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} engines: {node: '>=8'} @@ -6507,17 +6426,6 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true - /fast-glob@3.2.12: - resolution: {integrity: sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==} - engines: {node: '>=8.6.0'} - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.5 - dev: false - /fast-glob@3.3.0: resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==} engines: {node: '>=8.6.0'} @@ -6748,10 +6656,6 @@ packages: engines: {node: '>= 0.6'} dev: true - /fp-ts@2.13.1: - resolution: {integrity: sha512-0eu5ULPS2c/jsa1lGFneEFFEdTbembJv8e4QKXeVJ3lm/5hyve06dlKZrpxmMwJt6rYen7sxmHHK2CLaXvWuWQ==} - dev: false - /fp-ts@2.16.0: resolution: {integrity: sha512-bLq+KgbiXdTEoT1zcARrWEpa5z6A/8b7PcDW7Gef3NSisQ+VS7ll2Xbf1E+xsgik0rWub/8u0qP/iTTjj+PhxQ==} dev: false @@ -6779,6 +6683,7 @@ packages: graceful-fs: 4.2.11 jsonfile: 6.1.0 universalify: 2.0.0 + dev: true /fs-extra@11.1.1: resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} @@ -7057,11 +6962,6 @@ packages: has-symbols: 1.0.3 dev: true - /has-yarn@2.1.0: - resolution: {integrity: sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==} - engines: {node: '>=8'} - dev: false - /has@1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} @@ -7122,17 +7022,6 @@ packages: toidentifier: 1.0.1 dev: true - /http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - dependencies: - '@tootallnate/once': 2.0.0 - agent-base: 6.0.2 - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: false - /http-proxy-agent@7.0.0: resolution: {integrity: sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ==} engines: {node: '>= 14'} @@ -7153,16 +7042,6 @@ packages: - supports-color dev: false - /https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - dependencies: - agent-base: 6.0.2 - debug: 4.3.4 - transitivePeerDependencies: - - supports-color - dev: false - /https-proxy-agent@7.0.0: resolution: {integrity: sha512-0euwPCRyAPSgGdzD1IVN9nJYHtBhJwb6XPfbpQcYbPCwrBidX6GzxmchnaF4sfF/jPb74Ojx5g4yTg3sixlyPw==} engines: {node: '>= 14'} @@ -7581,34 +7460,6 @@ packages: - supports-color dev: true - /jest-cli@29.5.0(@types/node@18.0.0): - resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.5.0(ts-node@10.9.1) - '@jest/test-result': 29.5.0 - '@jest/types': 29.5.0 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - import-local: 3.1.0 - jest-config: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) - jest-util: 29.5.0 - jest-validate: 29.5.0 - prompts: 2.4.2 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - supports-color - - ts-node - dev: true - /jest-cli@29.5.0(@types/node@18.0.0)(ts-node@10.9.1): resolution: {integrity: sha512-L1KcP1l4HtfwdxXNFCL5bmUbLQiKrakMUriBEcc1Vfz6gx31ORKdreuWvmQVBit+1ss9NNR3yxjwfwzZNdQXJw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7972,26 +7823,6 @@ packages: supports-color: 8.1.1 dev: true - /jest@29.5.0(@types/node@18.0.0): - resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - dependencies: - '@jest/core': 29.5.0(ts-node@10.9.1) - '@jest/types': 29.5.0 - import-local: 3.1.0 - jest-cli: 29.5.0(@types/node@18.0.0) - transitivePeerDependencies: - - '@types/node' - - supports-color - - ts-node - dev: true - /jest@29.5.0(@types/node@18.0.0)(ts-node@10.9.1): resolution: {integrity: sha512-juMg3he2uru1QoXX078zTa7pO85QyB9xajZc6bU+d9yEGwrKX6+vGmJQ3UdVZsvTEUARIdObzH68QItim6OSSQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -8769,30 +8600,6 @@ packages: dependencies: whatwg-url: 5.0.0 - /node-fetch@2.6.7: - resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - dependencies: - whatwg-url: 5.0.0 - dev: false - - /node-fetch@2.6.9: - resolution: {integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==} - engines: {node: 4.x || >=6.0.0} - peerDependencies: - encoding: ^0.1.0 - peerDependenciesMeta: - encoding: - optional: true - dependencies: - whatwg-url: 5.0.0 - dev: false - /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true @@ -9830,15 +9637,6 @@ packages: engines: {node: '>=10'} dev: true - /resolve@1.22.1: - resolution: {integrity: sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==} - hasBin: true - dependencies: - is-core-module: 2.12.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - dev: false - /resolve@1.22.2: resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} hasBin: true @@ -10251,6 +10049,7 @@ packages: /streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + dev: true /string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} @@ -10765,7 +10564,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.3(@babel/core@7.22.9)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4): + /ts-jest@29.0.3(@babel/core@7.22.5)(esbuild@0.15.12)(jest@29.5.0)(typescript@4.8.4): resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -10786,7 +10585,7 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.22.9 + '@babel/core': 7.22.5 bs-logger: 0.2.6 esbuild: 0.15.12 fast-json-stable-stringify: 2.1.0 @@ -10800,7 +10599,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.5)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): + /ts-jest@29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -10821,21 +10620,21 @@ packages: esbuild: optional: true dependencies: - '@babel/core': 7.22.5 + '@babel/core': 7.22.9 bs-logger: 0.2.6 esbuild: 0.18.13 fast-json-stable-stringify: 2.1.0 - jest: 29.5.0(@types/node@18.0.0) + jest: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) jest-util: 29.5.0 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.5 + typescript: 4.9.4 yargs-parser: 21.1.1 dev: true - /ts-jest@29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.4): + /ts-jest@29.0.5(@babel/core@7.22.9)(esbuild@0.18.13)(jest@29.5.0)(typescript@4.9.5): resolution: {integrity: sha512-PL3UciSgIpQ7f6XjVOmbi96vmDHUqAyqDr8YxzopDqX3kfgYtX1cuNeBjP+L9sFXi6nzsGGA6R3fP3DDDJyrxA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} hasBin: true @@ -10860,13 +10659,13 @@ packages: bs-logger: 0.2.6 esbuild: 0.18.13 fast-json-stable-stringify: 2.1.0 - jest: 29.5.0(@types/node@18.0.0) + jest: 29.5.0(@types/node@18.0.0)(ts-node@10.9.1) jest-util: 29.5.0 json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 semver: 7.5.3 - typescript: 4.9.4 + typescript: 4.9.5 yargs-parser: 21.1.1 dev: true @@ -10939,10 +10738,6 @@ packages: yn: 3.1.1 dev: true - /ts-pattern@4.1.3: - resolution: {integrity: sha512-8beXMWTGEv1JfDjSxfNhe4uT5jKYdhmEUKzt4gZW9dmHlquq3b+IbEyA7vX9LjBfzHmvKnM4HiomAUCyaW2Pew==} - dev: false - /ts-pattern@4.3.0: resolution: {integrity: sha512-pefrkcd4lmIVR0LA49Imjf9DYLK8vtWhqBPA3Ya1ir8xCW0O2yjL9dsCVvI7pCodLC5q7smNpEtDR2yVulQxOg==} dev: false @@ -11187,13 +10982,6 @@ packages: resolution: {integrity: sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==} dev: true - /undici@5.16.0: - resolution: {integrity: sha512-KWBOXNv6VX+oJQhchXieUznEmnJMqgXMbs0xxH2t8q/FUAWSJvOSr/rMaZKnX5RIVq7JDn0JbP4BOnKG2SGXLQ==} - engines: {node: '>=12.18'} - dependencies: - busboy: 1.6.0 - dev: false - /undici@5.22.1: resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} engines: {node: '>=14.0'} @@ -11287,11 +11075,6 @@ packages: hasBin: true dev: false - /uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - dev: false - /uuid@9.0.0: resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} hasBin: true @@ -11374,7 +11157,7 @@ packages: fsevents: 2.3.2 dev: true - /vite@4.4.4: + /vite@4.4.4(@types/node@18.0.0): resolution: {integrity: sha512-4mvsTxjkveWrKDJI70QmelfVqTm+ihFAb6+xf4sjEU2TmUCTlVX87tmg/QooPEMQb/lM9qGHT99ebqPziEd3wg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -11402,6 +11185,7 @@ packages: terser: optional: true dependencies: + '@types/node': 18.0.0 esbuild: 0.18.14 postcss: 8.4.26 rollup: 3.26.3 @@ -11417,7 +11201,7 @@ packages: vite: optional: true dependencies: - vite: 4.4.4 + vite: 4.4.4(@types/node@18.0.0) dev: true /vitest@0.29.7: diff --git a/tests/integration/tests/cli/generate.test.ts b/tests/integration/tests/cli/generate.test.ts new file mode 100644 index 000000000..40a7981c8 --- /dev/null +++ b/tests/integration/tests/cli/generate.test.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/// + +import * as fs from 'fs'; +import path from 'path'; +import * as tmp from 'tmp'; +import { createProgram } from '../../../../packages/schema/src/cli'; +import { execSync } from '../../../../packages/schema/src/utils/exec-utils'; +import { createNpmrc } from './share'; + +describe('CLI generate command tests', () => { + let origDir: string; + const MODEL = ` +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +generator js { + provider = "prisma-client-js" +} + +model User { + id Int @id @default(autoincrement()) + email String @unique @email + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + author User @relation(fields: [authorId], references: [id]) + authorId Int +} + `; + + beforeEach(() => { + origDir = process.cwd(); + const r = tmp.dirSync({ unsafeCleanup: true }); + console.log(`Project dir: ${r.name}`); + process.chdir(r.name); + + // set up project + fs.writeFileSync('package.json', JSON.stringify({ name: 'my app', version: '1.0.0' })); + createNpmrc(); + execSync('npm install prisma @prisma/client zod'); + execSync(`npm install ${path.join(__dirname, '../../../../packages/runtime/dist')}`); + + // set up schema + fs.writeFileSync('schema.zmodel', MODEL, 'utf-8'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('generate standard', async () => { + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack/policy.js')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.js')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/zod/index.js')).toBeTruthy(); + }); + + it('generate custom output default', async () => { + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '-o', 'out'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack')).toBeFalsy(); + expect(fs.existsSync('./out/policy.js')).toBeTruthy(); + expect(fs.existsSync('./out/model-meta.js')).toBeTruthy(); + expect(fs.existsSync('./out/zod')).toBeTruthy(); + }); + + it('generate custom output non-std schema location', async () => { + fs.mkdirSync('./schema'); + fs.cpSync('schema.zmodel', './schema/my.zmodel'); + fs.rmSync('schema.zmodel'); + + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '--schema', './schema/my.zmodel', '-o', 'out'], { + from: 'user', + }); + expect(fs.existsSync('./node_modules/.zenstack')).toBeFalsy(); + expect(fs.existsSync('./out/policy.js')).toBeTruthy(); + expect(fs.existsSync('./out/model-meta.js')).toBeTruthy(); + expect(fs.existsSync('./out/zod')).toBeTruthy(); + }); + + it('generate custom output override', async () => { + fs.appendFileSync( + 'schema.zmodel', + ` + plugin policy { + provider = '@core/access-policy' + output = 'policy-out' + } + ` + ); + + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '-o', 'out'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack')).toBeFalsy(); + expect(fs.existsSync('./out/model-meta.js')).toBeTruthy(); + expect(fs.existsSync('./out/zod')).toBeTruthy(); + expect(fs.existsSync('./out/policy.js')).toBeFalsy(); + expect(fs.existsSync('./policy-out/policy.js')).toBeTruthy(); + }); + + it('generate no default plugins run nothing', async () => { + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '--no-default-plugins'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack/policy.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/zod')).toBeFalsy(); + expect(fs.existsSync('./prisma/schema.prisma')).toBeFalsy(); + }); + + it('generate no default plugins with prisma only', async () => { + fs.appendFileSync( + 'schema.zmodel', + ` + plugin prisma { + provider = '@core/prisma' + } + ` + ); + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '--no-default-plugins'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack/policy.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/zod')).toBeFalsy(); + expect(fs.existsSync('./prisma/schema.prisma')).toBeTruthy(); + }); + + it('generate no default plugins with access-policy with zod', async () => { + fs.appendFileSync( + 'schema.zmodel', + ` + plugin policy { + provider = '@core/access-policy' + } + ` + ); + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '--no-default-plugins'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack/policy.js')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.js')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/zod')).toBeTruthy(); + expect(fs.existsSync('./prisma/schema.prisma')).toBeTruthy(); + }); + + it('generate no default plugins with access-policy without zod', async () => { + fs.appendFileSync( + 'schema.zmodel', + ` + plugin policy { + provider = '@core/access-policy' + } + ` + ); + let content = fs.readFileSync('schema.zmodel', 'utf-8'); + content = content.replace('@email', ''); + fs.writeFileSync('schema.zmodel', content, 'utf-8'); + + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '--no-default-plugins'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack/policy.js')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.js')).toBeTruthy(); + expect(fs.existsSync('./prisma/schema.prisma')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/zod')).toBeFalsy(); + }); + + it('generate no compile', async () => { + const program = createProgram(); + await program.parseAsync(['generate', '--no-dependency-check', '--no-compile'], { from: 'user' }); + expect(fs.existsSync('./node_modules/.zenstack/policy.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/policy.ts')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/model-meta.ts')).toBeTruthy(); + expect(fs.existsSync('./node_modules/.zenstack/zod/index.js')).toBeFalsy(); + expect(fs.existsSync('./node_modules/.zenstack/zod/index.ts')).toBeTruthy(); + }); +}); diff --git a/tests/integration/tests/cli/command.test.ts b/tests/integration/tests/cli/init.test.ts similarity index 91% rename from tests/integration/tests/cli/command.test.ts rename to tests/integration/tests/cli/init.test.ts index 47671a69d..96492b286 100644 --- a/tests/integration/tests/cli/command.test.ts +++ b/tests/integration/tests/cli/init.test.ts @@ -7,8 +7,9 @@ import * as path from 'path'; import * as tmp from 'tmp'; import { createProgram } from '../../../../packages/schema/src/cli'; import { execSync } from '../../../../packages/schema/src/utils/exec-utils'; +import { createNpmrc } from './share'; -describe('CLI Command Tests', () => { +describe('CLI init command tests', () => { let origDir: string; beforeEach(() => { @@ -22,10 +23,6 @@ describe('CLI Command Tests', () => { process.chdir(origDir); }); - function createNpmrc() { - fs.writeFileSync('.npmrc', `cache=${getWorkspaceNpmCacheFolder(__dirname)}`); - } - it('init project t3 npm std', async () => { execSync('npx --yes create-t3-app@latest --prisma --CI --noGit .', 'inherit', { npm_config_user_agent: 'npm', @@ -98,6 +95,14 @@ describe('CLI Command Tests', () => { expect(fs.readFileSync('schema.zmodel', 'utf-8')).toBeTruthy(); }); + it('init project no version check', async () => { + fs.writeFileSync('package.json', JSON.stringify({ name: 'my app', version: '1.0.0' })); + createNpmrc(); + const program = createProgram(); + await program.parseAsync(['init', '--tag', 'latest', '--no-version-check'], { from: 'user' }); + expect(fs.readFileSync('schema.zmodel', 'utf-8')).toBeTruthy(); + }); + it('init project existing zmodel', async () => { fs.writeFileSync('package.json', JSON.stringify({ name: 'my app', version: '1.0.0' })); const origZModelContent = ` diff --git a/tests/integration/tests/cli/share.ts b/tests/integration/tests/cli/share.ts new file mode 100644 index 000000000..7d4f8805d --- /dev/null +++ b/tests/integration/tests/cli/share.ts @@ -0,0 +1,6 @@ +import { getWorkspaceNpmCacheFolder } from '@zenstackhq/testtools'; +import fs from 'fs'; + +export function createNpmrc() { + fs.writeFileSync('.npmrc', `cache=${getWorkspaceNpmCacheFolder(__dirname)}`); +} diff --git a/tests/integration/tests/enhancements/with-omit/with-omit.test.ts b/tests/integration/tests/enhancements/with-omit/with-omit.test.ts index 03e39142e..61d44b440 100644 --- a/tests/integration/tests/enhancements/with-omit/with-omit.test.ts +++ b/tests/integration/tests/enhancements/with-omit/with-omit.test.ts @@ -1,3 +1,4 @@ +import { withOmit } from '@zenstackhq/runtime'; import { loadSchema } from '@zenstackhq/testtools'; import path from 'path'; @@ -12,26 +13,27 @@ describe('Omit test', () => { process.chdir(origDir); }); + const model = ` + model User { + id String @id @default(cuid()) + password String @omit + profile Profile? + + @@allow('all', true) + } + + model Profile { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String @unique + image String @omit + + @@allow('all', true) + } + `; + it('omit tests', async () => { - const { withOmit } = await loadSchema( - ` - model User { - id String @id @default(cuid()) - password String @omit - profile Profile? - - @@allow('all', true) - } - - model Profile { - id String @id @default(cuid()) - user User @relation(fields: [userId], references: [id]) - userId String @unique - image String @omit - - @@allow('all', true) - } ` - ); + const { withOmit } = await loadSchema(model); const db = withOmit(); const r = await db.user.create({ @@ -75,4 +77,32 @@ describe('Omit test', () => { expect(e.profile.image).toBeUndefined(); }); }); + + it('customization', async () => { + const { prisma } = await loadSchema(model, { getPrismaOnly: true, output: './zen' }); + + const db = withOmit(prisma, { loadPath: './zen' }); + const r = await db.user.create({ + include: { profile: true }, + data: { + id: '1', + password: 'abc123', + profile: { create: { image: 'an image' } }, + }, + }); + expect(r.password).toBeUndefined(); + expect(r.profile.image).toBeUndefined(); + + const db1 = withOmit(prisma, { modelMeta: require(path.resolve('./zen/model-meta')).default }); + const r1 = await db1.user.create({ + include: { profile: true }, + data: { + id: '2', + password: 'abc123', + profile: { create: { image: 'an image' } }, + }, + }); + expect(r1.password).toBeUndefined(); + expect(r1.profile.image).toBeUndefined(); + }); }); diff --git a/tests/integration/tests/enhancements/with-password/with-password.test.ts b/tests/integration/tests/enhancements/with-password/with-password.test.ts index f0502e5e4..62e30636b 100644 --- a/tests/integration/tests/enhancements/with-password/with-password.test.ts +++ b/tests/integration/tests/enhancements/with-password/with-password.test.ts @@ -1,3 +1,4 @@ +import { withPassword } from '@zenstackhq/runtime'; import { loadSchema } from '@zenstackhq/testtools'; import { compareSync } from 'bcryptjs'; import path from 'path'; @@ -13,16 +14,16 @@ describe('Password test', () => { process.chdir(origDir); }); + const model = ` + model User { + id String @id @default(cuid()) + password String @password(saltLength: 16) + + @@allow('all', true) + }`; + it('password tests', async () => { - const { withPassword } = await loadSchema( - ` - model User { - id String @id @default(cuid()) - password String @password(saltLength: 16) - - @@allow('all', true) - }` - ); + const { withPassword } = await loadSchema(model); const db = withPassword(); const r = await db.user.create({ @@ -41,4 +42,26 @@ describe('Password test', () => { }); expect(compareSync('abc456', r1.password)).toBeTruthy(); }); + + it('customization', async () => { + const { prisma } = await loadSchema(model, { getPrismaOnly: true, output: './zen' }); + + const db = withPassword(prisma, { loadPath: './zen' }); + const r = await db.user.create({ + data: { + id: '1', + password: 'abc123', + }, + }); + expect(compareSync('abc123', r.password)).toBeTruthy(); + + const db1 = withPassword(prisma, { modelMeta: require(path.resolve('./zen/model-meta')).default }); + const r1 = await db1.user.create({ + data: { + id: '2', + password: 'abc123', + }, + }); + expect(compareSync('abc123', r1.password)).toBeTruthy(); + }); }); diff --git a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts index f0f57ab25..c6cebed39 100644 --- a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts +++ b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts @@ -1,3 +1,4 @@ +import { CrudFailureReason, isPrismaClientKnownRequestError } from '@zenstackhq/runtime'; import { FullDbClientContract, loadSchema, run } from '@zenstackhq/testtools'; describe('With Policy: field validation', () => { @@ -66,6 +67,28 @@ describe('With Policy: field validation', () => { }) ).toBeRejectedByPolicy(['String must contain at least 8 character(s) at "password"', 'Invalid at "handle"']); + let err: any; + try { + await db.user.create({ + data: { + id: '1', + password: 'abc123', + handle: 'hello world', + }, + }); + } catch (_err) { + err = _err; + } + + expect(isPrismaClientKnownRequestError(err)).toBeTruthy(); + expect(err).toMatchObject({ + code: 'P2004', + meta: { + reason: CrudFailureReason.DATA_VALIDATION_VIOLATION, + }, + }); + expect(err.meta.zodErrors).toBeTruthy(); + await expect( db.user.create({ data: { diff --git a/tests/integration/tests/enhancements/with-policy/options.test.ts b/tests/integration/tests/enhancements/with-policy/options.test.ts new file mode 100644 index 000000000..2c661ceb4 --- /dev/null +++ b/tests/integration/tests/enhancements/with-policy/options.test.ts @@ -0,0 +1,58 @@ +import { withPolicy } from '@zenstackhq/runtime'; +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('Password test', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(async () => { + process.chdir(origDir); + }); + + it('load path', async () => { + const { prisma } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + x Int + + @@allow('create', x > 0) + }`, + { getPrismaOnly: true, output: './zen' } + ); + + const db = withPolicy(prisma, undefined, { loadPath: './zen' }); + await expect( + db.foo.create({ + data: { x: 0 }, + }) + ).toBeRejectedByPolicy(); + }); + + it('overrides', async () => { + const { prisma } = await loadSchema( + ` + model Foo { + id String @id @default(cuid()) + x Int + + @@allow('create', x > 0) + }`, + { getPrismaOnly: true, output: './zen' } + ); + + const db = withPolicy(prisma, undefined, { + modelMeta: require(path.resolve('./zen/model-meta')).default, + policy: require(path.resolve('./zen/policy')).default, + }); + await expect( + db.foo.create({ + data: { x: 0 }, + }) + ).toBeRejectedByPolicy(); + }); +}); diff --git a/tests/integration/tests/enhancements/with-policy/query-reduction.test.ts b/tests/integration/tests/enhancements/with-policy/query-reduction.test.ts new file mode 100644 index 000000000..1654fba96 --- /dev/null +++ b/tests/integration/tests/enhancements/with-policy/query-reduction.test.ts @@ -0,0 +1,147 @@ +import { loadSchema } from '@zenstackhq/testtools'; +import path from 'path'; + +describe('With Policy: query reduction', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + }); + + afterEach(() => { + process.chdir(origDir); + }); + + it('test query reduction', async () => { + const { prisma, withPolicy } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + role String @default("User") + posts Post[] + private Boolean @default(false) + age Int + + @@allow('all', auth() == this) + @@allow('read', !private) + } + + model Post { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + title String + published Boolean @default(false) + viewCount Int @default(0) + + @@allow('all', auth() == user) + @@allow('read', published) + } + ` + ); + + await prisma.user.create({ + data: { + id: 1, + role: 'User', + age: 18, + posts: { + create: [ + { id: 1, title: 'Post 1' }, + { id: 2, title: 'Post 2', published: true }, + ], + }, + }, + }); + await prisma.user.create({ + data: { + id: 2, + role: 'Admin', + age: 28, + private: true, + posts: { + create: [{ id: 3, title: 'Post 3', viewCount: 100 }], + }, + }, + }); + + const dbUser1 = withPolicy({ id: 1 }); + const dbUser2 = withPolicy({ id: 2 }); + + await expect( + dbUser1.user.findMany({ + where: { id: 2, AND: { age: { gt: 20 } } }, + }) + ).resolves.toHaveLength(0); + + await expect( + dbUser2.user.findMany({ + where: { id: 2, AND: { age: { gt: 20 } } }, + }) + ).resolves.toHaveLength(1); + + await expect( + dbUser1.user.findMany({ + where: { + AND: { age: { gt: 10 } }, + OR: [{ age: { gt: 25 } }, { age: { lt: 20 } }], + NOT: { private: true }, + }, + }) + ).resolves.toHaveLength(1); + + await expect( + dbUser2.user.findMany({ + where: { + AND: { age: { gt: 10 } }, + OR: [{ age: { gt: 25 } }, { age: { lt: 20 } }], + NOT: { private: true }, + }, + }) + ).resolves.toHaveLength(1); + + // to-many relation query + await expect( + dbUser1.user.findMany({ + where: { posts: { some: { published: true } } }, + }) + ).resolves.toHaveLength(1); + await expect( + dbUser1.user.findMany({ + where: { posts: { some: { AND: [{ published: true }, { viewCount: { gt: 0 } }] } } }, + }) + ).resolves.toHaveLength(0); + await expect( + dbUser2.user.findMany({ + where: { posts: { some: { AND: [{ published: false }, { viewCount: { gt: 0 } }] } } }, + }) + ).resolves.toHaveLength(1); + await expect( + dbUser1.user.findMany({ + where: { posts: { every: { published: true } } }, + }) + ).resolves.toHaveLength(0); + await expect( + dbUser1.user.findMany({ + where: { posts: { none: { published: true } } }, + }) + ).resolves.toHaveLength(0); + + // to-one relation query + await expect( + dbUser1.post.findMany({ + where: { user: { role: 'Admin' } }, + }) + ).resolves.toHaveLength(0); + await expect( + dbUser1.post.findMany({ + where: { user: { is: { role: 'Admin' } } }, + }) + ).resolves.toHaveLength(0); + await expect( + dbUser1.post.findMany({ + where: { user: { isNot: { role: 'User' } } }, + }) + ).resolves.toHaveLength(0); + }); +}); diff --git a/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts b/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts index e77b27792..7c26bc854 100644 --- a/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts +++ b/tests/integration/tests/enhancements/with-policy/relation-one-to-one-filter.test.ts @@ -206,7 +206,7 @@ describe('With Policy: relation one-to-one filter', () => { }, }, }) - ).toResolveTruthy(); + ).toResolveFalsy(); // m1 with m2 and m3 await db.m1.create({ @@ -257,7 +257,7 @@ describe('With Policy: relation one-to-one filter', () => { }, }, }) - ).toResolveTruthy(); + ).toResolveFalsy(); }); it('direct object filter', async () => { diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index 40d0cded7..760ef0222 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -15,7 +15,8 @@ describe('Zod plugin tests', () => { }); it('basic generation', async () => { - const model = ` + const { zodSchemas } = await loadSchema( + ` datasource db { provider = 'postgresql' url = env('DATABASE_URL') @@ -24,16 +25,16 @@ describe('Zod plugin tests', () => { generator js { provider = 'prisma-client-js' } - + plugin zod { provider = "@core/zod" } - + enum Role { USER ADMIN } - + model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -54,9 +55,9 @@ describe('Zod plugin tests', () => { published Boolean @default(false) viewCount Int @default(0) } - `; - - const { zodSchemas } = await loadSchema(model, { addPrelude: false, pushDb: false }); + `, + { addPrelude: false, pushDb: false } + ); const schemas = zodSchemas.models; expect(schemas.UserSchema).toBeTruthy(); expect(schemas.UserCreateSchema).toBeTruthy(); @@ -74,12 +75,25 @@ describe('Zod plugin tests', () => { schemas.UserCreateSchema.safeParse({ email: 'abc@zenstack.dev', role: 'ADMIN', password: 'abc123' }).success ).toBeTruthy(); + // create unchecked + // create unchecked + expect( + zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123' }, + }).success + ).toBeTruthy(); + // update expect(schemas.UserUpdateSchema.safeParse({}).success).toBeTruthy(); expect(schemas.UserUpdateSchema.safeParse({ email: 'abc@def.com' }).success).toBeFalsy(); expect(schemas.UserUpdateSchema.safeParse({ email: 'def@zenstack.dev' }).success).toBeTruthy(); expect(schemas.UserUpdateSchema.safeParse({ password: 'password456' }).success).toBeTruthy(); + // update unchecked + expect( + zodSchemas.input.UserInputSchema.update.safeParse({ where: { id: 1 }, data: { id: 2 } }).success + ).toBeTruthy(); + // model schema expect(schemas.UserSchema.safeParse({ email: 'abc@zenstack.dev', role: 'ADMIN' }).success).toBeFalsy(); // without omitted field @@ -413,4 +427,64 @@ describe('Zod plugin tests', () => { await loadSchema(model, { addPrelude: false, pushDb: false }); }); + + it('no unchecked input', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = "@core/zod" + noUncheckedInput = true + } + + enum Role { + USER + ADMIN + } + + model User { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + email String @unique @email @endsWith('@zenstack.dev') + password String @omit + role Role @default(USER) + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String @length(5, 10) + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + published Boolean @default(false) + viewCount Int @default(0) + } + `, + { addPrelude: false, pushDb: false } + ); + const schemas = zodSchemas.models; + + // create unchecked + expect( + zodSchemas.input.UserInputSchema.create.safeParse({ + data: { id: 1, email: 'abc@zenstack.dev', password: 'abc123' }, + }).success + ).toBeFalsy(); + + // update unchecked + expect( + zodSchemas.input.UserInputSchema.update.safeParse({ where: { id: 1 }, data: { id: 2 } }).success + ).toBeFalsy(); + }); }); diff --git a/tests/integration/tests/regression/issue-674.test.ts b/tests/integration/tests/regression/issue-674.test.ts new file mode 100644 index 000000000..db8be80d7 --- /dev/null +++ b/tests/integration/tests/regression/issue-674.test.ts @@ -0,0 +1,16 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('Regression: issue 674', () => { + it('regression', async () => { + await loadSchema( + ` +model Foo { + id Int @id +} + +enum MyUnUsedEnum { ABC CDE @@map('my_unused_enum') } + `, + { provider: 'postgresql', dbUrl: 'env("DATABASE_URL")', pushDb: false } + ); + }); +}); diff --git a/tests/integration/tests/regression/issue-689.test.ts b/tests/integration/tests/regression/issue-689.test.ts new file mode 100644 index 000000000..32687abca --- /dev/null +++ b/tests/integration/tests/regression/issue-689.test.ts @@ -0,0 +1,71 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('Regression: issue 689', () => { + it('regression', async () => { + const { prisma, enhance } = await loadSchema( + ` + 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('all', auth() == this) + @@allow('all', userRole?[user == auth() && 'Admin' == role]) + @@allow('read', userRole?[user == auth()]) + } + ` + ); + + await prisma.user.create({ + data: { + id: 1, + userRole: { + create: [ + { id: 1, role: 'Admin' }, + { id: 2, role: 'Student' }, + ], + }, + }, + }); + + await prisma.user.create({ + data: { + id: 2, + userRole: { + connect: { id: 1 }, + }, + }, + }); + + const c1 = await prisma.user.count({ + where: { + userRole: { + some: { role: 'Student' }, + }, + NOT: { deleted: true }, + }, + }); + + const db = enhance(); + const c2 = await db.user.count({ + where: { + userRole: { + some: { role: 'Student' }, + }, + NOT: { deleted: true }, + }, + }); + + expect(c1).toEqual(c2); + }); +});