diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 000000000..2ef4532b9 --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,34 @@ +# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs + +name: Build Test CI + +on: + push: + branches: ['dev', 'main'] + pull_request: + branches: ['dev', 'main'] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [16.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v3 + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: ^7.15.0 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - run: pnpm run test diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml deleted file mode 100644 index 81d4bf3ef..000000000 --- a/.github/workflows/node.js.yml +++ /dev/null @@ -1,35 +0,0 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs - -name: Node.js CI - -on: - push: - branches: [ "dev", "main" ] - pull_request: - branches: [ "dev", "main" ] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [16.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v3 - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: ^7.15.0 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - run: pnpm run build - - run: pnpm run test diff --git a/README.md b/README.md index 2712254f4..f85a92c39 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - + diff --git a/package.json b/package.json index 8198f1674..2ce698bd2 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "zenstack-monorepo", - "version": "0.3.2", + "version": "0.3.4", "description": "", "scripts": { "build": "pnpm -r build", - "test": "pnpm -r test", + "test": "pnpm -r run test --silent", "lint": "pnpm -r lint", "publish-all": "pnpm --filter \"./packages/**\" -r publish" }, diff --git a/packages/internal/package.json b/packages/internal/package.json index 329d2b0ed..0ff3d9828 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/internal", - "version": "0.3.2", + "version": "0.3.4", "displayName": "ZenStack Internal Library", "description": "ZenStack internal runtime library. This package is for supporting runtime functionality of ZenStack and not supposed to be used directly.", "repository": { @@ -30,7 +30,9 @@ "cuid": "^2.1.8", "decimal.js": "^10.4.2", "deepcopy": "^2.1.0", - "swr": "^1.3.0" + "swr": "^1.3.0", + "zod": "^3.19.1", + "zod-validation-error": "^0.2.1" }, "peerDependencies": { "@prisma/client": "^4.4.0", diff --git a/packages/internal/src/client.ts b/packages/internal/src/client.ts index 18bd915ce..249754104 100644 --- a/packages/internal/src/client.ts +++ b/packages/internal/src/client.ts @@ -1,2 +1,3 @@ export { ServerErrorCode } from './types'; export * as request from './request'; +export * from './validation'; diff --git a/packages/internal/src/handler/data/handler.ts b/packages/internal/src/handler/data/handler.ts index 09dbc85c2..fcf42dd03 100644 --- a/packages/internal/src/handler/data/handler.ts +++ b/packages/internal/src/handler/data/handler.ts @@ -11,6 +11,7 @@ import { ServerErrorCode, Service, } from '../../types'; +import { ValidationError } from '../../validation'; import { RequestHandler, RequestHandlerError } from '../types'; import { and, @@ -131,6 +132,14 @@ export default class DataHandler ServerErrorCode.INVALID_REQUEST_PARAMS ), }); + } else if (err instanceof ValidationError) { + this.service.warn( + `Field constraint validation error for model "${model}": ${err.message}` + ); + res.status(400).send({ + code: ServerErrorCode.INVALID_REQUEST_PARAMS, + message: err.message, + }); } else { // generic errors this.service.error( @@ -140,7 +149,7 @@ export default class DataHandler this.service.error(err.stack); } res.status(500).send({ - error: ServerErrorCode.UNKNOWN, + code: ServerErrorCode.UNKNOWN, message: getServerErrorMessage(ServerErrorCode.UNKNOWN), }); } @@ -207,6 +216,8 @@ export default class DataHandler ); } + await this.service.validateModelPayload(model, 'create', args.data); + // preprocess payload to modify fields as required by attribute like @password await preprocessWritePayload(model, args, this.service); @@ -322,6 +333,8 @@ export default class DataHandler ); } + await this.service.validateModelPayload(model, 'update', args.data); + // preprocess payload to modify fields as required by attribute like @password await preprocessWritePayload(model, args, this.service); diff --git a/packages/internal/src/index.ts b/packages/internal/src/index.ts index 27aef584c..3eb8ed7b8 100644 --- a/packages/internal/src/index.ts +++ b/packages/internal/src/index.ts @@ -2,4 +2,4 @@ export * from './types'; export * from './config'; export * from './service'; export * from './request-handler'; -export * as request from './request'; +export * from './validation'; diff --git a/packages/internal/src/service.ts b/packages/internal/src/service.ts index 994075106..73f5c1fbb 100644 --- a/packages/internal/src/service.ts +++ b/packages/internal/src/service.ts @@ -10,6 +10,8 @@ import { Service, } from './types'; import colors from 'colors'; +import { validate } from './validation'; +import { z } from 'zod'; export abstract class DefaultService< DbClient extends { @@ -31,6 +33,9 @@ export abstract class DefaultService< // eslint-disable-next-line @typescript-eslint/no-explicit-any private guardModule: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private fieldConstraintModule: any; + private readonly prismaLogLevels: LogLevel[] = [ 'query', 'info', @@ -155,6 +160,22 @@ export abstract class DefaultService< return provider(context); } + async validateModelPayload( + model: string, + mode: 'create' | 'update', + payload: unknown + ) { + if (!this.fieldConstraintModule) { + this.fieldConstraintModule = await this.loadFieldConstraintModule(); + } + const validator = this.fieldConstraintModule[ + `${model}_${mode}_validator` + ] as z.ZodType; + if (validator) { + validate(validator, payload); + } + } + verbose(message: string): void { this.handleLog('verbose', message); } @@ -179,4 +200,7 @@ export abstract class DefaultService< // eslint-disable-next-line @typescript-eslint/no-explicit-any protected abstract loadGuardModule(): Promise; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected abstract loadFieldConstraintModule(): Promise; } diff --git a/packages/internal/src/types.ts b/packages/internal/src/types.ts index 214ab49b6..53d798a97 100644 --- a/packages/internal/src/types.ts +++ b/packages/internal/src/types.ts @@ -99,6 +99,12 @@ export interface Service { context: QueryContext ): Promise; + validateModelPayload( + model: string, + mode: 'create' | 'update', + payload: unknown + ): Promise; + /** * Generates a log message with verbose level. */ diff --git a/packages/internal/src/validation.ts b/packages/internal/src/validation.ts new file mode 100644 index 000000000..ed0ddbfb7 --- /dev/null +++ b/packages/internal/src/validation.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; + +/** + * Error indicating violations of field-level constraints + */ +export class ValidationError { + constructor(public readonly message: string) {} +} + +/** + * Validate the given data with the given zod schema (for field-level constraints) + */ +export function validate(validator: z.ZodType, data: unknown) { + try { + validator.parse(data); + } catch (err) { + throw new ValidationError(fromZodError(err as z.ZodError).message); + } +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index cd27827a8..2d867a6dc 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "0.3.2", + "version": "0.3.4", "description": "This package contains runtime library for consuming client and server side code generated by ZenStack.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index d23cb7944..c56611dd5 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 modeling data and access policies in full-stack development with Next.js and Typescript", - "version": "0.3.2", + "version": "0.3.4", "author": { "name": "ZenStack Team" }, @@ -70,7 +70,7 @@ "vscode:package": "vsce package --no-dependencies", "clean": "rimraf bundle", "build": "pnpm langium:generate && tsc --noEmit && pnpm bundle && cp -r src/res/* bundle/res/", - "bundle": "npm run clean && node build/bundle.js --minify", + "bundle": "npm run clean && node build/bundle.js", "bundle-watch": "node build/bundle.js --watch", "ts:watch": "tsc --watch --noEmit", "tsc-alias:watch": "tsc-alias --watch", diff --git a/packages/schema/src/generator/utils.ts b/packages/schema/src/generator/ast-utils.ts similarity index 100% rename from packages/schema/src/generator/utils.ts rename to packages/schema/src/generator/ast-utils.ts diff --git a/packages/schema/src/generator/field-constraint/index.ts b/packages/schema/src/generator/field-constraint/index.ts new file mode 100644 index 000000000..c3271d8d0 --- /dev/null +++ b/packages/schema/src/generator/field-constraint/index.ts @@ -0,0 +1,297 @@ +import { Context, Generator } from '../types'; +import { Project, SourceFile } from 'ts-morph'; +import * as path from 'path'; +import colors from 'colors'; +import { + DataModel, + DataModelField, + DataModelFieldAttribute, + isDataModel, + isLiteralExpr, + LiteralExpr, +} from '@lang/generated/ast'; + +/** + * Generates field constraint validators (run on both client and server side) + */ +export default class FieldConstraintGenerator implements Generator { + get name() { + return 'field-constraint'; + } + + async generate(context: Context): Promise { + const project = new Project(); + const sf = project.createSourceFile( + path.join( + context.generatedCodeDir, + 'src/field-constraint/index.ts' + ), + undefined, + { overwrite: true } + ); + + sf.addStatements([`import { z } from "zod";`]); + + context.schema.declarations + .filter((d): d is DataModel => isDataModel(d)) + .forEach((model) => { + this.generateConstraints(sf, model); + }); + + sf.formatText(); + await project.save(); + + console.log(colors.blue(` ✔️ Field constraint validators generated`)); + } + + private generateConstraints(sf: SourceFile, model: DataModel) { + sf.addStatements(` + export const ${this.validator( + model.name, + 'create' + )}: z.ZodType = z.lazy(() => z.object({ + ${model.fields + .map((f) => ({ + field: f, + schema: this.makeFieldValidator(f, 'create'), + })) + .filter(({ schema }) => !!schema) + .map(({ field, schema }) => field.name + ': ' + schema) + .join(',\n')} + })); + + export const ${this.validator( + model.name, + 'update' + )}: z.ZodType = z.lazy(() => z.object({ + ${model.fields + .map((f) => ({ + field: f, + schema: this.makeFieldValidator(f, 'update'), + })) + .filter(({ schema }) => !!schema) + .map(({ field, schema }) => field.name + ': ' + schema) + .join(',\n')} + }).partial()); + `); + } + + private makeFieldValidator( + field: DataModelField, + mode: 'create' | 'update' + ) { + const baseSchema = this.makeZodSchema(field, mode); + let zodSchema = baseSchema; + + // translate field constraint attributes to zod schema + for (const attr of field.attributes) { + switch (attr.decl.ref?.name) { + case '@length': { + const min = this.getAttrLiteralArg(attr, 'min'); + if (min) { + zodSchema += `.min(${min})`; + } + const max = this.getAttrLiteralArg(attr, 'max'); + if (max) { + zodSchema += `.max(${max})`; + } + break; + } + case '@regex': { + const expr = this.getAttrLiteralArg(attr, 'regex'); + if (expr) { + zodSchema += `.regex(/${expr}/)`; + } + break; + } + case '@startsWith': { + const text = this.getAttrLiteralArg(attr, 'text'); + if (text) { + zodSchema += `.startsWith(${JSON.stringify(text)})`; + } + break; + } + case '@endsWith': { + const text = this.getAttrLiteralArg(attr, 'text'); + if (text) { + zodSchema += `.endsWith(${JSON.stringify(text)})`; + } + break; + } + case '@email': { + zodSchema += `.email()`; + break; + } + case '@url': { + zodSchema += `.url()`; + break; + } + case '@datetime': { + zodSchema += `.datetime({ offset: true })`; + break; + } + case '@gt': { + const value = this.getAttrLiteralArg(attr, 'value'); + if (value !== undefined) { + zodSchema += `.gt(${value})`; + } + break; + } + case '@gte': { + const value = this.getAttrLiteralArg(attr, 'value'); + if (value !== undefined) { + zodSchema += `.gte(${value})`; + } + break; + } + case '@lt': { + const value = this.getAttrLiteralArg(attr, 'value'); + if (value !== undefined) { + zodSchema += `.lt(${value})`; + } + break; + } + case '@lte': { + const value = this.getAttrLiteralArg(attr, 'value'); + if (value !== undefined) { + zodSchema += `.lte(${value})`; + } + break; + } + } + } + + if ( + !isDataModel(field.type.reference?.ref) && + zodSchema === baseSchema + ) { + // empty schema, skip + return undefined; + } + + if (field.type.optional) { + zodSchema = this.optional(zodSchema); + } + + return zodSchema; + } + + private getAttrLiteralArg( + attr: DataModelFieldAttribute, + paramName: string + ) { + const arg = attr.args.find( + (arg) => arg.$resolvedParam?.name === paramName + ); + if (!arg || !isLiteralExpr(arg.value)) { + return undefined; + } + return (arg.value as LiteralExpr).value as T; + } + + private makeZodSchema(field: DataModelField, mode: 'create' | 'update') { + const type = field.type; + let schema = ''; + if (type.reference && isDataModel(type.reference.ref)) { + const modelType = type.reference.ref.name; + const create = this.validator(modelType, 'create'); + const update = this.validator(modelType, 'update'); + + // list all possible action fields in write playload: + // create/createMany/connectOrCreate/update/updateMany/upsert + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fields: any = { + create: this.optional(this.enumerable(create)), + createMany: this.optional(this.enumerable(create)), + connectOrCreate: this.optional( + this.enumerable(this.object({ create })) + ), + }; + + if (mode === 'update') { + fields = { + ...fields, + update: this.optional( + this.enumerable( + type.array ? this.object({ data: update }) : update + ) + ), + updateMany: this.optional( + this.enumerable(this.object({ data: update })) + ), + upsert: this.optional( + type.array + ? this.enumerable( + this.object({ + create, + update, + }) + ) + : this.object({ + create, + update, + }) + ), + }; + } + + schema = this.optional(this.object(fields)); + } else { + switch (type.type) { + case 'Int': + case 'Float': + case 'Decimal': + schema = 'z.number()'; + break; + case 'BigInt': + schema = 'z.bigint()'; + break; + case 'String': + schema = 'z.string()'; + break; + case 'Boolean': + schema = 'z.boolean()'; + break; + case 'DateTime': + schema = 'z.date()'; + break; + default: + schema = 'z.any()'; + break; + } + + if (type.array) { + schema = this.array(schema); + } + } + + return schema; + } + + private union(...schemas: string[]) { + return `z.union([${schemas.join(', ')}])`; + } + + private optional(schema: string) { + return `z.optional(${schema})`; + } + + private array(schema: string) { + return `z.array(${schema})`; + } + + private enumerable(schema: string) { + return this.union(schema, this.array(schema)); + } + + private object(fields: Record) { + return `z.object({ ${Object.entries(fields) + .map(([k, v]) => k + ': ' + v) + .join(',\n')} })`; + } + + private validator(modelName: string, mode: 'create' | 'update') { + return `${modelName}_${mode}_validator`; + } +} diff --git a/packages/schema/src/generator/index.ts b/packages/schema/src/generator/index.ts index 5781f5bdc..20f7b35ad 100644 --- a/packages/schema/src/generator/index.ts +++ b/packages/schema/src/generator/index.ts @@ -7,6 +7,7 @@ import ServiceGenerator from './service'; import ReactHooksGenerator from './react-hooks'; import NextAuthGenerator from './next-auth'; import { TypescriptCompilation } from './tsc'; +import FieldConstraintGenerator from './field-constraint'; /** * ZenStack code generator @@ -45,6 +46,7 @@ export class ZenStackGenerator { new ServiceGenerator(), new ReactHooksGenerator(), new NextAuthGenerator(), + new FieldConstraintGenerator(), new TypescriptCompilation(), ]; diff --git a/packages/schema/src/generator/prisma/query-guard-generator.ts b/packages/schema/src/generator/prisma/query-guard-generator.ts index 84204a77d..8df9e8d60 100644 --- a/packages/schema/src/generator/prisma/query-guard-generator.ts +++ b/packages/schema/src/generator/prisma/query-guard-generator.ts @@ -19,7 +19,7 @@ import { UNKNOWN_USER_ID, } from '../constants'; import { Context } from '../types'; -import { resolved } from '../utils'; +import { resolved } from '../ast-utils'; import ExpressionWriter from './expression-writer'; /** @@ -58,7 +58,9 @@ export default class QueryGuardGenerator { this.generateFieldMapping(models, sf); - models.forEach((model) => this.generateQueryGuardForModel(model, sf)); + for (const model of models) { + await this.generateQueryGuardForModel(model, sf); + } sf.formatText({}); await project.save(); diff --git a/packages/schema/src/generator/prisma/schema-generator.ts b/packages/schema/src/generator/prisma/schema-generator.ts index 51eaf9cf8..d513a47e2 100644 --- a/packages/schema/src/generator/prisma/schema-generator.ts +++ b/packages/schema/src/generator/prisma/schema-generator.ts @@ -1,4 +1,5 @@ import { + Attribute, AttributeArg, DataModel, DataModelAttribute, @@ -19,7 +20,7 @@ import { AstNode } from 'langium'; import path from 'path'; import { GUARD_FIELD_NAME, TRANSACTION_FIELD_NAME } from '../constants'; import { Context, GeneratorError } from '../types'; -import { resolved } from '../utils'; +import { resolved } from '../ast-utils'; import { AttributeArg as PrismaAttributeArg, AttributeArgValue as PrismaAttributeArgValue, @@ -35,8 +36,6 @@ import { ModelFieldType, } from './prisma-builder'; -const excludedAttributes = ['@@allow', '@@deny', '@password', '@omit']; - /** * Generates Prisma schema file */ @@ -186,14 +185,16 @@ export default class PrismaSchemaGenerator { ]); for (const attr of decl.attributes.filter( - (attr) => - attr.decl.ref?.name && - !excludedAttributes.includes(attr.decl.ref.name) + (attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref) )) { this.generateModelAttribute(model, attr); } } + private isPrismaAttribute(attr: Attribute) { + return !!attr.attributes.find((a) => a.decl.ref?.name === '@@@prisma'); + } + private generateModelField(model: PrismaDataModel, field: DataModelField) { const fieldType = field.type.type || field.type.reference?.ref?.name; if (!fieldType) { @@ -210,9 +211,7 @@ export default class PrismaSchemaGenerator { const attributes = field.attributes .filter( - (attr) => - attr.decl.ref?.name && - !excludedAttributes.includes(attr.decl.ref.name) + (attr) => attr.decl.ref && this.isPrismaAttribute(attr.decl.ref) ) .map((attr) => this.makeFieldAttribute(attr)); model.addField(field.name, type, attributes); diff --git a/packages/schema/src/generator/react-hooks/index.ts b/packages/schema/src/generator/react-hooks/index.ts index 313619f01..f731589a2 100644 --- a/packages/schema/src/generator/react-hooks/index.ts +++ b/packages/schema/src/generator/react-hooks/index.ts @@ -4,7 +4,7 @@ import * as path from 'path'; import { paramCase } from 'change-case'; import { DataModel } from '@lang/generated/ast'; import colors from 'colors'; -import { extractDataModelsWithAllowRules } from '../utils'; +import { extractDataModelsWithAllowRules } from '../ast-utils'; import { API_ROUTE_NAME, INTERNAL_PACKAGE } from '../constants'; /** @@ -29,6 +29,10 @@ export default class ReactHooksGenerator implements Generator { console.log(colors.blue(' ✔️ React hooks generated')); } + private getValidator(model: DataModel, mode: 'create' | 'update') { + return `${model.name}_${mode}_validator`; + } + private generateModelHooks( project: Project, context: Context, @@ -47,9 +51,16 @@ export default class ReactHooksGenerator implements Generator { moduleSpecifier: '../../.prisma', }); sf.addStatements([ - `import { request } from '${INTERNAL_PACKAGE}/lib/client';`, + `import { request, validate } from '${INTERNAL_PACKAGE}/lib/client';`, `import { ServerErrorCode } from '@zenstackhq/runtime/client';`, - `import { type SWRResponse } from 'swr'`, + `import { type SWRResponse } from 'swr';`, + `import { ${this.getValidator( + model, + 'create' + )}, ${this.getValidator( + model, + 'update' + )} } from '../field-constraint';`, ]); sf.addStatements( @@ -78,8 +89,15 @@ export default class ReactHooksGenerator implements Generator { .addBody() .addStatements([ ` + // validate field-level constraints + validate(${this.getValidator(model, 'create')}, args.data); + try { - return await request.post>>(endpoint, args, mutate); + return await request.post>>(endpoint, args, mutate); } catch (err: any) { if (err.info?.code === ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED) { return undefined; @@ -149,8 +167,15 @@ export default class ReactHooksGenerator implements Generator { .addBody() .addStatements([ ` + // validate field-level constraints + validate(${this.getValidator(model, 'update')}, args.data); + try { - return await request.put, P.CheckSelect>>(\`\${endpoint}/\${id}\`, args, mutate); + return await request.put, P.CheckSelect>>(\`\${endpoint}/\${id}\`, args, mutate); } catch (err: any) { if (err.info?.code === ServerErrorCode.READ_BACK_AFTER_WRITE_DENIED) { return undefined; diff --git a/packages/schema/src/generator/service/index.ts b/packages/schema/src/generator/service/index.ts index fb883b03d..2646064cf 100644 --- a/packages/schema/src/generator/service/index.ts +++ b/packages/schema/src/generator/service/index.ts @@ -46,6 +46,13 @@ export default class ServiceGenerator implements Generator { return import('./query/guard'); `); + cls.addMethod({ + name: 'loadFieldConstraintModule', + isAsync: true, + }).setBodyText(` + return import('./field-constraint'); + `); + // Recommended by Prisma for Next.js // https://www.prisma.io/docs/guides/database/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices#problem sf.addStatements([ diff --git a/packages/schema/src/language-server/generated/ast.ts b/packages/schema/src/language-server/generated/ast.ts index 326027ba0..9d7ec5a01 100644 --- a/packages/schema/src/language-server/generated/ast.ts +++ b/packages/schema/src/language-server/generated/ast.ts @@ -15,6 +15,8 @@ export function isAbstractDeclaration(item: unknown): item is AbstractDeclaratio return reflection.isInstance(item, AbstractDeclaration); } +export type AttributeAttributeName = string; + export type AttributeName = string; export type BuiltinType = 'BigInt' | 'Boolean' | 'Bytes' | 'DateTime' | 'Decimal' | 'Float' | 'Int' | 'Json' | 'String'; @@ -74,6 +76,7 @@ export function isArrayExpr(item: unknown): item is ArrayExpr { export interface Attribute extends AstNode { readonly $container: Model; + attributes: Array name: AttributeName params: Array } @@ -85,7 +88,7 @@ export function isAttribute(item: unknown): item is Attribute { } export interface AttributeArg extends AstNode { - readonly $container: DataModelAttribute | DataModelFieldAttribute; + readonly $container: AttributeAttribute | DataModelAttribute | DataModelFieldAttribute; name?: string value: Expression } @@ -96,6 +99,18 @@ export function isAttributeArg(item: unknown): item is AttributeArg { return reflection.isInstance(item, AttributeArg); } +export interface AttributeAttribute extends AstNode { + readonly $container: Attribute; + args: Array + decl: Reference +} + +export const AttributeAttribute = 'AttributeAttribute'; + +export function isAttributeAttribute(item: unknown): item is AttributeAttribute { + return reflection.isInstance(item, AttributeAttribute); +} + export interface AttributeParam extends AstNode { readonly $container: Attribute; default: boolean @@ -389,12 +404,12 @@ export function isUnaryExpr(item: unknown): item is UnaryExpr { return reflection.isInstance(item, UnaryExpr); } -export type ZModelAstType = 'AbstractDeclaration' | 'Argument' | 'ArrayExpr' | 'Attribute' | 'AttributeArg' | 'AttributeParam' | 'AttributeParamType' | 'BinaryExpr' | 'DataModel' | 'DataModelAttribute' | 'DataModelField' | 'DataModelFieldAttribute' | 'DataModelFieldType' | 'DataSource' | 'DataSourceField' | 'Enum' | 'EnumField' | 'Expression' | 'Function' | 'FunctionParam' | 'FunctionParamType' | 'InvocationExpr' | 'LiteralExpr' | 'MemberAccessExpr' | 'Model' | 'NullExpr' | 'ReferenceArg' | 'ReferenceExpr' | 'ReferenceTarget' | 'ThisExpr' | 'TypeDeclaration' | 'UnaryExpr'; +export type ZModelAstType = 'AbstractDeclaration' | 'Argument' | 'ArrayExpr' | 'Attribute' | 'AttributeArg' | 'AttributeAttribute' | 'AttributeParam' | 'AttributeParamType' | 'BinaryExpr' | 'DataModel' | 'DataModelAttribute' | 'DataModelField' | 'DataModelFieldAttribute' | 'DataModelFieldType' | 'DataSource' | 'DataSourceField' | 'Enum' | 'EnumField' | 'Expression' | 'Function' | 'FunctionParam' | 'FunctionParamType' | 'InvocationExpr' | 'LiteralExpr' | 'MemberAccessExpr' | 'Model' | 'NullExpr' | 'ReferenceArg' | 'ReferenceExpr' | 'ReferenceTarget' | 'ThisExpr' | 'TypeDeclaration' | 'UnaryExpr'; export class ZModelAstReflection implements AstReflection { getAllTypes(): string[] { - return ['AbstractDeclaration', 'Argument', 'ArrayExpr', 'Attribute', 'AttributeArg', 'AttributeParam', 'AttributeParamType', 'BinaryExpr', 'DataModel', 'DataModelAttribute', 'DataModelField', 'DataModelFieldAttribute', 'DataModelFieldType', 'DataSource', 'DataSourceField', 'Enum', 'EnumField', 'Expression', 'Function', 'FunctionParam', 'FunctionParamType', 'InvocationExpr', 'LiteralExpr', 'MemberAccessExpr', 'Model', 'NullExpr', 'ReferenceArg', 'ReferenceExpr', 'ReferenceTarget', 'ThisExpr', 'TypeDeclaration', 'UnaryExpr']; + return ['AbstractDeclaration', 'Argument', 'ArrayExpr', 'Attribute', 'AttributeArg', 'AttributeAttribute', 'AttributeParam', 'AttributeParamType', 'BinaryExpr', 'DataModel', 'DataModelAttribute', 'DataModelField', 'DataModelFieldAttribute', 'DataModelFieldType', 'DataSource', 'DataSourceField', 'Enum', 'EnumField', 'Expression', 'Function', 'FunctionParam', 'FunctionParamType', 'InvocationExpr', 'LiteralExpr', 'MemberAccessExpr', 'Model', 'NullExpr', 'ReferenceArg', 'ReferenceExpr', 'ReferenceTarget', 'ThisExpr', 'TypeDeclaration', 'UnaryExpr']; } isInstance(node: unknown, type: string): boolean { @@ -440,6 +455,9 @@ export class ZModelAstReflection implements AstReflection { getReferenceType(refInfo: ReferenceInfo): string { const referenceId = `${refInfo.container.$type}:${refInfo.property}`; switch (referenceId) { + case 'AttributeAttribute:decl': { + return Attribute; + } case 'AttributeParamType:reference': { return TypeDeclaration; } @@ -484,10 +502,19 @@ export class ZModelAstReflection implements AstReflection { return { name: 'Attribute', mandatory: [ + { name: 'attributes', type: 'array' }, { name: 'params', type: 'array' } ] }; } + case 'AttributeAttribute': { + return { + name: 'AttributeAttribute', + mandatory: [ + { name: 'args', type: 'array' } + ] + }; + } case 'AttributeParam': { return { name: 'AttributeParam', diff --git a/packages/schema/src/language-server/generated/grammar.ts b/packages/schema/src/language-server/generated/grammar.ts index 886cdff50..6ab93eb19 100644 --- a/packages/schema/src/language-server/generated/grammar.ts +++ b/packages/schema/src/language-server/generated/grammar.ts @@ -1616,6 +1616,33 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "parameters": [], "wildcard": false }, + { + "$type": "ParserRule", + "name": "AttributeAttributeName", + "dataType": "string", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "@@@" + }, + { + "$type": "RuleCall", + "rule": { + "$refText": "ID" + }, + "arguments": [] + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, { "$type": "ParserRule", "name": "DataModelAttributeName", @@ -1690,6 +1717,13 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "$refText": "DataModelFieldAttributeName" }, "arguments": [] + }, + { + "$type": "RuleCall", + "rule": { + "$refText": "AttributeAttributeName" + }, + "arguments": [] } ] }, @@ -1769,6 +1803,19 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Keyword", "value": ")" + }, + { + "$type": "Assignment", + "feature": "attributes", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$refText": "AttributeAttribute" + }, + "arguments": [] + }, + "cardinality": "*" } ] }, @@ -2028,6 +2075,62 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "parameters": [], "wildcard": false }, + { + "$type": "ParserRule", + "name": "AttributeAttribute", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "decl", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$refText": "Attribute" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$refText": "AttributeAttributeName" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "(" + }, + { + "$type": "RuleCall", + "rule": { + "$refText": "AttributeArgList" + }, + "arguments": [], + "cardinality": "?" + }, + { + "$type": "Keyword", + "value": ")" + } + ], + "cardinality": "?" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, { "$type": "ParserRule", "name": "AttributeArgList", diff --git a/packages/schema/src/language-server/langium-ext.d.ts b/packages/schema/src/language-server/langium-ext.d.ts index cbcf9adb6..84ffffebf 100644 --- a/packages/schema/src/language-server/langium-ext.d.ts +++ b/packages/schema/src/language-server/langium-ext.d.ts @@ -1,4 +1,7 @@ import { ResolvedType } from '@lang/types'; +import { AttributeParam } from './generated/ast'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { AttributeArg } from './generated/ast'; declare module 'langium' { export interface AstNode { @@ -8,3 +11,12 @@ declare module 'langium' { $resolvedType?: ResolvedType; } } + +declare module './generated/ast' { + interface AttributeArg { + /** + * Resolved attribute param declaration + */ + $resolvedParam?: AttributeParam; + } +} diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index 7be8a6750..38bf29d8f 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -1,12 +1,15 @@ import { SCALAR_TYPES } from '@lang/constants'; import { ArrayExpr, + Attribute, AttributeParam, DataModel, DataModelAttribute, DataModelField, DataModelFieldAttribute, + isAttribute, isDataModel, + isDataModelField, isLiteralExpr, ReferenceExpr, } from '@lang/generated/ast'; @@ -105,6 +108,27 @@ export default class DataModelValidator implements AstValidator { return; } + const targetDecl = attr.$container; + if (decl.name === '@@@targetField' && !isAttribute(targetDecl)) { + accept( + 'error', + `attribute "${decl.name}" can only be used on attribute declarations`, + { node: attr } + ); + return; + } + + if ( + isDataModelField(targetDecl) && + !this.isValidAttributeTarget(decl, targetDecl) + ) { + accept( + 'error', + `attribute "${decl.name}" cannot be used on this type of field`, + { node: attr } + ); + } + const filledParams = new Set(); for (const arg of attr.args) { @@ -149,6 +173,7 @@ export default class DataModelValidator implements AstValidator { return false; } filledParams.add(paramDecl); + arg.$resolvedParam = paramDecl; } const missingParams = decl.params.filter( @@ -171,6 +196,64 @@ export default class DataModelValidator implements AstValidator { return true; } + private isValidAttributeTarget( + attrDecl: Attribute, + targetDecl: DataModelField + ) { + const targetField = attrDecl.attributes.find( + (attr) => attr.decl.ref?.name === '@@@targetField' + ); + if (!targetField) { + // no field type constraint + return true; + } + + const fieldTypes = (targetField.args[0].value as ArrayExpr).items.map( + (item) => (item as ReferenceExpr).target.ref?.name + ); + + let allowed = false; + for (const allowedType of fieldTypes) { + switch (allowedType) { + case 'StringField': + allowed = allowed || targetDecl.type.type === 'String'; + break; + case 'IntField': + allowed = allowed || targetDecl.type.type === 'Int'; + break; + case 'FloatField': + allowed = allowed || targetDecl.type.type === 'Float'; + break; + case 'DecimalField': + allowed = allowed || targetDecl.type.type === 'Decimal'; + break; + case 'BooleanField': + allowed = allowed || targetDecl.type.type === 'Boolean'; + break; + case 'DateTimeField': + allowed = allowed || targetDecl.type.type === 'DateTime'; + break; + case 'JsonField': + allowed = allowed || targetDecl.type.type === 'Json'; + break; + case 'BytesField': + allowed = allowed || targetDecl.type.type === 'Bytes'; + break; + case 'ModelField': + allowed = + allowed || isDataModel(targetDecl.type.reference?.ref); + break; + default: + break; + } + if (allowed) { + break; + } + } + + return allowed; + } + private parseRelation(field: DataModelField, accept?: ValidationAcceptor) { const relAttr = field.attributes.find( (attr) => attr.decl.ref?.name === '@relation' diff --git a/packages/schema/src/language-server/zmodel.langium b/packages/schema/src/language-server/zmodel.langium index b022ec469..ef6ba05e0 100644 --- a/packages/schema/src/language-server/zmodel.langium +++ b/packages/schema/src/language-server/zmodel.langium @@ -148,17 +148,24 @@ FunctionParam: FunctionParamType: (type=ExpressionType | reference=[TypeDeclaration]) (array?='[]')?; +// attribute-level attribute +AttributeAttributeName returns string: + '@@@' ID; + +// model-level attribute DataModelAttributeName returns string: '@@' ID; + +// field-level attribute DataModelFieldAttributeName returns string: '@' ID; AttributeName returns string: - DataModelAttributeName | DataModelFieldAttributeName; + DataModelAttributeName | DataModelFieldAttributeName | AttributeAttributeName; // attribute Attribute: - 'attribute' name=AttributeName '(' (params+=AttributeParam (',' params+=AttributeParam)*)? ')'; + 'attribute' name=AttributeName '(' (params+=AttributeParam (',' params+=AttributeParam)*)? ')' (attributes+=AttributeAttribute)*; AttributeParam: (default?='_')? name=ID ':' type=AttributeParamType; @@ -174,6 +181,9 @@ DataModelFieldAttribute: DataModelAttribute: decl=[Attribute:DataModelAttributeName] ('(' AttributeArgList? ')')?; +AttributeAttribute: + decl=[Attribute:AttributeAttributeName] ('(' AttributeArgList? ')')?; + fragment AttributeArgList: args+=AttributeArg (',' args+=AttributeArg)*; diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index f4679ba3b..fd4138446 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -9,6 +9,21 @@ enum ReferentialAction { Cascade } +/* + * Enum representing all possible field types + */ +enum AttributeTargetField { + StringField + IntField + FloatField + DecimalField + BooleanField + DateTimeField + JsonField + BytesField + ModelField +} + /* * Reads value from an environment variable. */ @@ -45,50 +60,54 @@ function autoincrement(): Int {} */ function dbgenerated(expr: String): Any {} +attribute @@@targetField(targetField: AttributeTargetField[]) + +attribute @@@prisma() + /* * Defines an ID on the model. */ -attribute @id(map: String?) +attribute @id(map: String?) @@@prisma /* * Defines a default value for a field. */ -attribute @default(_ value: ContextType) +attribute @default(_ value: ContextType) @@@prisma /* * Defines a unique constraint for this field. */ -attribute @unique(map: String?) +attribute @unique(map: String?) @@@prisma /* * Defines a compound unique constraint for the specified fields. */ -attribute @@unique(_ fields: FieldReference[], name: String?, map: String?) +attribute @@unique(_ fields: FieldReference[], name: String?, map: String?) @@@prisma /* * Defines an index in the database. */ -attribute @@index(_ fields: FieldReference[], map: String?) +attribute @@index(_ fields: FieldReference[], map: String?) @@@prisma /* * Defines meta information about the relation. */ -attribute @relation(_ name: String?, fields: FieldReference[]?, references: FieldReference[]?, onDelete: ReferentialAction?, onUpdate: ReferentialAction?, map: String?) +attribute @relation(_ name: String?, fields: FieldReference[]?, references: FieldReference[]?, onDelete: ReferentialAction?, onUpdate: ReferentialAction?, map: String?) @@@prisma /* * Maps a field name or enum value from the schema to a column with a different name in the database. */ -attribute @map(_ name: String) +attribute @map(_ name: String) @@@prisma /* * Maps the schema model name to a table with a different name, or an enum name to a different underlying enum in the database. */ -attribute @@map(_ name: String) +attribute @@map(_ name: String) @@@prisma /* * Automatically stores the time when a record was last updated. */ -attribute @updatedAt() +attribute @updatedAt() @@@targetField([DateTimeField]) @@@prisma /* * Defines an access policy that allows a set of operations when the given condition is true. @@ -112,9 +131,64 @@ attribute @@deny(_ operation: String, _ condition: Boolean) * @saltLength: length of salt to use (cost factor for the hash function) * @salt: salt to use (a pregenerated valid salt) */ -attribute @password(saltLength: Int?, salt: String?) +attribute @password(saltLength: Int?, salt: String?) @@@targetField([StringField]) /* * Indicates that the field should be omitted when read from the generated services. */ attribute @omit() + +/* + * Validates length of a string field. + */ +attribute @length(_ min: Int?, _ max: Int?) @@@targetField([StringField]) + +/* + * Validates a string field value matches a regex. + */ +attribute @regex(_ regex: String) @@@targetField([StringField]) + +/* + * Validates a string field value starts with the given text. + */ +attribute @startsWith(_ text: String) @@@targetField([StringField]) + +/* + * Validates a string field value ends with the given text. + */ +attribute @endsWith(_ text: String) @@@targetField([StringField]) + +/* + * Validates a string field value is a valid email address. + */ +attribute @email() @@@targetField([StringField]) + +/* + * Validates a string field value is a valid ISO datetime. + */ +attribute @datetime() @@@targetField([StringField]) + +/* + * Validates a string field value is a valid url. + */ +attribute @url() @@@targetField([StringField]) + +/* + * Validates a number field is greater than the given value. + */ +attribute @gt(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) + +/* + * Validates a number field is greater than or equal to the given value. + */ +attribute @gte(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) + +/* + * Validates a number field is less than the given value. + */ +attribute @lt(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) + +/* + * Validates a number field is less than or equal to the given value. + */ +attribute @lte(_ value: Int) @@@targetField([IntField, FloatField, DecimalField]) diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts new file mode 100644 index 000000000..af39ba322 --- /dev/null +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -0,0 +1,240 @@ +import { loadModel, loadModelWithError } from '../../utils'; + +describe('Attribute tests', () => { + const prelude = ` + datasource db { + provider = "postgresql" + url = "url" + } + `; + + it('builtin field attributes', async () => { + await loadModel(` + ${prelude} + model M { + x String @id @default("abc") @unique @map("_id") + y DateTime @updatedAt + } + `); + }); + + it('field attribute type checking', async () => { + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id(123) + } + `) + ).toContain(`Unexpected unnamed argument`); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id() @default(value:'def', 'abc') + } + `) + ).toContain(`Unexpected unnamed argument`); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id() @default('abc', value:'def') + } + `) + ).toContain(`Parameter "value" is already provided`); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id() @default(123) + } + `) + ).toContain(`Value is not assignable to parameter`); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id() @default() + } + `) + ).toContain(`Required parameter not provided: value`); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id() @default('abc', value: 'def') + } + `) + ).toContain(`Parameter "value" is already provided`); + + expect( + await loadModelWithError(` + ${prelude} + model M { + id String @id() @default(foo: 'abc') + } + `) + ).toContain( + `Attribute "@default" doesn't have a parameter named "foo"` + ); + }); + + it('field attribute coverage', async () => { + await loadModel(` + ${prelude} + model A { + id String @id + } + + model B { + id String @id() + } + + model C { + id String @id(map: "__id") + } + + model D { + id String @id + x String @default("x") + } + + model E { + id String @id + x String @default(value: "x") + } + + model F { + id String @id + x String @default(uuid()) + } + + model G { + id String @id + x Int @default(autoincrement()) + } + + model H { + id String @id + x String @unique() + } + `); + }); + + it('model attribute coverage', async () => { + await loadModel(` + ${prelude} + model A { + id String @id + x Int + y String + @@unique([x, y]) + } + `); + + await loadModel(` + ${prelude} + model A { + id String @id + x Int + y String + @@unique(fields: [x, y]) + } + `); + + expect( + await loadModelWithError(` + ${prelude} + model A { + id String @id + x Int + y String + @@unique([x, z]) + } + `) + ).toContain( + `Could not resolve reference to ReferenceTarget named 'z'.` + ); + + await loadModel(` + ${prelude} + model A { + id String @id + x Int + y String + @@index([x, y]) + } + `); + + await loadModel(` + ${prelude} + model A { + id String @id + x Int + y String + @@map("__A") + } + `); + }); + + it('attribute function coverage', async () => { + await loadModel(` + ${prelude} + model A { + id String @id @default(uuid()) + id1 String @default(cuid()) + created DateTime @default(now()) + serial Int @default(autoincrement()) + foo String @default(dbgenerated("gen_random_uuid()")) + @@allow('all', auth() != null) + } + `); + }); + + it('attribute function check', async () => { + expect( + await loadModelWithError(` + ${prelude} + model A { + id String @id @default(foo()) + } + `) + ).toContain(`Could not resolve reference to Function named 'foo'.`); + + expect( + await loadModelWithError(` + ${prelude} + model A { + id Int @id @default(uuid()) + } + `) + ).toContain(`Value is not assignable to parameter`); + }); + + it('invalid attribute target field', async () => { + expect( + await loadModelWithError(` + ${prelude} + model A { + id String @id @gt(10) + } + `) + ).toContain('attribute "@gt" cannot be used on this type of field'); + + expect( + await loadModelWithError(` + ${prelude} + model A { + id String @id + x Int @length(5) + } + `) + ).toContain('attribute "@length" cannot be used on this type of field'); + }); +}); diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts index 43d682398..6162126c3 100644 --- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts +++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts @@ -151,214 +151,6 @@ describe('Data Model Validation Tests', () => { ).toContain(`Field with @id attribute must be of scalar type`); }); - it('builtin field attributes', async () => { - await loadModel(` - ${prelude} - model M { - x String @id @default("abc") @unique @map("_id") @updatedAt - } - `); - }); - - it('field attribute type checking', async () => { - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id(123) - } - `) - ).toContain(`Unexpected unnamed argument`); - - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id() @default(value:'def', 'abc') - } - `) - ).toContain(`Unexpected unnamed argument`); - - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id() @default('abc', value:'def') - } - `) - ).toContain(`Parameter \"value\" is already provided`); - - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id() @default(123) - } - `) - ).toContain(`Value is not assignable to parameter`); - - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id() @default() - } - `) - ).toContain(`Required parameter not provided: value`); - - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id() @default('abc', value: 'def') - } - `) - ).toContain(`Parameter "value" is already provided`); - - expect( - await loadModelWithError(` - ${prelude} - model M { - id String @id() @default(foo: 'abc') - } - `) - ).toContain( - `Attribute "@default" doesn't have a parameter named "foo"` - ); - }); - - it('field attribute coverage', async () => { - await loadModel(` - ${prelude} - model A { - id String @id - } - - model B { - id String @id() - } - - model C { - id String @id(map: "__id") - } - - model D { - id String @id - x String @default("x") - } - - model E { - id String @id - x String @default(value: "x") - } - - model F { - id String @id - x String @default(uuid()) - } - - model G { - id String @id - x Int @default(autoincrement()) - } - - model H { - id String @id - x String @unique() - } - `); - }); - - it('model attribute coverage', async () => { - await loadModel(` - ${prelude} - model A { - id String @id - x Int - y String - @@unique([x, y]) - } - `); - - await loadModel(` - ${prelude} - model A { - id String @id - x Int - y String - @@unique(fields: [x, y]) - } - `); - - expect( - await loadModelWithError(` - ${prelude} - model A { - id String @id - x Int - y String - @@unique([x, z]) - } - `) - ).toContain( - `Could not resolve reference to ReferenceTarget named 'z'.` - ); - - await loadModel(` - ${prelude} - model A { - id String @id - x Int - y String - @@index([x, y]) - } - `); - - await loadModel(` - ${prelude} - model A { - id String @id - x Int - y String - @@map("__A") - } - `); - }); - - it('attribute function coverage', async () => { - await loadModel(` - ${prelude} - model A { - id String @id @default(uuid()) - id1 String @default(cuid()) - created DateTime @default(now()) - serial Int @default(autoincrement()) - foo String @default(dbgenerated("gen_random_uuid()")) - @@allow('all', auth() != null) - } - `); - }); - - it('attribute function check', async () => { - expect( - await loadModelWithError(` - ${prelude} - model A { - id String @id @default(foo()) - } - `) - ).toContain(`Could not resolve reference to Function named 'foo'.`); - - expect( - await loadModelWithError(` - ${prelude} - model A { - id Int @id @default(uuid()) - } - `) - ).toContain(`Value is not assignable to parameter`); - }); - it('relation', async () => { // one-to-one await loadModel(` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c50ae3e29..8abbbb208 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,8 @@ importers: tsc-alias: ^1.7.0 tsconfig-paths-jest: ^0.0.1 typescript: ^4.6.2 + zod: ^3.19.1 + zod-validation-error: ^0.2.1 dependencies: bcryptjs: 2.4.3 colors: 1.4.0 @@ -39,6 +41,8 @@ importers: react: 18.2.0 react-dom: 18.2.0_react@18.2.0 swr: 1.3.0_react@18.2.0 + zod: 3.19.1 + zod-validation-error: 0.2.1_zod@3.19.1 devDependencies: '@prisma/client': 4.5.0 '@types/bcryptjs': 2.4.2 @@ -154,6 +158,7 @@ importers: '@types/tmp': ^0.2.3 bcryptjs: ^2.4.3 jest: ^29.0.3 + jest-fetch-mock: ^3.0.3 next: ^12.3.1 sleep-promise: ^9.1.0 supertest: ^6.3.0 @@ -171,10 +176,11 @@ importers: '@types/supertest': 2.0.12 '@types/tmp': 0.2.3 jest: 29.0.3_johvxhudwcpndp4mle25vwrlq4 - next: 12.3.1_qtpcxnaaarbm4ws7ughq6oxfve + jest-fetch-mock: 3.0.3 + next: 12.3.1_6tziyx3dehkoeijunclpkpolha supertest: 6.3.0 tmp: 0.2.1 - ts-jest: 29.0.1_t3cec5bure72u77t3utxqeumoa + ts-jest: 29.0.1_poggjixajg6vd6yquly7s7dsj4 ts-node: 10.9.1_ck2axrxkiif44rdbzjywaqjysa typescript: 4.8.3 @@ -2669,6 +2675,14 @@ packages: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true + /cross-fetch/3.1.5: + resolution: {integrity: sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==} + dependencies: + node-fetch: 2.6.7 + transitivePeerDependencies: + - encoding + dev: true + /cross-spawn/7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -4190,6 +4204,15 @@ packages: jest-util: 29.2.1 dev: true + /jest-fetch-mock/3.0.3: + resolution: {integrity: sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==} + dependencies: + cross-fetch: 3.1.5 + promise-polyfill: 8.2.3 + transitivePeerDependencies: + - encoding + dev: true + /jest-get-type/29.0.0: resolution: {integrity: sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -5112,52 +5135,6 @@ packages: transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - dev: false - - /next/12.3.1_qtpcxnaaarbm4ws7ughq6oxfve: - resolution: {integrity: sha512-l7bvmSeIwX5lp07WtIiP9u2ytZMv7jIeB8iacR28PuUEFG5j0HGAPnMqyG5kbZNBG2H7tRsrQ4HCjuMOPnANZw==} - engines: {node: '>=12.22.0'} - hasBin: true - peerDependencies: - fibers: '>= 3.1.0' - node-sass: ^6.0.0 || ^7.0.0 - react: ^17.0.2 || ^18.0.0-0 - react-dom: ^17.0.2 || ^18.0.0-0 - sass: ^1.3.0 - peerDependenciesMeta: - fibers: - optional: true - node-sass: - optional: true - sass: - optional: true - dependencies: - '@next/env': 12.3.1 - '@swc/helpers': 0.4.11 - caniuse-lite: 1.0.30001409 - postcss: 8.4.14 - react: 18.2.0 - react-dom: 18.2.0_react@18.2.0 - styled-jsx: 5.0.7_otspjrsspon4ofp37rshhlhp2y - use-sync-external-store: 1.2.0_react@18.2.0 - optionalDependencies: - '@next/swc-android-arm-eabi': 12.3.1 - '@next/swc-android-arm64': 12.3.1 - '@next/swc-darwin-arm64': 12.3.1 - '@next/swc-darwin-x64': 12.3.1 - '@next/swc-freebsd-x64': 12.3.1 - '@next/swc-linux-arm-gnueabihf': 12.3.1 - '@next/swc-linux-arm64-gnu': 12.3.1 - '@next/swc-linux-arm64-musl': 12.3.1 - '@next/swc-linux-x64-gnu': 12.3.1 - '@next/swc-linux-x64-musl': 12.3.1 - '@next/swc-win32-arm64-msvc': 12.3.1 - '@next/swc-win32-ia32-msvc': 12.3.1 - '@next/swc-win32-x64-msvc': 12.3.1 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - dev: true /no-case/3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -5522,6 +5499,10 @@ packages: engines: {node: '>=0.4.0'} dev: true + /promise-polyfill/8.2.3: + resolution: {integrity: sha512-Og0+jCRQetV84U8wVjMNccfGCnMQ9mGs9Hv78QFe+pSDD3gWTpz0y+1QCuxy5d/vBFuZ3iwP2eycAkvqIMPmWg==} + dev: true + /promisify/0.0.3: resolution: {integrity: sha512-CcBGsRhhq466fsZVyHfptuKqon6eih0CqMsJE0kWIIjbpVNEyDoaKLELm2WVs//W/WXRBHip+6xhTExTkHUwtA==} dependencies: @@ -5989,24 +5970,6 @@ packages: dependencies: '@babel/core': 7.19.3 react: 18.2.0 - dev: false - - /styled-jsx/5.0.7_otspjrsspon4ofp37rshhlhp2y: - resolution: {integrity: sha512-b3sUzamS086YLRuvnaDigdAewz1/EFYlHpYBP5mZovKEdQQOIIYq8lApylub3HHZ6xFjV051kkGU7cudJmrXEA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - dependencies: - '@babel/core': 7.19.6 - react: 18.2.0 - dev: true /superagent/8.0.2: resolution: {integrity: sha512-QtYZ9uaNAMexI7XWl2vAXAh0j4q9H7T0WVEI/y5qaUB3QLwxo+voUgCQ217AokJzUTIVOp0RTo7fhZrwhD7A2Q==} @@ -6215,40 +6178,6 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-jest/29.0.1_t3cec5bure72u77t3utxqeumoa: - resolution: {integrity: sha512-htQOHshgvhn93QLxrmxpiQPk69+M1g7govO1g6kf6GsjCv4uvRV0znVmDrrvjUrVCnTYeY4FBxTYYYD4airyJA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - hasBin: true - peerDependencies: - '@babel/core': '>=7.0.0-beta.0 <8' - '@jest/types': ^29.0.0 - babel-jest: ^29.0.0 - esbuild: '*' - jest: ^29.0.0 - typescript: '>=4.3' - peerDependenciesMeta: - '@babel/core': - optional: true - '@jest/types': - optional: true - babel-jest: - optional: true - esbuild: - optional: true - dependencies: - '@babel/core': 7.19.6 - bs-logger: 0.2.6 - fast-json-stable-stringify: 2.1.0 - jest: 29.0.3_johvxhudwcpndp4mle25vwrlq4 - jest-util: 29.0.3 - json5: 2.2.1 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.3.7 - typescript: 4.8.3 - yargs-parser: 21.1.1 - dev: true - /ts-jest/29.0.3_nvckv3qbfhmmsla6emqlkyje4a: resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6766,3 +6695,17 @@ packages: compress-commons: 4.1.1 readable-stream: 3.6.0 dev: true + + /zod-validation-error/0.2.1_zod@3.19.1: + resolution: {integrity: sha512-zGg6P5EHi5V0dvyEeC8HBZd2pzp7QDKTngkSWgWunljrY+0SHkHyjI519D+u8/37BHkGHAFseWgnZ2Uq8LNFKg==} + engines: {node: ^14.17 || >=16.0.0} + peerDependencies: + zod: ^3.18.0 + dependencies: + '@swc/helpers': 0.4.11 + zod: 3.19.1 + dev: false + + /zod/3.19.1: + resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==} + dev: false diff --git a/samples/todo/package-lock.json b/samples/todo/package-lock.json index ef225c9c5..a67305e3e 100644 --- a/samples/todo/package-lock.json +++ b/samples/todo/package-lock.json @@ -1,17 +1,17 @@ { "name": "todo", - "version": "0.3.1", + "version": "0.3.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "todo", - "version": "0.3.1", + "version": "0.3.4", "dependencies": { "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", - "@zenstackhq/internal": "^0.3.1", - "@zenstackhq/runtime": "^0.3.1", + "@zenstackhq/internal": "^0.3.4", + "@zenstackhq/runtime": "^0.3.4", "bcryptjs": "^2.4.3", "daisyui": "^2.31.0", "moment": "^2.29.4", @@ -21,7 +21,8 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-toastify": "^9.0.8", - "swr": "^1.3.0" + "swr": "^1.3.0", + "zod": "^3.19.1" }, "devDependencies": { "@tailwindcss/line-clamp": "^0.4.2", @@ -35,7 +36,7 @@ "postcss": "^8.4.16", "tailwindcss": "^3.1.8", "typescript": "^4.6.2", - "zenstack": "^0.3.1" + "zenstack": "^0.3.4" } }, "node_modules/@babel/code-frame": { @@ -722,16 +723,18 @@ } }, "node_modules/@zenstackhq/internal": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.3.1.tgz", - "integrity": "sha512-ZCpV2R5MVW7BYyCCvltojvKmmRftG9qcDrkFuLFbX+kf2k+M7rQQ9W7GpMOwO3PgCm8+wajLLytPl58L2OWlkA==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.3.4.tgz", + "integrity": "sha512-fBRbVC/AGmBX1l0U66lP/d1AaEY2rmxoPa2g1xMIeeoPnnTetymxZloUKdC2+J54/sV6q4TWfN6g5UC11SCthQ==", "dependencies": { "bcryptjs": "^2.4.3", "colors": "1.4.0", "cuid": "^2.1.8", "decimal.js": "^10.4.2", "deepcopy": "^2.1.0", - "swr": "^1.3.0" + "swr": "^1.3.0", + "zod": "^3.19.1", + "zod-validation-error": "^0.2.1" }, "peerDependencies": { "@prisma/client": "^4.4.0", @@ -741,9 +744,9 @@ } }, "node_modules/@zenstackhq/runtime": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.1.tgz", - "integrity": "sha512-VsrFgiA2c08914biFfMgNLNxPD26Gj84uOt5F27AcLTWSS/6fAPOJUQTb3YB6ytYUvqcRzaIPiG6JHOR7dmU3g==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.4.tgz", + "integrity": "sha512-AH6FnHeWQ18NQmEjL3NH3zwKrrKuQ5xga3sDA5MmWyiOwnAzDI7btXfr/mpo4iCycxyDfcwSFKIAan+ddlPgGQ==", "dependencies": { "@zenstackhq/internal": "latest" }, @@ -4526,12 +4529,12 @@ } }, "node_modules/zenstack": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.1.tgz", - "integrity": "sha512-eQOO7Je4cMUTFblXn/Sbi1+kV+KOW7kMpOi6UhVtqKKxy9R5J2mdCaT0Npkb1YI9ppCnfeF1VDYRJutZsFc4Vg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.4.tgz", + "integrity": "sha512-kUqBrO6/j0PRBMbKiUvM/J6F7IWsYEkmozc3TgppJGC88LuvLDmjEZnIq7TRCCQebTPxgFu/mhYjiMUVC8fdAA==", "dev": true, "dependencies": { - "@zenstackhq/internal": "0.3.1", + "@zenstackhq/internal": "0.3.4", "change-case": "^4.1.2", "chevrotain": "^9.1.0", "colors": "1.4.0", @@ -4563,6 +4566,28 @@ "bin": { "uuid": "dist/bin/uuid" } + }, + "node_modules/zod": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.19.1.tgz", + "integrity": "sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-0.2.1.tgz", + "integrity": "sha512-zGg6P5EHi5V0dvyEeC8HBZd2pzp7QDKTngkSWgWunljrY+0SHkHyjI519D+u8/37BHkGHAFseWgnZ2Uq8LNFKg==", + "dependencies": { + "@swc/helpers": "^0.4.11" + }, + "engines": { + "node": "^14.17 || >=16.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } }, "dependencies": { @@ -5023,22 +5048,24 @@ } }, "@zenstackhq/internal": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.3.1.tgz", - "integrity": "sha512-ZCpV2R5MVW7BYyCCvltojvKmmRftG9qcDrkFuLFbX+kf2k+M7rQQ9W7GpMOwO3PgCm8+wajLLytPl58L2OWlkA==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@zenstackhq/internal/-/internal-0.3.4.tgz", + "integrity": "sha512-fBRbVC/AGmBX1l0U66lP/d1AaEY2rmxoPa2g1xMIeeoPnnTetymxZloUKdC2+J54/sV6q4TWfN6g5UC11SCthQ==", "requires": { "bcryptjs": "^2.4.3", "colors": "1.4.0", "cuid": "^2.1.8", "decimal.js": "^10.4.2", "deepcopy": "^2.1.0", - "swr": "^1.3.0" + "swr": "^1.3.0", + "zod": "^3.19.1", + "zod-validation-error": "^0.2.1" } }, "@zenstackhq/runtime": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.1.tgz", - "integrity": "sha512-VsrFgiA2c08914biFfMgNLNxPD26Gj84uOt5F27AcLTWSS/6fAPOJUQTb3YB6ytYUvqcRzaIPiG6JHOR7dmU3g==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@zenstackhq/runtime/-/runtime-0.3.4.tgz", + "integrity": "sha512-AH6FnHeWQ18NQmEjL3NH3zwKrrKuQ5xga3sDA5MmWyiOwnAzDI7btXfr/mpo4iCycxyDfcwSFKIAan+ddlPgGQ==", "requires": { "@zenstackhq/internal": "latest" } @@ -7778,12 +7805,12 @@ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==" }, "zenstack": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.1.tgz", - "integrity": "sha512-eQOO7Je4cMUTFblXn/Sbi1+kV+KOW7kMpOi6UhVtqKKxy9R5J2mdCaT0Npkb1YI9ppCnfeF1VDYRJutZsFc4Vg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/zenstack/-/zenstack-0.3.4.tgz", + "integrity": "sha512-kUqBrO6/j0PRBMbKiUvM/J6F7IWsYEkmozc3TgppJGC88LuvLDmjEZnIq7TRCCQebTPxgFu/mhYjiMUVC8fdAA==", "dev": true, "requires": { - "@zenstackhq/internal": "0.3.1", + "@zenstackhq/internal": "0.3.4", "change-case": "^4.1.2", "chevrotain": "^9.1.0", "colors": "1.4.0", @@ -7808,6 +7835,19 @@ "dev": true } } + }, + "zod": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.19.1.tgz", + "integrity": "sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==" + }, + "zod-validation-error": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-0.2.1.tgz", + "integrity": "sha512-zGg6P5EHi5V0dvyEeC8HBZd2pzp7QDKTngkSWgWunljrY+0SHkHyjI519D+u8/37BHkGHAFseWgnZ2Uq8LNFKg==", + "requires": { + "@swc/helpers": "^0.4.11" + } } } } diff --git a/samples/todo/package.json b/samples/todo/package.json index b339af888..1acd9958d 100644 --- a/samples/todo/package.json +++ b/samples/todo/package.json @@ -1,6 +1,6 @@ { "name": "todo", - "version": "0.3.2", + "version": "0.3.4", "private": true, "scripts": { "dev": "next dev", @@ -20,8 +20,8 @@ "dependencies": { "@heroicons/react": "^2.0.12", "@prisma/client": "^4.4.0", - "@zenstackhq/internal": "^0.3.1", - "@zenstackhq/runtime": "^0.3.1", + "@zenstackhq/internal": "^0.3.4", + "@zenstackhq/runtime": "^0.3.4", "bcryptjs": "^2.4.3", "daisyui": "^2.31.0", "moment": "^2.29.4", @@ -31,7 +31,8 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-toastify": "^9.0.8", - "swr": "^1.3.0" + "swr": "^1.3.0", + "zod": "^3.19.1" }, "devDependencies": { "@tailwindcss/line-clamp": "^0.4.2", @@ -45,6 +46,6 @@ "postcss": "^8.4.16", "tailwindcss": "^3.1.8", "typescript": "^4.6.2", - "zenstack": "^0.3.1" + "zenstack": "^0.3.4" } } diff --git a/samples/todo/zenstack/schema.zmodel b/samples/todo/zenstack/schema.zmodel index 1a26cab54..8a48617bb 100644 --- a/samples/todo/zenstack/schema.zmodel +++ b/samples/todo/zenstack/schema.zmodel @@ -25,8 +25,8 @@ model Space { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - name String - slug String @unique + name String @length(4, 50) + slug String @unique @regex('^[0-9a-zA-Z]{4,16}$') members SpaceUser[] lists List[] @@ -75,23 +75,23 @@ model User { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - email String @unique + email String @unique @email emailVerified DateTime? password String @password @omit name String? spaces SpaceUser[] - image String? + image String? @url lists List[] todos Todo[] // can be created by anyone, even not logged in @@allow('create', true) - // can be read by current user or users sharing any space - @@allow('read', auth() == this || spaces?[space.members?[user == auth()]]) + // can be read by users sharing any space + @@allow('read', spaces?[space.members?[user == auth()]]) - // can only be updated and deleted by himeself - @@allow('update,delete', auth() == this) + // full access by oneself + @@allow('all', auth() == this) } /* @@ -105,7 +105,7 @@ model List { spaceId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String - title String + title String @length(1, 100) private Boolean @default(false) todos Todo[] @@ -115,8 +115,11 @@ model List { // can be read by owner or space members (only if not private) @@allow('read', owner == auth() || (space.members?[user == auth()] && !private)) - // can be created/updated/deleted by owner - @@allow('create,update,delete', owner == auth() && space.members?[user == auth()]) + // when create/udpate, owner must be set to current user, and user must be in the space + @@allow('create,update', owner == auth() && space.members?[user == auth()]) + + // can be deleted by owner + @@allow('delete', owner == auth()) } /* @@ -130,7 +133,7 @@ model Todo { ownerId String list List @relation(fields: [listId], references: [id], onDelete: Cascade) listId String - title String + title String @length(1, 100) completedAt DateTime? // require login diff --git a/tests/integration/jest.config.ts b/tests/integration/jest.config.ts index bba1c0b6d..e825ade7c 100644 --- a/tests/integration/jest.config.ts +++ b/tests/integration/jest.config.ts @@ -6,9 +6,6 @@ export default { // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, - // Automatically reset mock state before every test - resetMocks: true, - // A map from regular expressions to paths to transformers transform: { '^.+\\.tsx?$': 'ts-jest' }, diff --git a/tests/integration/package.json b/tests/integration/package.json index 7d79e122b..6ee5aab3b 100644 --- a/tests/integration/package.json +++ b/tests/integration/package.json @@ -15,6 +15,7 @@ "@types/supertest": "^2.0.12", "@types/tmp": "^0.2.3", "jest": "^29.0.3", + "jest-fetch-mock": "^3.0.3", "next": "^12.3.1", "supertest": "^6.3.0", "tmp": "^0.2.1", diff --git a/tests/integration/tests/field-validation-client.test.ts b/tests/integration/tests/field-validation-client.test.ts new file mode 100644 index 000000000..741280e48 --- /dev/null +++ b/tests/integration/tests/field-validation-client.test.ts @@ -0,0 +1,208 @@ +import path from 'path'; +import { run, setup } from './utils'; +import { default as fetch, enableFetchMocks } from 'jest-fetch-mock'; + +describe('Field validation client-side tests', () => { + let origDir: string; + + const hooksModule = '@zenstackhq/runtime/hooks'; + const requestModule = '@zenstackhq/internal/lib/request'; + + beforeAll(async () => { + origDir = path.resolve('.'); + await setup('./tests/field-validation.zmodel'); + + // mock mutate method + jest.mock(requestModule, () => ({ + ...jest.requireActual(requestModule), + getMutate: jest.fn(() => jest.fn()), + })); + + // mock fetch + enableFetchMocks(); + fetch.mockResponse(JSON.stringify({ status: 'ok' })); + }); + + beforeEach(async () => { + run('npx prisma migrate reset --schema ./zenstack/schema.prisma -f'); + }); + + afterAll(() => { + process.chdir(origDir); + jest.resetAllMocks(); + }); + + async function expectErrors( + call: () => Promise, + expectedErrors: string[] + ) { + try { + await call(); + } catch (err: any) { + if (!err.message) { + throw err; + } + const errors: string[] = err.message.split(';'); + expect(errors).toEqual( + expect.arrayContaining( + expectedErrors.map((e) => expect.stringContaining(e)) + ) + ); + return; + } + + throw new Error('Error is expected'); + } + + it('direct write test', async () => { + const { useUser } = await import(hooksModule); + const { create: createUser, update: updateUser } = useUser(); + + expectErrors( + () => + createUser({ + data: { + id: '1', + password: 'abc123', + handle: 'hello world', + }, + }), + ['password', 'email', 'handle'] + ); + + await createUser({ + data: { + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + }, + }); + + expectErrors( + () => + updateUser('1', { + data: { + password: 'abc123', + email: 'me@test.org', + handle: 'hello world', + }, + }), + ['password', 'email', 'handle'] + ); + + await updateUser('1', { + data: { + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + }, + }); + }); + + it('nested write test', async () => { + const { useUser } = await import(hooksModule); + const { create: createUser, update: updateUser } = useUser(); + + const userData = { + a: 1, + b: 0, + c: -1, + d: 0, + text1: 'abc123', + text2: 'def', + text3: 'aaa', + text4: 'abcab', + }; + + const tasks = [ + { + slug: 'abcabc', + }, + { + slug: 'abcdef', + }, + ]; + + expectErrors( + () => + createUser({ + data: { + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + + userData: { + create: { + a: 0, + }, + }, + + tasks: { + create: { + slug: 'xyz', + }, + }, + }, + }), + ['userData.create', 'tasks.create.slug'] + ); + + await createUser({ + data: { + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + + userData: { + create: userData, + }, + + tasks: { + create: tasks, + }, + }, + }); + + expectErrors( + () => + updateUser('1', { + data: { + userData: { + update: { + a: 0, + }, + }, + + tasks: { + update: { + where: { id: 1 }, + data: { + slug: 'xyz', + }, + }, + }, + }, + }), + ['userData.update', 'tasks.update.data.slug'] + ); + + await updateUser('1', { + data: { + userData: { + update: { + a: 1, + }, + }, + + tasks: { + update: { + where: { id: 1 }, + data: { + slug: 'abcxyz', + }, + }, + }, + }, + }); + }); +}); diff --git a/tests/integration/tests/field-validation-server.test.ts b/tests/integration/tests/field-validation-server.test.ts new file mode 100644 index 000000000..a57ac894d --- /dev/null +++ b/tests/integration/tests/field-validation-server.test.ts @@ -0,0 +1,548 @@ +import path from 'path'; +import { makeClient, run, setup } from './utils'; +import { ServerErrorCode } from '../../../packages/internal/src/types'; + +describe('Field validation server-side tests', () => { + let origDir: string; + + beforeAll(async () => { + origDir = path.resolve('.'); + await setup('./tests/field-validation.zmodel'); + }); + + beforeEach(() => { + run('npx prisma migrate reset --schema ./zenstack/schema.prisma -f'); + }); + + afterAll(() => { + process.chdir(origDir); + }); + + it('direct write test', async () => { + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + id: '1', + password: 'abc123', + handle: 'hello world', + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'String must contain at least 8 character(s) at "password"' + ); + expect(resp.body.message).toContain('Required at "email"'); + expect(resp.body.message).toContain('Invalid at "handle"'); + }); + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + id: '1', + password: 'abc123!@#', + email: 'something', + handle: 'user1user1user1user1user1', + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain('Invalid email at "email"'); + expect(resp.body.message).toContain( + 'must end with "@myorg.com" at "email"' + ); + expect(resp.body.message).toContain('Invalid at "handle"'); + }); + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + id: '1', + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + }, + }) + .expect(201); + + await makeClient('/api/data/User/1') + .put('/') + .send({ + data: { + password: 'abc123', + email: 'something', + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'String must contain at least 8 character(s) at "password"' + ); + expect(resp.body.message).toContain('Invalid email at "email"'); + expect(resp.body.message).toContain( + 'must end with "@myorg.com" at "email"' + ); + }); + }); + + it('direct write more test', async () => { + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + id: '1', + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + }, + }) + .expect(201); + + await makeClient('/api/data/UserData') + .post('/') + .send({ + data: { + userId: '1', + a: 0, + b: -1, + c: 0, + d: 1, + text1: 'a', + text2: 'xyz', + text3: 'a', + text4: 'abcabc', + text5: 'abc', + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Number must be greater than 0 at "a"' + ); + expect(resp.body.message).toContain( + 'Number must be greater than or equal to 0 at "b"' + ); + expect(resp.body.message).toContain( + 'Number must be less than 0 at "c"' + ); + expect(resp.body.message).toContain( + 'Number must be less than or equal to 0 at "d"' + ); + expect(resp.body.message).toContain( + 'must start with "abc" at "text1"' + ); + expect(resp.body.message).toContain( + 'must end with "def" at "text2"' + ); + expect(resp.body.message).toContain( + 'String must contain at least 3 character(s) at "text3"' + ); + expect(resp.body.message).toContain( + 'String must contain at most 5 character(s) at "text4"' + ); + expect(resp.body.message).toContain( + 'must end with "xyz" at "text5"' + ); + }); + + await makeClient('/api/data/UserData') + .post('/') + .send({ + data: { + userId: '1', + a: 1, + b: 0, + c: -1, + d: 0, + text1: 'abc123', + text2: 'def', + text3: 'aaa', + text4: 'abcab', + }, + }) + .expect(201); + }); + + it('nested create test', async () => { + const user = { + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + }; + + const userData = { + a: 1, + b: 0, + c: -1, + d: 0, + text1: 'abc123', + text2: 'def', + text3: 'aaa', + text4: 'abcab', + }; + + const tasks = [ + { + slug: 'abcabc', + }, + { + slug: 'abcdef', + }, + ]; + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + ...user, + userData: { + create: { + a: 0, + }, + }, + tasks: { + create: { + slug: 'abc', + }, + }, + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Invalid input at "userData.create"' + ); + expect(resp.body.message).toContain( + 'Invalid at "tasks.create.slug"' + ); + }); + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + ...user, + userData: { create: userData }, + tasks: { + create: { + slug: 'abcabc', + }, + }, + }, + }) + .expect(201); + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + ...user, + userData: { + connectOrCreate: { + where: { + id: '1', + }, + create: { + a: 0, + }, + }, + }, + tasks: { + create: [ + { + slug: 'abc', + }, + { + slug: 'abcdef', + }, + ], + }, + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Invalid input at "userData.connectOrCreate"' + ); + expect(resp.body.message).toContain( + 'Invalid at "tasks.create[0].slug"' + ); + }); + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + ...user, + tasks: { + createMany: [ + { + slug: 'abc', + }, + { + slug: 'abcdef', + }, + ], + }, + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Invalid at "tasks.createMany[0].slug"' + ); + }); + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + ...user, + userData: { + connectOrCreate: { + where: { + id: '1', + }, + create: userData, + }, + }, + tasks: { + create: tasks, + }, + }, + }) + .expect(201); + }); + + it('nested update test', async () => { + const user = { + password: 'abc123!@#', + email: 'who@myorg.com', + handle: 'user1', + }; + + const userData = { + a: 1, + b: 0, + c: -1, + d: 0, + text1: 'abc123', + text2: 'def', + text3: 'aaa', + text4: 'abcab', + }; + + const tasks = [ + { + id: '1', + slug: 'abcabc', + }, + { + id: '2', + slug: 'abcdef', + }, + ]; + + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + id: '1', + ...user, + }, + }) + .expect(201); + + const client = makeClient('/api/data/User/1'); + + await client + .put('/') + .send({ + data: { + userData: { + create: { + a: 0, + }, + }, + tasks: { + create: { + slug: 'abc', + }, + }, + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Invalid input at "userData.create"' + ); + expect(resp.body.message).toContain( + 'Invalid at "tasks.create.slug"' + ); + }); + + await client + .put('/') + .send({ + data: { + userData: { + create: { id: '1', ...userData }, + }, + tasks: { + create: { + id: '1', + slug: 'abcabc', + }, + }, + }, + }) + .expect(200); + + await client + .put('/') + .send({ + data: { + userData: { + update: { + a: 0, + }, + }, + tasks: { + update: { + where: { id: '1' }, + data: { + slug: 'abc', + }, + }, + }, + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Number must be greater than 0 at "userData.update.a"' + ); + expect(resp.body.message).toContain( + 'Invalid at "tasks.update.data.slug"' + ); + }); + + await client + .put('/') + .send({ + data: { + userData: { + update: { + a: 2, + }, + }, + tasks: { + update: { + where: { id: '1' }, + data: { + slug: 'defdef', + }, + }, + }, + }, + }) + .expect(200); + + await client + .put('/') + .send({ + where: { id: '1' }, + data: { + userData: { + upsert: { + create: { + a: 0, + }, + update: { + a: 0, + }, + }, + }, + tasks: { + updateMany: { + where: { id: '1' }, + data: { + slug: 'abc', + }, + }, + }, + }, + }) + .expect(400) + .expect((resp) => { + expect(resp.body.code).toBe( + ServerErrorCode.INVALID_REQUEST_PARAMS + ); + expect(resp.body.message).toContain( + 'Number must be greater than 0 at "userData.upsert.create.a"' + ); + expect(resp.body.message).toContain( + 'Number must be greater than 0 at "userData.upsert.update.a"' + ); + expect(resp.body.message).toContain( + 'Invalid at "tasks.updateMany.data.slug"' + ); + }); + + await client + .put('/') + .send({ + data: { + userData: { + upsert: { + create: { + ...userData, + }, + update: { + a: 1, + }, + }, + }, + tasks: { + updateMany: { + where: { id: '1' }, + data: { + slug: 'xxxyyy', + }, + }, + }, + }, + }) + .expect(200); + }); +}); diff --git a/tests/integration/tests/field-validation.zmodel b/tests/integration/tests/field-validation.zmodel new file mode 100644 index 000000000..f46b5be90 --- /dev/null +++ b/tests/integration/tests/field-validation.zmodel @@ -0,0 +1,44 @@ +datasource db { + provider = 'sqlite' + url = 'file:./field-validation.db' +} + +model User { + id String @id @default(cuid()) + password String @length(8, 16) + email String @email @endsWith("@myorg.com") + profileImage String? @url + handle String @regex("^[0-9a-zA-Z]{4,16}$") + + userData UserData? + tasks Task[] + + @@allow('all', true) +} + +model UserData { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String @unique + + a Int @gt(0) + b Int @gte(0) + c Int @lt(0) + d Int @lte(0) + text1 String @startsWith('abc') + text2 String @endsWith('def') + text3 String @length(min: 3) + text4 String @length(max: 5) + text5 String? @endsWith('xyz') + + @@allow('all', true) +} + +model Task { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id]) + userId String + slug String @regex("^[0-9a-zA-Z]{4,16}$") + + @@allow('all', true) +} diff --git a/tests/integration/tests/logging.test.ts b/tests/integration/tests/logging.test.ts index c76d85c42..9a47dc6a5 100644 --- a/tests/integration/tests/logging.test.ts +++ b/tests/integration/tests/logging.test.ts @@ -151,9 +151,13 @@ describe('Logging tests', () => { gotWarnEmit = true; }); - await makeClient('/api/data/User').post('/').send({ - data: {}, - }); + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + email: 'abc@def.com', + }, + }); expect(gotQueryStd).toBeTruthy(); expect(gotVerboseStd).toBeTruthy(); @@ -239,9 +243,13 @@ describe('Logging tests', () => { gotWarnEmit = true; }); - await makeClient('/api/data/User').post('/').send({ - data: {}, - }); + await makeClient('/api/data/User') + .post('/') + .send({ + data: { + email: 'abc@def.com', + }, + }); expect(gotInfoEmit).toBeTruthy(); expect(gotQueryEmit).toBeTruthy(); diff --git a/tests/integration/tests/todo-e2e.test.ts b/tests/integration/tests/todo-e2e.test.ts index c19d821da..2ca943f08 100644 --- a/tests/integration/tests/todo-e2e.test.ts +++ b/tests/integration/tests/todo-e2e.test.ts @@ -3,12 +3,11 @@ import { makeClient, run, setup } from './utils'; import { ServerErrorCode } from '../../../packages/internal/src/types'; describe('Todo E2E Tests', () => { - let workDir: string; let origDir: string; beforeAll(async () => { origDir = path.resolve('.'); - workDir = await setup('./tests/todo.zmodel'); + await setup('./tests/todo.zmodel'); }); afterAll(() => { diff --git a/tests/integration/tests/todo.zmodel b/tests/integration/tests/todo.zmodel index 2b2dd6347..1529c21fc 100644 --- a/tests/integration/tests/todo.zmodel +++ b/tests/integration/tests/todo.zmodel @@ -7,16 +7,19 @@ datasource db { url = 'file:./todo.db' } + +/* + * Model for a space in which users can collaborate on Lists and Todos + */ model Space { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - name String - slug String @unique + name String @length(4, 50) + slug String @unique @length(4, 16) owner User? @relation(fields: [ownerId], references: [id]) ownerId String? - members SpaceUser[] lists List[] @@ -30,9 +33,12 @@ model Space { @@allow('read', members?[user == auth()]) // space admin can update and delete - @@allow('update,delete', members?[user == auth() && role == "ADMIN"]) + @@allow('update,delete', members?[user == auth() && role == 'ADMIN']) } +/* + * Model representing membership of a user in a space + */ model SpaceUser { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -49,25 +55,25 @@ model SpaceUser { @@deny('all', auth() == null) // space admin can create/update/delete - @@allow('create,update,delete', space.owner == auth() || space.members?[user == auth() && role == "ADMIN"]) + @@allow('create,update,delete', space.members?[user == auth() && role == 'ADMIN']) // user can read entries for spaces which he's a member of @@allow('read', space.members?[user == auth()]) } +/* + * Model for a user + */ model User { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - email String @unique + email String @unique @email emailVerified DateTime? - password String? name String? - ownedSpaces Space[] - spaces SpaceUser[] - image String? + image String? @url lists List[] todos Todo[] @@ -75,12 +81,15 @@ model User { @@allow('create', true) // can be read by users sharing any space - @@allow('read', auth() == this || spaces?[space.members?[user == auth()]]) + @@allow('read', spaces?[space.members?[user == auth()]]) - // can only be updated and deleted by himeself - @@allow('update,delete', auth() == this) + // full access by oneself + @@allow('all', auth() == this) } +/* + * Model for a Todo list + */ model List { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -89,7 +98,7 @@ model List { spaceId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) ownerId String - title String + title String @length(1, 100) private Boolean @default(false) todos Todo[] @@ -99,10 +108,16 @@ model List { // can be read by owner or space members (only if not private) @@allow('read', owner == auth() || (space.members?[user == auth()] && !private)) - // can be updated/deleted by owner with a valid space - @@allow('create,update,delete', owner == auth() && space.members?[user == auth()]) + // when create/udpate, owner must be set to current user, and user must be in the space + @@allow('create,update', owner == auth() && space.members?[user == auth()]) + + // can be deleted by owner + @@allow('delete', owner == auth()) } +/* + * Model for a single Todo + */ model Todo { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -111,7 +126,7 @@ model Todo { ownerId String list List @relation(fields: [listId], references: [id], onDelete: Cascade) listId String - title String + title String @length(1, 100) completedAt DateTime? // require login diff --git a/tests/integration/tests/type-coverage.test.ts b/tests/integration/tests/type-coverage.test.ts index c9dc2c944..31e99ca28 100644 --- a/tests/integration/tests/type-coverage.test.ts +++ b/tests/integration/tests/type-coverage.test.ts @@ -36,8 +36,6 @@ describe('Type Coverage Tests', () => { }) .expect(201) .expect((resp) => { - console.log(resp.body); - expect(resp.body.bigInt).toEqual( expect.objectContaining({ type: 'BigInt', diff --git a/tests/integration/tests/utils.ts b/tests/integration/tests/utils.ts index 825e8a3b8..bc93d8850 100644 --- a/tests/integration/tests/utils.ts +++ b/tests/integration/tests/utils.ts @@ -9,7 +9,7 @@ import { NextApiHandler } from 'next/types'; import supertest from 'supertest'; export function run(cmd: string) { - execSync(cmd, { stdio: 'inherit', encoding: 'utf-8' }); + execSync(cmd, { stdio: 'pipe', encoding: 'utf-8' }); } export async function setup(schemaFile: string) { @@ -33,6 +33,7 @@ export async function setup(schemaFile: string) { 'swr', 'react', 'prisma', + 'zod', '../../../../packages/schema', '../../../../packages/runtime', '../../../../packages/internal',