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',