diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml
index f92512bc8..404f42d3a 100644
--- a/.github/workflows/build-test.yml
+++ b/.github/workflows/build-test.yml
@@ -97,4 +97,4 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Test
- run: pnpm run test-ci
+ run: pnpm run test-scaffold && pnpm run test-ci
diff --git a/.gitignore b/.gitignore
index 9b5f3d7c8..307f58a86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ dist
.npmcache
coverage
.build
+.test
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f1f733983..97319e08c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -35,6 +35,14 @@ I want to think you first for considering contributing to ZenStack 🙏🏻. It'
pnpm build
```
+1. Scaffold the project used for testing
+
+ ```bash
+ pnpm test-scaffold
+ ```
+
+ You only need to run this command once.
+
1. Run tests
```bash
diff --git a/jest.config.ts b/jest.config.ts
index 917cf52f6..b08a6426f 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -3,15 +3,21 @@
* https://jestjs.io/docs/configuration
*/
+import path from 'path';
+
export default {
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
+ globalSetup: path.join(__dirname, './script/test-global-setup.ts'),
+
+ setupFiles: [path.join(__dirname, './script/set-test-env.ts')],
+
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// The directory where Jest should output its coverage files
- coverageDirectory: 'tests/coverage',
+ coverageDirectory: path.join(__dirname, '.test/coverage'),
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ['/node_modules/', '/tests/'],
diff --git a/package.json b/package.json
index 2d2d2e264..dbd80b93b 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,13 @@
{
"name": "zenstack-monorepo",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "",
"scripts": {
"build": "pnpm -r build",
"lint": "pnpm -r lint",
- "test": "ZENSTACK_TEST=1 pnpm -r run test --silent --forceExit",
- "test-ci": "ZENSTACK_TEST=1 pnpm -r run test --silent --forceExit",
+ "test": "pnpm -r --parallel run test --silent --forceExit",
+ "test-ci": "pnpm -r --parallel run test --silent --forceExit",
+ "test-scaffold": "tsx script/test-scaffold.ts",
"publish-all": "pnpm --filter \"./packages/**\" -r publish --access public",
"publish-preview": "pnpm --filter \"./packages/**\" -r publish --force --registry https://preview.registry.zenstack.dev/",
"unpublish-preview": "pnpm --recursive --shell-mode exec -- npm unpublish -f --registry https://preview.registry.zenstack.dev/ \"\\$PNPM_PACKAGE_NAME\""
@@ -30,6 +31,7 @@
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"tsup": "^8.0.1",
+ "tsx": "^4.7.1",
"typescript": "^5.3.2"
}
}
diff --git a/packages/README.md b/packages/README.md
deleted file mode 100644
index 2104ff0eb..000000000
--- a/packages/README.md
+++ /dev/null
@@ -1,122 +0,0 @@
-
-
-## What it is
-
-ZenStack is a toolkit that simplifies the development of a web app's backend. It supercharges [Prisma ORM](https://prisma.io) with a powerful access control layer and unleashes its full potential for web development.
-
-Our goal is to let you save time writing boilerplate code and focus on building real features!
-
-## How it works
-
-ZenStack extended Prisma schema language for supporting custom attributes and functions and, based on that, implemented a flexible access control layer around Prisma.
-
-```prisma
-// schema.zmodel
-
-model Post {
- id String @id
- title String
- published Boolean @default(false)
- author User @relation(fields: [authorId], references: [id])
- authorId String
-
- // 🔐 allow logged-in users to read published posts
- @@allow('read', auth() != null && published)
-
- // 🔐 allow full CRUD by author
- @@allow('all', author == auth())
-}
-```
-
-At runtime, transparent proxies are created around Prisma clients for intercepting queries and mutations to enforce access policies. Moreover, framework integration packages help you wrap an access-control-enabled Prisma client into backend APIs that can be safely called from the frontend.
-
-```ts
-// Next.js example: pages/api/model/[...path].ts
-
-import { requestHandler } from '@zenstackhq/next';
-import { withPolicy } from '@zenstackhq/runtime';
-import { getSessionUser } from '@lib/auth';
-import { prisma } from '@lib/db';
-
-export default requestHandler({
- getPrisma: (req, res) => withPolicy(prisma, { user: getSessionUser(req, res) }),
-});
-```
-
-Plugins can generate strong-typed client libraries that talk to the APIs:
-
-```tsx
-// React example: components/MyPosts.tsx
-
-import { usePost } from '@lib/hooks';
-
-const MyPosts = () => {
- // Post CRUD hooks
- const { findMany } = usePost();
-
- // list all posts that're visible to the current user, together with their authors
- const { data: posts } = findMany({
- include: { author: true },
- orderBy: { createdAt: 'desc' },
- });
-
- return (
-
- {posts?.map((post) => (
- -
- {post.title} by {post.author.name}
-
- ))}
-
- );
-};
-```
-
-## Links
-
-- [Home](https://zenstack.dev)
-- [Documentation](https://zenstack.dev/docs)
-- [Community chat](https://go.zenstack.dev/chat)
-- [Twitter](https://twitter.com/zenstackhq)
-- [Blog](https://dev.to/zenstack)
-
-## Features
-
-- Access control and data validation rules right inside your Prisma schema
-- Auto-generated RESTful API and client library
-- End-to-end type safety
-- Extensible: custom attributes, functions, and a plugin system
-- Framework agnostic
-- Uncompromised performance
-
-## Examples
-
-Check out the [Collaborative Todo App](https://zenstack-todo.vercel.app/) for a running example. You can find the source code below:
-
-- [Next.js + React hooks implementation](https://github.com/zenstackhq/sample-todo-nextjs)
-- [Next.js + tRPC implementation](https://github.com/zenstackhq/sample-todo-trpc)
-
-## Community
-
-Join our [discord server](https://go.zenstack.dev/chat) for chat and updates!
-
-## License
-
-[MIT](LICENSE)
diff --git a/packages/ide/jetbrains/CHANGELOG.md b/packages/ide/jetbrains/CHANGELOG.md
index 4f4625001..1fa15f2eb 100644
--- a/packages/ide/jetbrains/CHANGELOG.md
+++ b/packages/ide/jetbrains/CHANGELOG.md
@@ -1,6 +1,11 @@
# Changelog
## [Unreleased]
+### Added
+- Added support to complex usage of `@@index` attribute like `@@index([content(ops: raw("gin_trgm_ops"))], type: Gin)`.
+### Fixed
+- Fixed several ZModel validation issues related to model inheritance.
+## 1.7.0
### Added
- Auto-completion is now supported inside attributes.
diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts
index b3074746e..2e7742364 100644
--- a/packages/ide/jetbrains/build.gradle.kts
+++ b/packages/ide/jetbrains/build.gradle.kts
@@ -9,7 +9,7 @@ plugins {
}
group = "dev.zenstack"
-version = "1.9.0"
+version = "1.10.0"
repositories {
mavenCentral()
diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json
index 7305853c2..b380dc39f 100644
--- a/packages/ide/jetbrains/package.json
+++ b/packages/ide/jetbrains/package.json
@@ -1,6 +1,6 @@
{
"name": "jetbrains",
- "version": "1.9.0",
+ "version": "1.10.0",
"displayName": "ZenStack JetBrains IDE Plugin",
"description": "ZenStack JetBrains IDE plugin",
"homepage": "https://zenstack.dev",
diff --git a/packages/language/package.json b/packages/language/package.json
index a80768913..562ff434e 100644
--- a/packages/language/package.json
+++ b/packages/language/package.json
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/language",
- "version": "1.9.0",
+ "version": "1.10.0",
"displayName": "ZenStack modeling language compiler",
"description": "ZenStack modeling language compiler",
"homepage": "https://zenstack.dev",
diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json
index e477bc7b8..2c04bf073 100644
--- a/packages/misc/redwood/package.json
+++ b/packages/misc/redwood/package.json
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/redwood",
"displayName": "ZenStack RedwoodJS Integration",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "CLI and runtime for integrating ZenStack with RedwoodJS projects.",
"repository": {
"type": "git",
diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json
index 96a336f58..c13ecd0c0 100644
--- a/packages/plugins/openapi/package.json
+++ b/packages/plugins/openapi/package.json
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/openapi",
"displayName": "ZenStack Plugin and Runtime for OpenAPI",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "ZenStack plugin and runtime supporting OpenAPI",
"main": "index.js",
"repository": {
@@ -17,7 +17,7 @@
"build": "pnpm lint --max-warnings=0 && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE dist && copyfiles -u 1 ./src/plugin.zmodel dist && pnpm pack dist --pack-destination '../../../../.build'",
"watch": "tsc --watch",
"lint": "eslint src --ext ts",
- "test": "ZENSTACK_TEST=1 jest",
+ "test": "jest",
"prepublishOnly": "pnpm build"
},
"keywords": [
diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json
index c64538378..733cd6687 100644
--- a/packages/plugins/swr/package.json
+++ b/packages/plugins/swr/package.json
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/swr",
"displayName": "ZenStack plugin for generating SWR hooks",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "ZenStack plugin for generating SWR hooks",
"main": "index.js",
"repository": {
@@ -13,7 +13,7 @@
"build": "pnpm lint --max-warnings=0 && pnpm clean && tsc && tsup-node --config ./tsup.config.ts && copyfiles ./package.json ./README.md ./LICENSE dist && pnpm pack dist --pack-destination '../../../../.build'",
"watch": "concurrently \"tsc --watch\" \"tsup-node --config ./tsup.config.ts --watch\"",
"lint": "eslint src --ext ts",
- "test": "ZENSTACK_TEST=1 jest",
+ "test": "jest",
"prepublishOnly": "pnpm build"
},
"publishConfig": {
diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json
index 0c16ca59d..05a401f30 100644
--- a/packages/plugins/tanstack-query/package.json
+++ b/packages/plugins/tanstack-query/package.json
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/tanstack-query",
"displayName": "ZenStack plugin for generating tanstack-query hooks",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "ZenStack plugin for generating tanstack-query hooks",
"main": "index.js",
"exports": {
@@ -69,7 +69,7 @@
"build": "pnpm lint --max-warnings=0 && pnpm clean && tsc && tsup-node --config ./tsup.config.ts && tsup-node --config ./tsup-v5.config.ts && node scripts/postbuild && copyfiles ./package.json ./README.md ./LICENSE dist && pnpm pack dist --pack-destination '../../../../.build'",
"watch": "concurrently \"tsc --watch\" \"tsup-node --config ./tsup.config.ts --watch\" \"tsup-node --config ./tsup-v5.config.ts --watch\"",
"lint": "eslint src --ext ts",
- "test": "ZENSTACK_TEST=1 jest",
+ "test": "jest",
"prepublishOnly": "pnpm build"
},
"publishConfig": {
diff --git a/packages/plugins/tanstack-query/src/generator.ts b/packages/plugins/tanstack-query/src/generator.ts
index 10852e826..3dd040f71 100644
--- a/packages/plugins/tanstack-query/src/generator.ts
+++ b/packages/plugins/tanstack-query/src/generator.ts
@@ -216,7 +216,7 @@ function generateMutationHook(
{
name: `_mutation`,
initializer: `
- useModelMutation<${argsType}, ${
+ useModelMutation<${argsType}, DefaultError, ${
overrideReturnType ?? model
}, ${checkReadBack}>('${model}', '${httpVerb.toUpperCase()}', \`\${endpoint}/${lowerCaseFirst(
model
@@ -565,9 +565,9 @@ function makeBaseImports(target: TargetFramework, version: TanStackVersion) {
const runtimeImportBase = makeRuntimeImportBase(version);
const shared = [
`import { useModelQuery, useInfiniteModelQuery, useModelMutation } from '${runtimeImportBase}/${target}';`,
- `import type { PickEnumerable, CheckSelect } from '${runtimeImportBase}';`,
+ `import type { PickEnumerable, CheckSelect, QueryError } from '${runtimeImportBase}';`,
`import metadata from './__model_meta';`,
- `type DefaultError = Error;`,
+ `type DefaultError = QueryError;`,
];
switch (target) {
case 'react': {
@@ -643,11 +643,11 @@ function makeQueryOptions(
function makeMutationOptions(target: string, returnType: string, argsType: string) {
switch (target) {
case 'react':
- return `UseMutationOptions<${returnType}, unknown, ${argsType}>`;
+ return `UseMutationOptions<${returnType}, DefaultError, ${argsType}>`;
case 'vue':
- return `UseMutationOptions<${returnType}, unknown, ${argsType}, unknown>`;
+ return `UseMutationOptions<${returnType}, DefaultError, ${argsType}, unknown>`;
case 'svelte':
- return `MutationOptions<${returnType}, unknown, ${argsType}>`;
+ return `MutationOptions<${returnType}, DefaultError, ${argsType}>`;
default:
throw new PluginError(name, `Unsupported target: ${target}`);
}
diff --git a/packages/plugins/tanstack-query/src/runtime-v5/index.ts b/packages/plugins/tanstack-query/src/runtime-v5/index.ts
index 302b775fc..2954d4683 100644
--- a/packages/plugins/tanstack-query/src/runtime-v5/index.ts
+++ b/packages/plugins/tanstack-query/src/runtime-v5/index.ts
@@ -1,2 +1,2 @@
export * from '../runtime/prisma-types';
-export { type FetchFn, getQueryKey } from '../runtime/common';
+export { type FetchFn, type QueryError, getQueryKey } from '../runtime/common';
diff --git a/packages/plugins/tanstack-query/src/runtime-v5/react.ts b/packages/plugins/tanstack-query/src/runtime-v5/react.ts
index 375cb2676..92194535f 100644
--- a/packages/plugins/tanstack-query/src/runtime-v5/react.ts
+++ b/packages/plugins/tanstack-query/src/runtime-v5/react.ts
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
+ UseSuspenseInfiniteQueryOptions,
+ UseSuspenseQueryOptions,
useInfiniteQuery,
useMutation,
useQuery,
@@ -10,14 +12,11 @@ import {
type UseInfiniteQueryOptions,
type UseMutationOptions,
type UseQueryOptions,
- UseSuspenseInfiniteQueryOptions,
- UseSuspenseQueryOptions,
} from '@tanstack/react-query-v5';
import type { ModelMeta } from '@zenstackhq/runtime/cross';
import { createContext, useContext } from 'react';
import {
DEFAULT_QUERY_ENDPOINT,
- FetchFn,
fetcher,
getQueryKey,
makeUrl,
@@ -25,6 +24,7 @@ import {
setupInvalidation,
setupOptimisticUpdate,
type APIContext,
+ type FetchFn,
} from '../runtime/common';
/**
@@ -167,12 +167,18 @@ export function useSuspenseInfiniteModelQuery(
* @param checkReadBack Whether to check for read back errors and return undefined if found.
* @param optimisticUpdate Whether to enable automatic optimistic update
*/
-export function useModelMutation(
+export function useModelMutation<
+ TArgs,
+ TError,
+ R = any,
+ C extends boolean = boolean,
+ Result = C extends true ? R | undefined : R
+>(
model: string,
method: 'POST' | 'PUT' | 'DELETE',
url: string,
modelMeta: ModelMeta,
- options?: Omit, 'mutationFn'>,
+ options?: Omit, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C,
diff --git a/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts b/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts
index 5f479138e..7de2202d6 100644
--- a/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts
+++ b/packages/plugins/tanstack-query/src/runtime-v5/svelte.ts
@@ -16,13 +16,13 @@ import { Readable, derived } from 'svelte/store';
import {
APIContext,
DEFAULT_QUERY_ENDPOINT,
- FetchFn,
fetcher,
getQueryKey,
makeUrl,
marshal,
setupInvalidation,
setupOptimisticUpdate,
+ type FetchFn,
} from '../runtime/common';
export { APIContext as RequestHandlerContext } from '../runtime/common';
@@ -147,12 +147,18 @@ function isStore(opt: unknown): opt is Readable {
* @param invalidateQueries Whether to invalidate queries after mutation.
* @returns useMutation hooks
*/
-export function useModelMutation(
+export function useModelMutation<
+ TArgs,
+ TError,
+ R = any,
+ C extends boolean = boolean,
+ Result = C extends true ? R | undefined : R
+>(
model: string,
method: 'POST' | 'PUT' | 'DELETE',
url: string,
modelMeta: ModelMeta,
- options?: Omit, 'mutationFn'>,
+ options?: Omit, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C,
diff --git a/packages/plugins/tanstack-query/src/runtime/common.ts b/packages/plugins/tanstack-query/src/runtime/common.ts
index ea64bd3e2..b0d45b246 100644
--- a/packages/plugins/tanstack-query/src/runtime/common.ts
+++ b/packages/plugins/tanstack-query/src/runtime/common.ts
@@ -25,6 +25,21 @@ export const QUERY_KEY_PREFIX = 'zenstack';
*/
export type FetchFn = (url: string, options?: RequestInit) => Promise;
+/**
+ * Type for query and mutation errors.
+ */
+export type QueryError = Error & {
+ /**
+ * Additional error information.
+ */
+ info?: unknown;
+
+ /**
+ * HTTP status code.
+ */
+ status?: number;
+};
+
/**
* Context type for configuring the hooks.
*/
@@ -64,9 +79,7 @@ export async function fetcher(
// policy doesn't allow mutation result to be read back, just return undefined
return undefined as any;
}
- const error: Error & { info?: unknown; status?: number } = new Error(
- 'An error occurred while fetching the data.'
- );
+ const error: QueryError = new Error('An error occurred while fetching the data.');
error.info = errData.error;
error.status = res.status;
throw error;
diff --git a/packages/plugins/tanstack-query/src/runtime/index.ts b/packages/plugins/tanstack-query/src/runtime/index.ts
index 909c0c4bf..0894bc461 100644
--- a/packages/plugins/tanstack-query/src/runtime/index.ts
+++ b/packages/plugins/tanstack-query/src/runtime/index.ts
@@ -1,2 +1,2 @@
export * from './prisma-types';
-export { type FetchFn, getQueryKey } from './common';
+export { type FetchFn, type QueryError, getQueryKey } from './common';
diff --git a/packages/plugins/tanstack-query/src/runtime/react.ts b/packages/plugins/tanstack-query/src/runtime/react.ts
index 2f75d88eb..607b57430 100644
--- a/packages/plugins/tanstack-query/src/runtime/react.ts
+++ b/packages/plugins/tanstack-query/src/runtime/react.ts
@@ -12,7 +12,6 @@ import type { ModelMeta } from '@zenstackhq/runtime/cross';
import { createContext, useContext } from 'react';
import {
DEFAULT_QUERY_ENDPOINT,
- FetchFn,
fetcher,
getQueryKey,
makeUrl,
@@ -20,6 +19,7 @@ import {
setupInvalidation,
setupOptimisticUpdate,
type APIContext,
+ type FetchFn,
} from './common';
/**
@@ -110,12 +110,18 @@ export function useInfiniteModelQuery(
* @param optimisticUpdate Whether to enable automatic optimistic update
* @returns useMutation hooks
*/
-export function useModelMutation(
+export function useModelMutation<
+ TArgs,
+ TError,
+ R = any,
+ C extends boolean = boolean,
+ Result = C extends true ? R | undefined : R
+>(
model: string,
method: 'POST' | 'PUT' | 'DELETE',
url: string,
modelMeta: ModelMeta,
- options?: Omit, 'mutationFn'>,
+ options?: Omit, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C,
diff --git a/packages/plugins/tanstack-query/src/runtime/svelte.ts b/packages/plugins/tanstack-query/src/runtime/svelte.ts
index 88c675a82..dbd0342aa 100644
--- a/packages/plugins/tanstack-query/src/runtime/svelte.ts
+++ b/packages/plugins/tanstack-query/src/runtime/svelte.ts
@@ -13,13 +13,13 @@ import { getContext, setContext } from 'svelte';
import {
APIContext,
DEFAULT_QUERY_ENDPOINT,
- FetchFn,
fetcher,
getQueryKey,
makeUrl,
marshal,
setupInvalidation,
setupOptimisticUpdate,
+ type FetchFn,
} from './common';
export { APIContext as RequestHandlerContext } from './common';
@@ -109,12 +109,18 @@ export function useInfiniteModelQuery(
* @param optimisticUpdate Whether to enable automatic optimistic update.
* @returns useMutation hooks
*/
-export function useModelMutation(
+export function useModelMutation<
+ TArgs,
+ TError,
+ R = any,
+ C extends boolean = boolean,
+ Result = C extends true ? R | undefined : R
+>(
model: string,
method: 'POST' | 'PUT' | 'DELETE',
url: string,
modelMeta: ModelMeta,
- options?: Omit, 'mutationFn'>,
+ options?: Omit, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C,
diff --git a/packages/plugins/tanstack-query/src/runtime/vue.ts b/packages/plugins/tanstack-query/src/runtime/vue.ts
index a0f1055e8..049b66907 100644
--- a/packages/plugins/tanstack-query/src/runtime/vue.ts
+++ b/packages/plugins/tanstack-query/src/runtime/vue.ts
@@ -14,13 +14,13 @@ import { inject, provide } from 'vue';
import {
APIContext,
DEFAULT_QUERY_ENDPOINT,
- FetchFn,
fetcher,
getQueryKey,
makeUrl,
marshal,
setupInvalidation,
setupOptimisticUpdate,
+ type FetchFn,
} from './common';
export { APIContext as RequestHandlerContext } from './common';
@@ -61,7 +61,7 @@ export function useModelQuery(
model: string,
url: string,
args?: unknown,
- options?: UseQueryOptions,
+ options?: Omit, 'queryKey'>,
fetch?: FetchFn,
optimisticUpdate = false
) {
@@ -87,7 +87,7 @@ export function useInfiniteModelQuery(
model: string,
url: string,
args?: unknown,
- options?: UseInfiniteQueryOptions,
+ options?: Omit, 'queryKey'>,
fetch?: FetchFn
) {
return useInfiniteQuery({
@@ -113,12 +113,18 @@ export function useInfiniteModelQuery(
* @param optimisticUpdate Whether to enable automatic optimistic update
* @returns useMutation hooks
*/
-export function useModelMutation(
+export function useModelMutation<
+ TArgs,
+ TError,
+ R = any,
+ C extends boolean = boolean,
+ Result = C extends true ? R | undefined : R
+>(
model: string,
method: 'POST' | 'PUT' | 'DELETE',
url: string,
modelMeta: ModelMeta,
- options?: Omit, 'mutationFn'>,
+ options?: Omit, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C,
@@ -168,5 +174,5 @@ export function useModelMutation(finalOptions);
+ return useMutation(finalOptions);
}
diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json
index 0b53d2aa2..44422a105 100644
--- a/packages/plugins/trpc/package.json
+++ b/packages/plugins/trpc/package.json
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/trpc",
"displayName": "ZenStack plugin for tRPC",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "ZenStack plugin for tRPC",
"main": "index.js",
"repository": {
@@ -13,7 +13,7 @@
"build": "pnpm lint --max-warnings=0 && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE 'res/**/*' dist && pnpm pack dist --pack-destination '../../../../.build'",
"watch": "tsc --watch",
"lint": "eslint src --ext ts",
- "test": "ZENSTACK_TEST=1 jest",
+ "test": "jest",
"prepublishOnly": "pnpm build"
},
"publishConfig": {
diff --git a/packages/runtime/package.json b/packages/runtime/package.json
index 3cb61dd38..d4fb0b2a8 100644
--- a/packages/runtime/package.json
+++ b/packages/runtime/package.json
@@ -1,7 +1,7 @@
{
"name": "@zenstackhq/runtime",
"displayName": "ZenStack Runtime Library",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "Runtime of ZenStack for both client-side and server-side environments.",
"repository": {
"type": "git",
diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/policy/handler.ts
index 698dcd364..e11379cdf 100644
--- a/packages/runtime/src/enhancements/policy/handler.ts
+++ b/packages/runtime/src/enhancements/policy/handler.ts
@@ -467,7 +467,7 @@ export class PolicyProxyHandler implements Pr
// Validates the given create payload against Zod schema if any
private validateCreateInputSchema(model: string, data: any) {
const schema = this.utils.getZodSchema(model, 'create');
- if (schema) {
+ if (schema && data) {
const parseResult = schema.safeParse(data);
if (!parseResult.success) {
throw this.utils.deniedByPolicy(
@@ -496,11 +496,18 @@ export class PolicyProxyHandler implements Pr
args = this.utils.clone(args);
- // do static input validation and check if post-create checks are needed
+ // go through create items, statically check input to determine if post-create
+ // check is needed, and also validate zod schema
let needPostCreateCheck = false;
for (const item of enumerate(args.data)) {
+ const validationResult = this.validateCreateInputSchema(this.model, item);
+ if (validationResult !== item) {
+ this.utils.replace(item, validationResult);
+ }
+
const inputCheck = this.utils.checkInputGuard(this.model, item, 'create');
if (inputCheck === false) {
+ // unconditionally deny
throw this.utils.deniedByPolicy(
this.model,
'create',
@@ -508,14 +515,10 @@ export class PolicyProxyHandler implements Pr
CrudFailureReason.ACCESS_POLICY_VIOLATION
);
} else if (inputCheck === true) {
- const r = this.validateCreateInputSchema(this.model, item);
- if (r !== item) {
- this.utils.replace(item, r);
- }
+ // unconditionally allow
} else if (inputCheck === undefined) {
// static policy check is not possible, need to do post-create check
needPostCreateCheck = true;
- break;
}
}
@@ -786,7 +789,13 @@ export class PolicyProxyHandler implements Pr
// check if the update actually writes to this model
let thisModelUpdate = false;
- const updatePayload: any = (args as any).data ?? args;
+ const updatePayload = (args as any).data ?? args;
+
+ const validatedPayload = this.validateUpdateInputSchema(model, updatePayload);
+ if (validatedPayload !== updatePayload) {
+ this.utils.replace(updatePayload, validatedPayload);
+ }
+
if (updatePayload) {
for (const key of Object.keys(updatePayload)) {
const field = resolveField(this.modelMeta, model, key);
@@ -857,6 +866,8 @@ export class PolicyProxyHandler implements Pr
);
}
+ args.data = this.validateUpdateInputSchema(model, args.data);
+
const updateGuard = this.utils.getAuthGuard(db, model, 'update');
if (this.utils.isTrue(updateGuard) || this.utils.isFalse(updateGuard)) {
// injects simple auth guard into where clause
@@ -917,7 +928,10 @@ export class PolicyProxyHandler implements Pr
await _registerPostUpdateCheck(model, uniqueFilter);
// convert upsert to update
- context.parent.update = { where: args.where, data: args.update };
+ context.parent.update = {
+ where: args.where,
+ data: this.validateUpdateInputSchema(model, args.update),
+ };
delete context.parent.upsert;
// continue visiting the new payload
@@ -1016,6 +1030,37 @@ export class PolicyProxyHandler implements Pr
return { result, postWriteChecks };
}
+ // Validates the given update payload against Zod schema if any
+ private validateUpdateInputSchema(model: string, data: any) {
+ const schema = this.utils.getZodSchema(model, 'update');
+ if (schema && data) {
+ // update payload can contain non-literal fields, like:
+ // { x: { increment: 1 } }
+ // we should only validate literal fields
+
+ const literalData = Object.entries(data).reduce(
+ (acc, [k, v]) => ({ ...acc, ...(typeof v !== 'object' ? { [k]: v } : {}) }),
+ {}
+ );
+
+ const parseResult = schema.safeParse(literalData);
+ if (!parseResult.success) {
+ throw this.utils.deniedByPolicy(
+ model,
+ 'update',
+ `input failed validation: ${fromZodError(parseResult.error)}`,
+ CrudFailureReason.DATA_VALIDATION_VIOLATION,
+ parseResult.error
+ );
+ }
+
+ // schema may have transformed field values, use it to overwrite the original data
+ return { ...data, ...parseResult.data };
+ } else {
+ return data;
+ }
+ }
+
private isUnsafeMutate(model: string, args: any) {
if (!args) {
return false;
@@ -1046,6 +1091,8 @@ export class PolicyProxyHandler implements Pr
args = this.utils.clone(args);
this.utils.injectAuthGuardAsWhere(this.prisma, args, this.model, 'update');
+ args.data = this.validateUpdateInputSchema(this.model, args.data);
+
if (this.utils.hasAuthGuard(this.model, 'postUpdate') || this.utils.getZodSchema(this.model)) {
// use a transaction to do post-update checks
const postWriteChecks: PostWriteCheckRecord[] = [];
diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/policy/policy-utils.ts
index 63b83b79f..ea5816f6c 100644
--- a/packages/runtime/src/enhancements/policy/policy-utils.ts
+++ b/packages/runtime/src/enhancements/policy/policy-utils.ts
@@ -319,7 +319,7 @@ export class PolicyUtil {
/**
* Checks if the given model has a policy guard for the given operation.
*/
- hasAuthGuard(model: string, operation: PolicyOperationKind): boolean {
+ hasAuthGuard(model: string, operation: PolicyOperationKind) {
const guard = this.policy.guard[lowerCaseFirst(model)];
if (!guard) {
return false;
@@ -328,6 +328,21 @@ export class PolicyUtil {
return typeof provider !== 'boolean' || provider !== true;
}
+ /**
+ * Checks if the given model has any field-level override policy guard for the given operation.
+ */
+ hasOverrideAuthGuard(model: string, operation: PolicyOperationKind) {
+ const guard = this.requireGuard(model);
+ switch (operation) {
+ case 'read':
+ return Object.keys(guard).some((k) => k.startsWith(FIELD_LEVEL_OVERRIDE_READ_GUARD_PREFIX));
+ case 'update':
+ return Object.keys(guard).some((k) => k.startsWith(FIELD_LEVEL_OVERRIDE_UPDATE_GUARD_PREFIX));
+ default:
+ return false;
+ }
+ }
+
/**
* Checks model creation policy based on static analysis to the input args.
*
@@ -731,7 +746,7 @@ export class PolicyUtil {
preValue?: any
) {
let guard = this.getAuthGuard(db, model, operation, preValue);
- if (this.isFalse(guard)) {
+ if (this.isFalse(guard) && !this.hasOverrideAuthGuard(model, operation)) {
throw this.deniedByPolicy(
model,
operation,
@@ -904,7 +919,7 @@ export class PolicyUtil {
*/
tryReject(db: Record, model: string, operation: PolicyOperationKind) {
const guard = this.getAuthGuard(db, model, operation);
- if (this.isFalse(guard)) {
+ if (this.isFalse(guard) && !this.hasOverrideAuthGuard(model, operation)) {
throw this.deniedByPolicy(model, operation, undefined, CrudFailureReason.ACCESS_POLICY_VIOLATION);
}
}
diff --git a/packages/schema/package.json b/packages/schema/package.json
index 2451a87c8..0edd09d95 100644
--- a/packages/schema/package.json
+++ b/packages/schema/package.json
@@ -3,7 +3,7 @@
"publisher": "zenstack",
"displayName": "ZenStack Language Tools",
"description": "Build scalable web apps with minimum code by defining authorization and validation rules inside the data schema that closer to the database",
- "version": "1.9.0",
+ "version": "1.10.0",
"author": {
"name": "ZenStack Team"
},
@@ -73,7 +73,7 @@
"bundle": "rimraf bundle && pnpm lint --max-warnings=0 && node build/bundle.js --minify",
"watch": "tsc --watch",
"lint": "eslint src tests --ext ts",
- "test": "ZENSTACK_TEST=1 jest",
+ "test": "jest",
"prepublishOnly": "pnpm build",
"postinstall": "node bin/post-install.js"
},
diff --git a/packages/schema/src/cli/actions/repl.ts b/packages/schema/src/cli/actions/repl.ts
index 6ca3c3503..df15e30fb 100644
--- a/packages/schema/src/cli/actions/repl.ts
+++ b/packages/schema/src/cli/actions/repl.ts
@@ -2,7 +2,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import colors from 'colors';
import path from 'path';
-import prettyRepl from 'pretty-repl';
import { inspect } from 'util';
// inspired by: https://github.com/Kinjalrk2k/prisma-console
@@ -11,6 +10,13 @@ import { inspect } from 'util';
* CLI action for starting a REPL session
*/
export async function repl(projectPath: string, options: { prismaClient?: string; debug?: boolean; table?: boolean }) {
+ if (!process?.stdout?.isTTY && process?.versions?.bun) {
+ console.error('REPL on Bun is only available in a TTY terminal at this time. Please use npm/npx to run the command in this context instead of bun/bunx.');
+ return;
+ }
+
+ const prettyRepl = await import('pretty-repl')
+
console.log('Welcome to ZenStack REPL. See help with the ".help" command.');
console.log('Global variables:');
console.log(` ${colors.blue('db')} to access enhanced PrismaClient`);
diff --git a/packages/schema/src/cli/cli-util.ts b/packages/schema/src/cli/cli-util.ts
index 85c38e82a..c8ede65a2 100644
--- a/packages/schema/src/cli/cli-util.ts
+++ b/packages/schema/src/cli/cli-util.ts
@@ -13,7 +13,7 @@ import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from '../language-server/cons
import { ZModelFormatter } from '../language-server/zmodel-formatter';
import { createZModelServices, ZModelServices } from '../language-server/zmodel-module';
import { mergeBaseModel, resolveImport, resolveTransitiveImports } from '../utils/ast-utils';
-import { findPackageJson } from '../utils/pkg-utils';
+import { findUp } from '../utils/pkg-utils';
import { getVersion } from '../utils/version-utils';
import { CliError } from './cli-error';
@@ -280,7 +280,7 @@ export async function formatDocument(fileName: string) {
export function getDefaultSchemaLocation() {
// handle override from package.json
- const pkgJsonPath = findPackageJson();
+ const pkgJsonPath = findUp(['package.json']);
if (pkgJsonPath) {
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
if (typeof pkgJson?.zenstack?.schema === 'string') {
diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts
index 3096d5257..09af0971c 100644
--- a/packages/schema/src/language-server/validator/datamodel-validator.ts
+++ b/packages/schema/src/language-server/validator/datamodel-validator.ts
@@ -5,6 +5,7 @@ import {
isDataModel,
isStringLiteral,
ReferenceExpr,
+ isEnum,
} from '@zenstackhq/language/ast';
import { getLiteral, getModelIdFields, getModelUniqueFields } from '@zenstackhq/sdk';
import { AstNode, DiagnosticInfo, getDocument, ValidationAcceptor } from 'langium';
@@ -61,8 +62,13 @@ export default class DataModelValidator implements AstValidator {
if (idField.type.optional) {
accept('error', 'Field with @id attribute must not be optional', { node: idField });
}
- if (idField.type.array || !idField.type.type || !SCALAR_TYPES.includes(idField.type.type)) {
- accept('error', 'Field with @id attribute must be of scalar type', { node: idField });
+
+ const isArray = idField.type.array;
+ const isScalar = SCALAR_TYPES.includes(idField.type.type as (typeof SCALAR_TYPES)[number]);
+ const isValidType = isScalar || isEnum(idField.type.reference?.ref);
+
+ if (isArray || !isValidType) {
+ accept('error', 'Field with @id attribute must be of scalar or enum type', { node: idField });
}
});
}
@@ -115,7 +121,7 @@ export default class DataModelValidator implements AstValidator {
fields = (arg.value as ArrayExpr).items as ReferenceExpr[];
if (fields.length === 0) {
if (accept) {
- accept('error', `"fields" value cannot be emtpy`, {
+ accept('error', `"fields" value cannot be empty`, {
node: arg,
});
}
@@ -125,7 +131,7 @@ export default class DataModelValidator implements AstValidator {
references = (arg.value as ArrayExpr).items as ReferenceExpr[];
if (references.length === 0) {
if (accept) {
- accept('error', `"references" value cannot be emtpy`, {
+ accept('error', `"references" value cannot be empty`, {
node: arg,
});
}
@@ -151,6 +157,17 @@ export default class DataModelValidator implements AstValidator {
}
} else {
for (let i = 0; i < fields.length; i++) {
+ if (!field.type.optional && fields[i].$resolvedType?.nullable) {
+ // if relation is not optional, then fk field must not be nullable
+ if (accept) {
+ accept(
+ 'error',
+ `relation "${field.name}" is not optional, but field "${fields[i].target.$refText}" is optional`,
+ { node: fields[i].target.ref! }
+ );
+ }
+ }
+
if (!fields[i].$resolvedType) {
if (accept) {
accept('error', `field reference is unresolved`, { node: fields[i] });
diff --git a/packages/schema/src/language-server/validator/schema-validator.ts b/packages/schema/src/language-server/validator/schema-validator.ts
index b80bf890d..6f868c614 100644
--- a/packages/schema/src/language-server/validator/schema-validator.ts
+++ b/packages/schema/src/language-server/validator/schema-validator.ts
@@ -52,8 +52,9 @@ export default class SchemaValidator implements AstValidator {
private validateImports(model: Model, accept: ValidationAcceptor) {
model.imports.forEach((imp) => {
const importedModel = resolveImport(this.documents, imp);
+ const importPath = imp.path.endsWith('.zmodel') ? imp.path : `${imp.path}.zmodel`;
if (!importedModel) {
- accept('error', `Cannot find model file ${imp.path}.zmodel`, { node: imp });
+ accept('error', `Cannot find model file ${importPath}`, { node: imp });
}
});
}
diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts
index 2aa426b57..0eeea55c5 100644
--- a/packages/schema/src/plugins/prisma/schema-generator.ts
+++ b/packages/schema/src/plugins/prisma/schema-generator.ts
@@ -47,8 +47,8 @@ import stripColor from 'strip-color';
import { name } from '.';
import { getStringLiteral } from '../../language-server/validator/utils';
import telemetry from '../../telemetry';
-import { execSync } from '../../utils/exec-utils';
-import { findPackageJson } from '../../utils/pkg-utils';
+import { execPackage } from '../../utils/exec-utils';
+import { findUp } from '../../utils/pkg-utils';
import {
ModelFieldType,
AttributeArg as PrismaAttributeArg,
@@ -127,7 +127,7 @@ export default class PrismaSchemaGenerator {
if (options.format === true) {
try {
// run 'prisma format'
- await execSync(`npx prisma format --schema ${outFile}`);
+ await execPackage(`prisma format --schema ${outFile}`);
} catch {
warnings.push(`Failed to format Prisma schema file`);
}
@@ -136,18 +136,18 @@ export default class PrismaSchemaGenerator {
const generateClient = options.generateClient !== false;
if (generateClient) {
- let generateCmd = `npx prisma generate --schema "${outFile}"`;
+ let generateCmd = `prisma generate --schema "${outFile}"`;
if (typeof options.generateArgs === 'string') {
generateCmd += ` ${options.generateArgs}`;
}
try {
// run 'prisma generate'
- await execSync(generateCmd, 'ignore');
+ await execPackage(generateCmd, 'ignore');
} catch {
await this.trackPrismaSchemaError(outFile);
try {
// run 'prisma generate' again with output to the console
- await execSync(generateCmd);
+ await execPackage(generateCmd);
} catch {
// noop
}
@@ -450,7 +450,7 @@ export default class PrismaSchemaGenerator {
export function getDefaultPrismaOutputFile(schemaPath: string) {
// handle override from package.json
- const pkgJsonPath = findPackageJson(path.dirname(schemaPath));
+ const pkgJsonPath = findUp(['package.json'], path.dirname(schemaPath));
if (pkgJsonPath) {
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
if (typeof pkgJson?.zenstack?.prisma === 'string') {
diff --git a/packages/schema/src/plugins/zod/utils/schema-gen.ts b/packages/schema/src/plugins/zod/utils/schema-gen.ts
index 39e7d2bb2..889ab1674 100644
--- a/packages/schema/src/plugins/zod/utils/schema-gen.ts
+++ b/packages/schema/src/plugins/zod/utils/schema-gen.ts
@@ -174,7 +174,7 @@ function makeZodSchema(field: DataModelField) {
schema = 'z.boolean()';
break;
case 'DateTime':
- schema = 'z.date()';
+ schema = 'z.coerce.date()';
break;
case 'Bytes':
schema = 'z.union([z.string(), z.instanceof(Uint8Array)])';
diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel
index a3bee6b2e..145ffed60 100644
--- a/packages/schema/src/res/stdlib.zmodel
+++ b/packages/schema/src/res/stdlib.zmodel
@@ -390,7 +390,7 @@ attribute @updatedAt() @@@targetField([DateTimeField]) @@@prisma
/**
* Add full text index (MySQL only).
*/
-attribute @@fulltext(_ fields: FieldReference[]) @@@prisma
+attribute @@fulltext(_ fields: FieldReference[], map: String?) @@@prisma
// String type modifiers
@@ -479,7 +479,7 @@ attribute @db.Bytes() @@@targetField([BytesField]) @@@prisma
attribute @db.ByteA() @@@targetField([BytesField]) @@@prisma
attribute @db.LongBlob() @@@targetField([BytesField]) @@@prisma
attribute @db.Binary() @@@targetField([BytesField]) @@@prisma
-attribute @db.VarBinary() @@@targetField([BytesField]) @@@prisma
+attribute @db.VarBinary(_ x: Int?) @@@targetField([BytesField]) @@@prisma
attribute @db.TinyBlob() @@@targetField([BytesField]) @@@prisma
attribute @db.Blob() @@@targetField([BytesField]) @@@prisma
attribute @db.MediumBlob() @@@targetField([BytesField]) @@@prisma
diff --git a/packages/schema/src/utils/exec-utils.ts b/packages/schema/src/utils/exec-utils.ts
index f355ae2b4..8f0508dbb 100644
--- a/packages/schema/src/utils/exec-utils.ts
+++ b/packages/schema/src/utils/exec-utils.ts
@@ -7,3 +7,12 @@ export function execSync(cmd: string, stdio: StdioOptions = 'inherit', env?: Rec
const mergedEnv = { ...process.env, ...env };
_exec(cmd, { encoding: 'utf-8', stdio, env: mergedEnv });
}
+
+/**
+ * Utility for running package commands through npx/bunx
+ */
+export function execPackage(cmd: string, stdio: StdioOptions = 'inherit', env?: Record): void {
+ const packageManager = process?.versions?.bun ? 'bunx' : 'npx';
+ const mergedEnv = { ...process.env, ...env };
+ _exec(`${packageManager} ${cmd}`, { encoding: 'utf-8', stdio, env: mergedEnv });
+}
\ No newline at end of file
diff --git a/packages/schema/src/utils/pkg-utils.ts b/packages/schema/src/utils/pkg-utils.ts
index ca4ca127d..ce41dac34 100644
--- a/packages/schema/src/utils/pkg-utils.ts
+++ b/packages/schema/src/utils/pkg-utils.ts
@@ -1,20 +1,40 @@
-import fs from 'fs';
-import path from 'path';
+import fs from 'node:fs';
+import path from 'node:path';
import { execSync } from './exec-utils';
export type PackageManagers = 'npm' | 'yarn' | 'pnpm';
-function findUp(names: string[], cwd: string): string | undefined {
- let dir = cwd;
- // eslint-disable-next-line no-constant-condition
- while (true) {
- const target = names.find((name) => fs.existsSync(path.join(dir, name)));
- if (target) return target;
-
- const up = path.resolve(dir, '..');
- if (up === dir) return undefined; // it'll fail anyway
- dir = up;
- }
+/**
+ * A type named FindUp that takes a type parameter e which extends boolean.
+ * If e extends true, it returns a union type of string[] or undefined.
+ * If e does not extend true, it returns a union type of string or undefined.
+ *
+ * @export
+ * @template e A type parameter that extends boolean
+ */
+export type FindUp = e extends true ? string[] | undefined : string | undefined
+/**
+ * Find and return file paths by searching parent directories based on the given names list and current working directory (cwd) path.
+ * Optionally return a single path or multiple paths.
+ * If multiple allowed, return all paths found.
+ * If no paths are found, return undefined.
+ *
+ * @export
+ * @template [e=false]
+ * @param names An array of strings representing names to search for within the directory
+ * @param cwd A string representing the current working directory
+ * @param [multiple=false as e] A boolean flag indicating whether to search for multiple levels. Useful for finding node_modules directories...
+ * @param [result=[]] An array of strings representing the accumulated results used in multiple results
+ * @returns Path(s) to a specific file or folder within the directory or parent directories
+ */
+export function findUp(names: string[], cwd: string = process.cwd(), multiple: e = false as e, result: string[] = []): FindUp {
+ if (!names.some((name) => !!name)) return undefined;
+ const target = names.find((name) => fs.existsSync(path.join(cwd, name)));
+ if (multiple == false && target) return path.join(cwd, target) as FindUp;
+ if (target) result.push(path.join(cwd, target));
+ const up = path.resolve(cwd, '..');
+ if (up === cwd) return (multiple && result.length > 0 ? result : undefined) as FindUp; // it'll fail anyway
+ return findUp(names, up, multiple, result);
}
function getPackageManager(projectPath = '.'): PackageManagers {
@@ -85,6 +105,11 @@ export function ensurePackage(
}
}
+/**
+ * A function that searches for the nearest package.json file starting from the provided search path or the current working directory if no search path is provided.
+ * It iterates through the directory structure going one level up at a time until it finds a package.json file. If no package.json file is found, it returns undefined.
+ * @deprecated Use findUp instead @see findUp
+ */
export function findPackageJson(searchPath?: string) {
let currDir = searchPath ?? process.cwd();
while (currDir) {
@@ -102,7 +127,7 @@ export function findPackageJson(searchPath?: string) {
}
export function getPackageJson(searchPath?: string) {
- const pkgJsonPath = findPackageJson(searchPath);
+ const pkgJsonPath = findUp(['package.json'], searchPath ?? process.cwd());
if (pkgJsonPath) {
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
} else {
diff --git a/packages/schema/tests/schema/stdlib.test.ts b/packages/schema/tests/schema/stdlib.test.ts
index f4b1cc1fe..ad637be7a 100644
--- a/packages/schema/tests/schema/stdlib.test.ts
+++ b/packages/schema/tests/schema/stdlib.test.ts
@@ -24,7 +24,7 @@ describe('Stdlib Tests', () => {
}`
);
}
- throw new SchemaLoadingError(validationErrors.map((e) => e.message));
+ throw new SchemaLoadingError(validationErrors);
}
});
});
diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts
index b86637b58..8eb674b2f 100644
--- a/packages/schema/tests/schema/validation/attribute-validation.test.ts
+++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts
@@ -226,6 +226,25 @@ describe('Attribute tests', () => {
}
`);
+ await loadModel(`
+ ${ prelude }
+ model A {
+ id String @id
+ x String
+ y String
+ z String
+ @@fulltext([x, y, z])
+ }
+
+ model B {
+ id String @id
+ x String
+ y String
+ z String
+ @@fulltext([x, y, z], map: "n")
+ }
+ `);
+
await loadModel(`
${prelude}
model A {
@@ -352,6 +371,7 @@ describe('Attribute tests', () => {
_longBlob Bytes @db.LongBlob
_binary Bytes @db.Binary
_varBinary Bytes @db.VarBinary
+ _varBinarySized Bytes @db.VarBinary(100)
_tinyBlob Bytes @db.TinyBlob
_blob Bytes @db.Blob
_mediumBlob Bytes @db.MediumBlob
diff --git a/packages/schema/tests/schema/validation/datamodel-validation.test.ts b/packages/schema/tests/schema/validation/datamodel-validation.test.ts
index 4212441fe..78e31204d 100644
--- a/packages/schema/tests/schema/validation/datamodel-validation.test.ts
+++ b/packages/schema/tests/schema/validation/datamodel-validation.test.ts
@@ -1,4 +1,4 @@
-import { loadModel, loadModelWithError } from '../../utils';
+import { loadModel, safelyLoadModel, errorLike } from '../../utils';
describe('Data Model Validation Tests', () => {
const prelude = `
@@ -9,20 +9,20 @@ describe('Data Model Validation Tests', () => {
`;
it('duplicated fields', async () => {
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- id String @id
- x Int
- x String
- }
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ id String @id
+ x Int
+ x String
+ }
`)
- ).toContain('Duplicated declaration name "x"');
+
+ expect(result).toMatchObject(errorLike('Duplicated declaration name "x"'));
});
it('scalar types', async () => {
- await loadModel(`
+ const result = await safelyLoadModel(`
${prelude}
model M {
id String @id
@@ -38,33 +38,36 @@ describe('Data Model Validation Tests', () => {
i Bytes
}
`);
+ expect(result).toMatchObject({ status: 'fulfilled' });
});
it('Unsupported type valid arg', async () => {
- await loadModel(`
+ const result = await safelyLoadModel(`
${prelude}
model M {
id String @id
a Unsupported('foo')
}
`);
+
+ expect(result).toMatchObject({ status: 'fulfilled' });
});
it('Unsupported type invalid arg', async () => {
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model M {
id String @id
a Unsupported(123)
}
`)
- ).toContain('Unsupported type argument must be a string literal');
+ ).toMatchObject(errorLike('Unsupported type argument must be a string literal'));
});
it('Unsupported type used in expression', async () => {
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model M {
id String @id
@@ -72,208 +75,258 @@ describe('Data Model Validation Tests', () => {
@@allow('all', a == 'a')
}
`)
- ).toContain('Field of "Unsupported" type cannot be used in expressions');
+ ).toMatchObject(errorLike('Field of "Unsupported" type cannot be used in expressions'));
});
it('mix array and optional', async () => {
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model M {
id String @id
x Int[]?
}
`)
- ).toContain('Optional lists are not supported. Use either `Type[]` or `Type?`');
+ ).toMatchObject(errorLike('Optional lists are not supported. Use either `Type[]` or `Type?`'));
});
it('unresolved field type', async () => {
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model M {
id String @id
x Integer
}
`)
- ).toContain(`Could not resolve reference to TypeDeclaration named 'Integer'.`);
+ ).toMatchObject(errorLike(`Could not resolve reference to TypeDeclaration named 'Integer'.`));
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model M {
id String @id
x Integer[]
}
`)
- ).toContain(`Could not resolve reference to TypeDeclaration named 'Integer'.`);
+ ).toMatchObject(errorLike(`Could not resolve reference to TypeDeclaration named 'Integer'.`));
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model M {
id String @id
x Integer?
}
`)
- ).toContain(`Could not resolve reference to TypeDeclaration named 'Integer'.`);
+ ).toMatchObject(errorLike(`Could not resolve reference to TypeDeclaration named 'Integer'.`));
});
- it('id field', async () => {
+ describe('id field', () => {
const err =
'Model must have at least one unique criteria. Either mark a single field with `@id`, `@unique` or add a multi field criterion with `@@id([])` or `@@unique([])` to the model.';
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int
- @@allow('all', x > 0)
- }
- `)
- ).toContain(err);
+ it('should error when there are no unique fields', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int
+ @@allow('all', x > 0)
+ }
+ `)
+ expect(result).toMatchObject(errorLike(err));
+ })
- // @unique used as id
- await loadModel(`
- ${prelude}
- model M {
- id Int @unique
- x Int
- @@allow('all', x > 0)
- }
- `);
+ it('should should use @unique when there is no @id', async () => {
+ const result = await safelyLoadModel(`
+ ${prelude}
+ model M {
+ id Int @unique
+ x Int
+ @@allow('all', x > 0)
+ }
+ `);
+ expect(result).toMatchObject({ status: 'fulfilled' });
+ })
// @@unique used as id
- await loadModel(`
- ${prelude}
+ it('should suceed when @@unique used as id', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
model M {
x Int
@@unique([x])
@@allow('all', x > 0)
}
`);
+ expect(result).toMatchObject({ status: 'fulfilled' });
+ })
+
+ it('should succeed when @id is an enum type', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ enum E {
+ A
+ B
+ }
+ model M {
+ id E @id
+ }
+ `);
+ expect(result).toMatchObject({ status: 'fulfilled' });
+ })
+
+ it('should succeed when @@id is an enum type', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ enum E {
+ A
+ B
+ }
+ model M {
+ x Int
+ y E
+ @@id([x, y])
+ }
+ `);
+ expect(result).toMatchObject({ status: 'fulfilled' });
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int
- @@deny('all', x <= 0)
- }
- `)
- ).toContain(err);
+ it('should error when there are no id fields, even when denying access', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int
+ @@deny('all', x <= 0)
+ }
+ `)
+
+ expect(result).toMatchObject(errorLike(err));
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int @gt(0)
- }
- `)
- ).toContain(err);
+ it('should error when there are not id fields, without access restrictions', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int @gt(0)
+ }
+ `)
+
+ expect(result).toMatchObject(errorLike(err));
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int @id
- y Int @id
- }
- `)
- ).toContain(`Model can include at most one field with @id attribute`);
+ it('should error when there is more than one field marked as @id', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int @id
+ y Int @id
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Model can include at most one field with @id attribute`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int @id
- y Int
- @@id([x, y])
- }
- `)
- ).toContain(`Model cannot have both field-level @id and model-level @@id attributes`);
+ it('should error when both @id and @@id are used', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int @id
+ y Int
+ @@id([x, y])
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Model cannot have both field-level @id and model-level @@id attributes`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int? @id
- }
- `)
- ).toContain(`Field with @id attribute must not be optional`);
+ it('should error when @id used on optional field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int? @id
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must not be optional`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int?
- @@id([x])
- }
- `)
- ).toContain(`Field with @id attribute must not be optional`);
+ it('should error when @@id used on optional field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int?
+ @@id([x])
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must not be optional`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int[] @id
- }
- `)
- ).toContain(`Field with @id attribute must be of scalar type`);
+ it('should error when @id used on list field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int[] @id
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Int[]
- @@id([x])
- }
- `)
- ).toContain(`Field with @id attribute must be of scalar type`);
+ it('should error when @@id used on list field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Int[]
+ @@id([x])
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Json @id
- }
- `)
- ).toContain(`Field with @id attribute must be of scalar type`);
+ it('should error when @id used on a Json field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Json @id
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model M {
- x Json
- @@id([x])
- }
- `)
- ).toContain(`Field with @id attribute must be of scalar type`);
+ it('should error when @@id used on a Json field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model M {
+ x Json
+ @@id([x])
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model Id {
- id String @id
- }
- model M {
- myId Id @id
- }
- `)
- ).toContain(`Field with @id attribute must be of scalar type`);
+ it('should error when @id used on a reference field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model Id {
+ id String @id
+ }
+ model M {
+ myId Id @id
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`))
+ })
- expect(
- await loadModelWithError(`
- ${prelude}
- model Id {
- id String @id
- }
- model M {
- myId Id
- @@id([myId])
- }
- `)
- ).toContain(`Field with @id attribute must be of scalar type`);
+ it('should error when @@id used on a reference field', async () => {
+ const result = await safelyLoadModel(`
+ ${ prelude }
+ model Id {
+ id String @id
+ }
+ model M {
+ myId Id
+ @@id([myId])
+ }
+ `)
+ expect(result).toMatchObject(errorLike(`Field with @id attribute must be of scalar or enum type`))
+ })
});
it('relation', async () => {
@@ -326,7 +379,7 @@ describe('Data Model Validation Tests', () => {
// one-to-one incomplete
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -337,11 +390,11 @@ describe('Data Model Validation Tests', () => {
id String @id
}
`)
- ).toContain(`The relation field "b" on model "A" is missing an opposite relation field on model "B"`);
+ ).toMatchObject(errorLike(`The relation field "b" on model "A" is missing an opposite relation field on model "B"`));
// one-to-one ambiguous
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -354,11 +407,11 @@ describe('Data Model Validation Tests', () => {
a1 A
}
`)
- ).toContain(`Fields "a", "a1" on model "B" refer to the same relation to model "A"`);
+ ).toMatchObject(errorLike(`Fields "a", "a1" on model "B" refer to the same relation to model "A"`));
// fields or references missing
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -371,11 +424,11 @@ describe('Data Model Validation Tests', () => {
aId String
}
`)
- ).toContain(`Both "fields" and "references" must be provided`);
+ ).toMatchObject(errorLike(`Both "fields" and "references" must be provided`));
// one-to-one inconsistent attribute
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -388,11 +441,11 @@ describe('Data Model Validation Tests', () => {
aId String
}
`)
- ).toContain(`"fields" and "references" must be provided only on one side of relation field`);
+ ).toMatchObject(errorLike(`"fields" and "references" must be provided only on one side of relation field`));
// references mismatch
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
myId Int @id
@@ -405,11 +458,11 @@ describe('Data Model Validation Tests', () => {
aId String @unique
}
`)
- ).toContain(`values of "references" and "fields" must have the same type`);
+ ).toMatchObject(errorLike(`values of "references" and "fields" must have the same type`));
// "fields" and "references" typing consistency
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id Int @id
@@ -422,11 +475,11 @@ describe('Data Model Validation Tests', () => {
aId String @unique
}
`)
- ).toContain(`values of "references" and "fields" must have the same type`);
+ ).toMatchObject(errorLike(`values of "references" and "fields" must have the same type`));
// one-to-one missing @unique
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -439,13 +492,11 @@ describe('Data Model Validation Tests', () => {
aId String
}
`)
- ).toContain(
- `Field "aId" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`
- );
+ ).toMatchObject(errorLike(`Field "aId" is part of a one-to-one relation and must be marked as @unique or be part of a model-level @@unique attribute`));
// missing @relation
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -457,13 +508,11 @@ describe('Data Model Validation Tests', () => {
a A
}
`)
- ).toContain(
- `Field for one side of relation must carry @relation attribute with both "fields" and "references" fields`
- );
+ ).toMatchObject(errorLike(`Field for one side of relation must carry @relation attribute with both "fields" and "references" fields`));
// wrong relation owner field type
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -476,11 +525,11 @@ describe('Data Model Validation Tests', () => {
aId String
}
`)
- ).toContain(`Relation field needs to be list or optional`);
+ ).toMatchObject(errorLike(`Relation field needs to be list or optional`));
// unresolved field
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model A {
id String @id
@@ -492,7 +541,7 @@ describe('Data Model Validation Tests', () => {
a A @relation(fields: [aId], references: [id])
}
`)
- ).toContain(`Could not resolve reference to ReferenceTarget named 'aId'.`);
+ ).toMatchObject(errorLike(`Could not resolve reference to ReferenceTarget named 'aId'.`));
// enum as foreign key
await loadModel(`
@@ -607,7 +656,7 @@ describe('Data Model Validation Tests', () => {
});
it('abstract base type', async () => {
- const errors = await loadModelWithError(`
+ const errors = await safelyLoadModel(`
${prelude}
abstract model Base {
@@ -623,11 +672,11 @@ describe('Data Model Validation Tests', () => {
}
`);
- expect(errors).toContain(`Model A cannot be extended because it's not abstract`);
+ expect(errors).toMatchObject(errorLike(`Model A cannot be extended because it's not abstract`));
// relation incomplete from multiple level inheritance
expect(
- await loadModelWithError(`
+ await safelyLoadModel(`
${prelude}
model User {
id Int @id @default(autoincrement())
@@ -647,6 +696,6 @@ describe('Data Model Validation Tests', () => {
a String
}
`)
- ).toContain(`The relation field "user" on model "A" is missing an opposite relation field on model "User"`);
+ ).toMatchObject(errorLike(`The relation field "user" on model "A" is missing an opposite relation field on model "User"`));
});
});
diff --git a/packages/schema/tests/schema/validation/datasource-validation.test.ts b/packages/schema/tests/schema/validation/datasource-validation.test.ts
index 19be1f076..469ba5ac1 100644
--- a/packages/schema/tests/schema/validation/datasource-validation.test.ts
+++ b/packages/schema/tests/schema/validation/datasource-validation.test.ts
@@ -1,18 +1,21 @@
-import { loadModel, loadModelWithError } from '../../utils';
+import { loadModel, loadModelWithError, safelyLoadModel } from '../../utils';
describe('Datasource Validation Tests', () => {
it('missing fields', async () => {
- expect(
- await loadModelWithError(`
+ const result = await safelyLoadModel(`
datasource db {
}
- `)
- ).toEqual(
- expect.arrayContaining([
- 'datasource must include a "provider" field',
- 'datasource must include a "url" field',
- ])
- );
+ `);
+
+ expect(result).toMatchObject({
+ status: 'rejected',
+ reason: {
+ cause: [
+ { message: 'datasource must include a "provider" field' },
+ { message: 'datasource must include a "url" field' },
+ ]
+ }
+ })
});
it('dup fields', async () => {
@@ -41,7 +44,7 @@ describe('Datasource Validation Tests', () => {
provider = 'abc'
}
`)
- ).toContainEqual(expect.stringContaining('Provider "abc" is not supported'));
+ ).toContain('Provider "abc" is not supported');
});
it('invalid url value', async () => {
diff --git a/packages/schema/tests/schema/validation/schema-validation.test.ts b/packages/schema/tests/schema/validation/schema-validation.test.ts
index 5f1cc6254..ca0efa697 100644
--- a/packages/schema/tests/schema/validation/schema-validation.test.ts
+++ b/packages/schema/tests/schema/validation/schema-validation.test.ts
@@ -39,6 +39,20 @@ describe('Toplevel Schema Validation Tests', () => {
).toContain('Cannot find model file models/abc.zmodel');
});
+ it('not existing import with extension', async () => {
+ expect(
+ await loadModelWithError(`
+ import 'models/abc.zmodel'
+ datasource db1 {
+ provider = 'postgresql'
+ url = env('DATABASE_URL')
+ }
+
+ model X {id String @id }
+ `)
+ ).toContain('Cannot find model file models/abc.zmodel');
+ })
+
it('multiple auth models', async () => {
expect(
await loadModelWithError(`
diff --git a/packages/schema/tests/utils.ts b/packages/schema/tests/utils.ts
index f88aae6e2..7369838f5 100644
--- a/packages/schema/tests/utils.ts
+++ b/packages/schema/tests/utils.ts
@@ -7,9 +7,21 @@ import { URI } from 'vscode-uri';
import { createZModelServices } from '../src/language-server/zmodel-module';
import { mergeBaseModel } from '../src/utils/ast-utils';
-export class SchemaLoadingError extends Error {
- constructor(public readonly errors: string[]) {
- super('Schema error:\n' + errors.join('\n'));
+type Errorish = Error | { message: string; stack?: string } | string;
+
+export class SchemaLoadingError extends Error {
+ cause: Errors
+ constructor(public readonly errors: Errors) {
+ const stack = errors.find((e): e is typeof e & { stack: string } => typeof e === 'object' && 'stack' in e)?.stack;
+ const message = errors.map((e) => (typeof e === 'string' ? e : e.message)).join('\n');
+
+ super(`Schema error:\n${ message }`);
+
+ if (stack) {
+ const shiftedStack = stack.split('\n').slice(1).join('\n');
+ this.stack = shiftedStack
+ }
+ this.cause = errors
}
}
@@ -23,11 +35,11 @@ export async function loadModel(content: string, validate = true, verbose = true
const doc = shared.workspace.LangiumDocuments.getOrCreateDocument(URI.file(docPath));
if (doc.parseResult.lexerErrors.length > 0) {
- throw new SchemaLoadingError(doc.parseResult.lexerErrors.map((e) => e.message));
+ throw new SchemaLoadingError(doc.parseResult.lexerErrors);
}
if (doc.parseResult.parserErrors.length > 0) {
- throw new SchemaLoadingError(doc.parseResult.parserErrors.map((e) => e.message));
+ throw new SchemaLoadingError(doc.parseResult.parserErrors);
}
await shared.workspace.DocumentBuilder.build([stdLib, doc], {
@@ -46,7 +58,7 @@ export async function loadModel(content: string, validate = true, verbose = true
);
}
}
- throw new SchemaLoadingError(validationErrors.map((e) => e.message));
+ throw new SchemaLoadingError(validationErrors);
}
const model = (await doc.parseResult.value) as Model;
@@ -65,7 +77,19 @@ export async function loadModelWithError(content: string, verbose = false) {
if (!(err instanceof SchemaLoadingError)) {
throw err;
}
- return (err as SchemaLoadingError).errors;
+ return (err as SchemaLoadingError).message;
}
throw new Error('No error is thrown');
}
+
+export async function safelyLoadModel(content: string, validate = true, verbose = false) {
+ const [ result ] = await Promise.allSettled([ loadModel(content, validate, verbose) ]);
+
+ return result
+}
+
+export const errorLike = (msg: string) => ({
+ reason: {
+ message: expect.stringContaining(msg)
+ },
+})
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 99d7ba495..d09ea775c 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/sdk",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "ZenStack plugin development SDK",
"main": "index.js",
"scripts": {
diff --git a/packages/server/package.json b/packages/server/package.json
index 7ed1e3e29..37b7c4fd3 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/server",
- "version": "1.9.0",
+ "version": "1.10.0",
"displayName": "ZenStack Server-side Adapters",
"description": "ZenStack server-side adapters",
"homepage": "https://zenstack.dev",
@@ -9,7 +9,7 @@
"build": "pnpm lint --max-warnings=0 && pnpm clean && tsc && copyfiles ./package.json ./README.md ./LICENSE dist && pnpm pack dist --pack-destination '../../../.build'",
"watch": "tsc --watch",
"lint": "eslint src --ext ts",
- "test": "ZENSTACK_TEST=1 jest",
+ "test": "jest",
"prepublishOnly": "pnpm build"
},
"publishConfig": {
diff --git a/packages/server/tests/api/rest.test.ts b/packages/server/tests/api/rest.test.ts
index 7b084ef8a..7af912ec3 100644
--- a/packages/server/tests/api/rest.test.ts
+++ b/packages/server/tests/api/rest.test.ts
@@ -40,10 +40,11 @@ describe('REST server tests', () => {
id Int @id @default(autoincrement())
createdAt DateTime @default (now())
updatedAt DateTime @updatedAt
- title String
+ title String @length(1, 10)
author User? @relation(fields: [authorId], references: [myId])
authorId String?
published Boolean @default(false)
+ publishedAt DateTime?
viewCount Int @default(0)
comments Comment[]
setting Setting?
@@ -1293,6 +1294,49 @@ describe('REST server tests', () => {
});
});
+ it('creates an item with date coercion', async () => {
+ const r = await handler({
+ method: 'post',
+ path: '/post',
+ query: {},
+ requestBody: {
+ data: {
+ type: 'post',
+ attributes: {
+ id: 1,
+ title: 'Post1',
+ published: true,
+ publishedAt: '2024-03-02T05:00:00.000Z',
+ },
+ },
+ },
+ prisma,
+ });
+
+ expect(r.status).toBe(201);
+ });
+
+ it('creates an item with zod violation', async () => {
+ const r = await handler({
+ method: 'post',
+ path: '/post',
+ query: {},
+ requestBody: {
+ data: {
+ type: 'post',
+ attributes: {
+ id: 1,
+ title: 'a very very long long title',
+ },
+ },
+ },
+ prisma,
+ });
+
+ expect(r.status).toBe(400);
+ expect(r.body.errors[0].code).toBe('invalid-payload');
+ });
+
it('creates an item with collection relations', async () => {
await prisma.post.create({
data: { id: 1, title: 'Post1' },
@@ -1586,6 +1630,50 @@ describe('REST server tests', () => {
});
});
+ it('update an item with date coercion', async () => {
+ await prisma.post.create({ data: { id: 1, title: 'Post1' } });
+
+ const r = await handler({
+ method: 'put',
+ path: '/post/1',
+ query: {},
+ requestBody: {
+ data: {
+ type: 'post',
+ attributes: {
+ published: true,
+ publishedAt: '2024-03-02T05:00:00.000Z',
+ },
+ },
+ },
+ prisma,
+ });
+
+ expect(r.status).toBe(200);
+ });
+
+ it('update an item with zod violation', async () => {
+ await prisma.post.create({ data: { id: 1, title: 'Post1' } });
+
+ const r = await handler({
+ method: 'put',
+ path: '/post/1',
+ query: {},
+ requestBody: {
+ data: {
+ type: 'post',
+ attributes: {
+ publishedAt: '2024-13-01',
+ },
+ },
+ },
+ prisma,
+ });
+
+ expect(r.status).toBe(400);
+ expect(r.body.errors[0].code).toBe('invalid-payload');
+ });
+
it('update a single relation', async () => {
await prisma.user.create({ data: { myId: 'user1', email: 'user1@abc.com' } });
await prisma.post.create({
diff --git a/packages/server/tests/utils.ts b/packages/server/tests/utils.ts
index d1e0a0ffc..472a6818d 100644
--- a/packages/server/tests/utils.ts
+++ b/packages/server/tests/utils.ts
@@ -20,6 +20,7 @@ model Post {
author User? @relation(fields: [authorId], references: [id])
authorId String?
published Boolean @default(false)
+ publishedAt DateTime?
viewCount Int @default(0)
@@allow('all', author == auth())
diff --git a/packages/testtools/package.json b/packages/testtools/package.json
index 0aafe452e..1ee34f784 100644
--- a/packages/testtools/package.json
+++ b/packages/testtools/package.json
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/testtools",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "ZenStack Test Tools",
"main": "index.js",
"private": true,
@@ -11,7 +11,7 @@
"scripts": {
"clean": "rimraf dist",
"lint": "eslint src --ext ts",
- "build": "pnpm lint && pnpm clean && tsc && copyfiles ./package.json ./LICENSE ./README.md dist && copyfiles -u 1 src/package.template.json src/.npmrc.template dist && pnpm pack dist --pack-destination '../../../.build'",
+ "build": "pnpm lint && pnpm clean && tsc && copyfiles ./package.json ./LICENSE ./README.md dist && pnpm pack dist --pack-destination '../../../.build'",
"watch": "tsc --watch",
"prepublishOnly": "pnpm build"
},
diff --git a/packages/testtools/src/.npmrc.template b/packages/testtools/src/.npmrc.template
deleted file mode 100644
index 14f2c2865..000000000
--- a/packages/testtools/src/.npmrc.template
+++ /dev/null
@@ -1 +0,0 @@
-cache=/.npmcache
diff --git a/packages/testtools/src/package.template.json b/packages/testtools/src/package.template.json
deleted file mode 100644
index 8ea542361..000000000
--- a/packages/testtools/src/package.template.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "name": "test-run",
- "version": "1.0.0",
- "description": "",
- "main": "index.js",
- "keywords": [],
- "author": "",
- "license": "ISC",
- "dependencies": {
- "@prisma/client": "^4.8.0",
- "@zenstackhq/runtime": "file:/packages/runtime/dist",
- "@zenstackhq/swr": "file:/packages/plugins/swr/dist",
- "@zenstackhq/trpc": "file:/packages/plugins/trpc/dist",
- "@zenstackhq/openapi": "file:/packages/plugins/openapi/dist",
- "prisma": "^4.8.0",
- "typescript": "^4.9.3",
- "zenstack": "file:/packages/schema/dist",
- "zod": "^3.22.4",
- "decimal.js": "^10.4.2"
- }
-}
diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts
index f69a845cc..c570c6a30 100644
--- a/packages/testtools/src/schema.ts
+++ b/packages/testtools/src/schema.ts
@@ -35,14 +35,23 @@ export type FullDbClientContract = Record & {
};
export function run(cmd: string, env?: Record, cwd?: string) {
- const start = Date.now();
- execSync(cmd, {
- stdio: 'pipe',
- encoding: 'utf-8',
- env: { ...process.env, DO_NOT_TRACK: '1', ...env },
- cwd,
- });
- console.log('Execution took', Date.now() - start, 'ms', '-', cmd);
+ try {
+ const start = Date.now();
+ execSync(cmd, {
+ stdio: 'pipe',
+ encoding: 'utf-8',
+ env: { ...process.env, DO_NOT_TRACK: '1', ...env },
+ cwd,
+ });
+ console.log('Execution took', Date.now() - start, 'ms', '-', cmd);
+ } catch (err) {
+ console.error('Command failed:', cmd, err);
+ throw err;
+ }
+}
+
+export function installPackage(pkg: string, dev = false) {
+ run(`npm install ${dev ? '-D' : ''} --no-audit --no-fund ${pkg}`);
}
function normalizePath(p: string) {
@@ -86,17 +95,17 @@ generator js {
plugin meta {
provider = '@core/model-meta'
- preserveTsFiles = true
+ // preserveTsFiles = true
}
plugin policy {
provider = '@core/access-policy'
- preserveTsFiles = true
+ // preserveTsFiles = true
}
plugin zod {
provider = '@core/zod'
- preserveTsFiles = true
+ // preserveTsFiles = true
modelOnly = ${!options.fullZod}
}
`;
@@ -138,21 +147,29 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) {
const { name: projectRoot } = tmp.dirSync({ unsafeCleanup: true });
- const root = getWorkspaceRoot(__dirname);
+ const workspaceRoot = getWorkspaceRoot(__dirname);
- if (!root) {
+ if (!workspaceRoot) {
throw new Error('Could not find workspace root');
}
- const pkgContent = fs.readFileSync(path.join(__dirname, 'package.template.json'), { encoding: 'utf-8' });
- fs.writeFileSync(path.join(projectRoot, 'package.json'), pkgContent.replaceAll('', root));
-
- const npmrcContent = fs.readFileSync(path.join(__dirname, '.npmrc.template'), { encoding: 'utf-8' });
- fs.writeFileSync(path.join(projectRoot, '.npmrc'), npmrcContent.replaceAll('', root));
-
console.log('Workdir:', projectRoot);
process.chdir(projectRoot);
+ // copy project structure from scaffold (prepared by test-setup.ts)
+ fs.cpSync(path.join(workspaceRoot, '.test/scaffold'), projectRoot, { recursive: true, force: true });
+
+ // install local deps
+ const localInstallDeps = [
+ 'packages/schema/dist',
+ 'packages/runtime/dist',
+ 'packages/plugins/swr/dist',
+ 'packages/plugins/trpc/dist',
+ 'packages/plugins/openapi/dist',
+ ];
+
+ run(`npm i --no-audit --no-fund ${localInstallDeps.map((d) => path.join(workspaceRoot, d)).join(' ')}`);
+
let zmodelPath = path.join(projectRoot, 'schema.zmodel');
const files = schema.split(FILE_SPLITTER);
@@ -189,16 +206,16 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) {
}
}
- run('npm install');
-
const outputArg = opt.output ? ` --output ${opt.output}` : '';
if (opt.customSchemaFilePath) {
- run(`npx zenstack generate --schema ${zmodelPath} --no-dependency-check${outputArg}`, {
+ run(`npx zenstack generate --no-version-check --schema ${zmodelPath} --no-dependency-check${outputArg}`, {
NODE_PATH: './node_modules',
});
} else {
- run(`npx zenstack generate --no-dependency-check${outputArg}`, { NODE_PATH: './node_modules' });
+ run(`npx zenstack generate --no-version-check --no-dependency-check${outputArg}`, {
+ NODE_PATH: './node_modules',
+ });
}
if (opt.pushDb) {
@@ -209,10 +226,10 @@ export async function loadSchema(schema: string, options?: SchemaLoadOptions) {
opt.extraDependencies?.push('@prisma/extension-pulse');
}
- opt.extraDependencies?.forEach((dep) => {
- console.log(`Installing dependency ${dep}`);
- run(`npm install ${dep}`);
- });
+ if (opt.extraDependencies) {
+ console.log(`Installing dependency ${opt.extraDependencies.join(' ')}`);
+ installPackage(opt.extraDependencies.join(' '));
+ }
opt.copyDependencies?.forEach((dep) => {
const pkgJson = JSON.parse(fs.readFileSync(path.join(dep, 'package.json'), { encoding: 'utf-8' }));
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9dcef261b..b432b95c7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
tsup:
specifier: ^8.0.1
version: 8.0.1(ts-node@10.9.1)(typescript@5.3.2)
+ tsx:
+ specifier: ^4.7.1
+ version: 4.7.1
typescript:
specifier: ^5.3.2
version: 5.3.2
@@ -1594,6 +1597,15 @@ packages:
tslib: 2.6.0
dev: true
+ /@esbuild/aix-ppc64@0.19.12:
+ resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [aix]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/android-arm64@0.17.19:
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
engines: {node: '>=12'}
@@ -1612,6 +1624,15 @@ packages:
dev: true
optional: true
+ /@esbuild/android-arm64@0.19.12:
+ resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/android-arm64@0.19.4:
resolution: {integrity: sha512-mRsi2vJsk4Bx/AFsNBqOH2fqedxn5L/moT58xgg51DjX1la64Z3Npicut2VbhvDFO26qjWtPMsVxCd80YTFVeg==}
engines: {node: '>=12'}
@@ -1648,6 +1669,15 @@ packages:
dev: true
optional: true
+ /@esbuild/android-arm@0.19.12:
+ resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/android-arm@0.19.4:
resolution: {integrity: sha512-uBIbiYMeSsy2U0XQoOGVVcpIktjLMEKa7ryz2RLr7L/vTnANNEsPVAh4xOv7ondGz6ac1zVb0F8Jx20rQikffQ==}
engines: {node: '>=12'}
@@ -1675,6 +1705,15 @@ packages:
dev: true
optional: true
+ /@esbuild/android-x64@0.19.12:
+ resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/android-x64@0.19.4:
resolution: {integrity: sha512-4iPufZ1TMOD3oBlGFqHXBpa3KFT46aLl6Vy7gwed0ZSYgHaZ/mihbYb4t7Z9etjkC9Al3ZYIoOaHrU60gcMy7g==}
engines: {node: '>=12'}
@@ -1702,6 +1741,15 @@ packages:
dev: true
optional: true
+ /@esbuild/darwin-arm64@0.19.12:
+ resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/darwin-arm64@0.19.4:
resolution: {integrity: sha512-Lviw8EzxsVQKpbS+rSt6/6zjn9ashUZ7Tbuvc2YENgRl0yZTktGlachZ9KMJUsVjZEGFVu336kl5lBgDN6PmpA==}
engines: {node: '>=12'}
@@ -1729,6 +1777,15 @@ packages:
dev: true
optional: true
+ /@esbuild/darwin-x64@0.19.12:
+ resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/darwin-x64@0.19.4:
resolution: {integrity: sha512-YHbSFlLgDwglFn0lAO3Zsdrife9jcQXQhgRp77YiTDja23FrC2uwnhXMNkAucthsf+Psr7sTwYEryxz6FPAVqw==}
engines: {node: '>=12'}
@@ -1756,6 +1813,15 @@ packages:
dev: true
optional: true
+ /@esbuild/freebsd-arm64@0.19.12:
+ resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/freebsd-arm64@0.19.4:
resolution: {integrity: sha512-vz59ijyrTG22Hshaj620e5yhs2dU1WJy723ofc+KUgxVCM6zxQESmWdMuVmUzxtGqtj5heHyB44PjV/HKsEmuQ==}
engines: {node: '>=12'}
@@ -1783,6 +1849,15 @@ packages:
dev: true
optional: true
+ /@esbuild/freebsd-x64@0.19.12:
+ resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/freebsd-x64@0.19.4:
resolution: {integrity: sha512-3sRbQ6W5kAiVQRBWREGJNd1YE7OgzS0AmOGjDmX/qZZecq8NFlQsQH0IfXjjmD0XtUYqr64e0EKNFjMUlPL3Cw==}
engines: {node: '>=12'}
@@ -1810,6 +1885,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-arm64@0.19.12:
+ resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-arm64@0.19.4:
resolution: {integrity: sha512-ZWmWORaPbsPwmyu7eIEATFlaqm0QGt+joRE9sKcnVUG3oBbr/KYdNE2TnkzdQwX6EDRdg/x8Q4EZQTXoClUqqA==}
engines: {node: '>=12'}
@@ -1837,6 +1921,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-arm@0.19.12:
+ resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-arm@0.19.4:
resolution: {integrity: sha512-z/4ArqOo9EImzTi4b6Vq+pthLnepFzJ92BnofU1jgNlcVb+UqynVFdoXMCFreTK7FdhqAzH0vmdwW5373Hm9pg==}
engines: {node: '>=12'}
@@ -1864,6 +1957,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-ia32@0.19.12:
+ resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-ia32@0.19.4:
resolution: {integrity: sha512-EGc4vYM7i1GRUIMqRZNCTzJh25MHePYsnQfKDexD8uPTCm9mK56NIL04LUfX2aaJ+C9vyEp2fJ7jbqFEYgO9lQ==}
engines: {node: '>=12'}
@@ -1900,6 +2002,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-loong64@0.19.12:
+ resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-loong64@0.19.4:
resolution: {integrity: sha512-WVhIKO26kmm8lPmNrUikxSpXcgd6HDog0cx12BUfA2PkmURHSgx9G6vA19lrlQOMw+UjMZ+l3PpbtzffCxFDRg==}
engines: {node: '>=12'}
@@ -1927,6 +2038,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-mips64el@0.19.12:
+ resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-mips64el@0.19.4:
resolution: {integrity: sha512-keYY+Hlj5w86hNp5JJPuZNbvW4jql7c1eXdBUHIJGTeN/+0QFutU3GrS+c27L+NTmzi73yhtojHk+lr2+502Mw==}
engines: {node: '>=12'}
@@ -1954,6 +2074,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-ppc64@0.19.12:
+ resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-ppc64@0.19.4:
resolution: {integrity: sha512-tQ92n0WMXyEsCH4m32S21fND8VxNiVazUbU4IUGVXQpWiaAxOBvtOtbEt3cXIV3GEBydYsY8pyeRMJx9kn3rvw==}
engines: {node: '>=12'}
@@ -1981,6 +2110,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-riscv64@0.19.12:
+ resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-riscv64@0.19.4:
resolution: {integrity: sha512-tRRBey6fG9tqGH6V75xH3lFPpj9E8BH+N+zjSUCnFOX93kEzqS0WdyJHkta/mmJHn7MBaa++9P4ARiU4ykjhig==}
engines: {node: '>=12'}
@@ -2008,6 +2146,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-s390x@0.19.12:
+ resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-s390x@0.19.4:
resolution: {integrity: sha512-152aLpQqKZYhThiJ+uAM4PcuLCAOxDsCekIbnGzPKVBRUDlgaaAfaUl5NYkB1hgY6WN4sPkejxKlANgVcGl9Qg==}
engines: {node: '>=12'}
@@ -2035,6 +2182,15 @@ packages:
dev: true
optional: true
+ /@esbuild/linux-x64@0.19.12:
+ resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/linux-x64@0.19.4:
resolution: {integrity: sha512-Mi4aNA3rz1BNFtB7aGadMD0MavmzuuXNTaYL6/uiYIs08U7YMPETpgNn5oue3ICr+inKwItOwSsJDYkrE9ekVg==}
engines: {node: '>=12'}
@@ -2062,6 +2218,15 @@ packages:
dev: true
optional: true
+ /@esbuild/netbsd-x64@0.19.12:
+ resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/netbsd-x64@0.19.4:
resolution: {integrity: sha512-9+Wxx1i5N/CYo505CTT7T+ix4lVzEdz0uCoYGxM5JDVlP2YdDC1Bdz+Khv6IbqmisT0Si928eAxbmGkcbiuM/A==}
engines: {node: '>=12'}
@@ -2089,6 +2254,15 @@ packages:
dev: true
optional: true
+ /@esbuild/openbsd-x64@0.19.12:
+ resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/openbsd-x64@0.19.4:
resolution: {integrity: sha512-MFsHleM5/rWRW9EivFssop+OulYVUoVcqkyOkjiynKBCGBj9Lihl7kh9IzrreDyXa4sNkquei5/DTP4uCk25xw==}
engines: {node: '>=12'}
@@ -2116,6 +2290,15 @@ packages:
dev: true
optional: true
+ /@esbuild/sunos-x64@0.19.12:
+ resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/sunos-x64@0.19.4:
resolution: {integrity: sha512-6Xq8SpK46yLvrGxjp6HftkDwPP49puU4OF0hEL4dTxqCbfx09LyrbUj/D7tmIRMj5D5FCUPksBbxyQhp8tmHzw==}
engines: {node: '>=12'}
@@ -2143,6 +2326,15 @@ packages:
dev: true
optional: true
+ /@esbuild/win32-arm64@0.19.12:
+ resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/win32-arm64@0.19.4:
resolution: {integrity: sha512-PkIl7Jq4mP6ke7QKwyg4fD4Xvn8PXisagV/+HntWoDEdmerB2LTukRZg728Yd1Fj+LuEX75t/hKXE2Ppk8Hh1w==}
engines: {node: '>=12'}
@@ -2170,6 +2362,15 @@ packages:
dev: true
optional: true
+ /@esbuild/win32-ia32@0.19.12:
+ resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/win32-ia32@0.19.4:
resolution: {integrity: sha512-ga676Hnvw7/ycdKB53qPusvsKdwrWzEyJ+AtItHGoARszIqvjffTwaaW3b2L6l90i7MO9i+dlAW415INuRhSGg==}
engines: {node: '>=12'}
@@ -2197,6 +2398,15 @@ packages:
dev: true
optional: true
+ /@esbuild/win32-x64@0.19.12:
+ resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
/@esbuild/win32-x64@0.19.4:
resolution: {integrity: sha512-HP0GDNla1T3ZL8Ko/SHAS2GgtjOg+VmWnnYLhuTksr++EnduYB0f3Y2LzHsUwb2iQ13JGoY6G3R8h6Du/WG6uA==}
engines: {node: '>=12'}
@@ -7770,6 +7980,37 @@ packages:
'@esbuild/win32-x64': 0.18.14
dev: true
+ /esbuild@0.19.12:
+ resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.19.12
+ '@esbuild/android-arm': 0.19.12
+ '@esbuild/android-arm64': 0.19.12
+ '@esbuild/android-x64': 0.19.12
+ '@esbuild/darwin-arm64': 0.19.12
+ '@esbuild/darwin-x64': 0.19.12
+ '@esbuild/freebsd-arm64': 0.19.12
+ '@esbuild/freebsd-x64': 0.19.12
+ '@esbuild/linux-arm': 0.19.12
+ '@esbuild/linux-arm64': 0.19.12
+ '@esbuild/linux-ia32': 0.19.12
+ '@esbuild/linux-loong64': 0.19.12
+ '@esbuild/linux-mips64el': 0.19.12
+ '@esbuild/linux-ppc64': 0.19.12
+ '@esbuild/linux-riscv64': 0.19.12
+ '@esbuild/linux-s390x': 0.19.12
+ '@esbuild/linux-x64': 0.19.12
+ '@esbuild/netbsd-x64': 0.19.12
+ '@esbuild/openbsd-x64': 0.19.12
+ '@esbuild/sunos-x64': 0.19.12
+ '@esbuild/win32-arm64': 0.19.12
+ '@esbuild/win32-ia32': 0.19.12
+ '@esbuild/win32-x64': 0.19.12
+ dev: true
+
/esbuild@0.19.4:
resolution: {integrity: sha512-x7jL0tbRRpv4QUyuDMjONtWFciygUxWaUM1kMX2zWxI0X2YWOt7MSA0g4UdeSiHM8fcYVzpQhKYOycZwxTdZkA==}
engines: {node: '>=12'}
@@ -8597,6 +8838,12 @@ packages:
get-intrinsic: 1.2.1
dev: true
+ /get-tsconfig@4.7.2:
+ resolution: {integrity: sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==}
+ dependencies:
+ resolve-pkg-maps: 1.0.0
+ dev: true
+
/giget@1.1.3:
resolution: {integrity: sha512-zHuCeqtfgqgDwvXlR84UNgnJDuUHQcNI5OqWqFxxuk2BshuKbYhJWdxBsEo4PvKqoGh23lUAIvBNpChMLv7/9Q==}
hasBin: true
@@ -12958,6 +13205,10 @@ packages:
engines: {node: '>=8'}
dev: true
+ /resolve-pkg-maps@1.0.0:
+ resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+ dev: true
+
/resolve.exports@2.0.2:
resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==}
engines: {node: '>=10'}
@@ -14255,6 +14506,17 @@ packages:
typescript: 5.3.2
dev: true
+ /tsx@4.7.1:
+ resolution: {integrity: sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+ dependencies:
+ esbuild: 0.19.12
+ get-tsconfig: 4.7.2
+ optionalDependencies:
+ fsevents: 2.3.3
+ dev: true
+
/tty-table@4.2.1:
resolution: {integrity: sha512-xz0uKo+KakCQ+Dxj1D/tKn2FSyreSYWzdkL/BYhgN6oMW808g8QRMuh1atAV9fjTPbWBjfbkKQpI/5rEcnAc7g==}
engines: {node: '>=8.0.0'}
diff --git a/script/set-test-env.ts b/script/set-test-env.ts
new file mode 100644
index 000000000..4db61d8d1
--- /dev/null
+++ b/script/set-test-env.ts
@@ -0,0 +1 @@
+process.env.ZENSTACK_TEST = '1';
diff --git a/script/test-global-setup.ts b/script/test-global-setup.ts
new file mode 100644
index 000000000..514cccae7
--- /dev/null
+++ b/script/test-global-setup.ts
@@ -0,0 +1,9 @@
+import fs from 'fs';
+import path from 'path';
+
+export default function globalSetup() {
+ if (!fs.existsSync(path.join(__dirname, '../.test/scaffold/package-lock.json'))) {
+ console.error(`Test scaffold not found. Please run \`pnpm test-scaffold\` first.`);
+ process.exit(1);
+ }
+}
diff --git a/script/test-scaffold.ts b/script/test-scaffold.ts
new file mode 100644
index 000000000..ddf3c999a
--- /dev/null
+++ b/script/test-scaffold.ts
@@ -0,0 +1,24 @@
+import path from 'path';
+import fs from 'fs';
+import { execSync } from 'child_process';
+
+const scaffoldPath = path.join(__dirname, '../.test/scaffold');
+if (fs.existsSync(scaffoldPath)) {
+ fs.rmSync(scaffoldPath, { recursive: true, force: true });
+}
+fs.mkdirSync(scaffoldPath, { recursive: true });
+
+function run(cmd: string) {
+ console.log(`Running: ${cmd}, in ${scaffoldPath}`);
+ try {
+ execSync(cmd, { cwd: scaffoldPath, stdio: 'ignore' });
+ } catch (err) {
+ console.error(`Test project scaffolding cmd error: ${err}`);
+ throw err;
+ }
+}
+
+run('npm init -y');
+run('npm i --no-audit --no-fund typescript prisma @prisma/client zod decimal.js');
+
+console.log('Test scaffold setup complete.');
diff --git a/tests/integration/global-setup.js b/tests/integration/global-setup.js
deleted file mode 100644
index 0d4b8e23e..000000000
--- a/tests/integration/global-setup.js
+++ /dev/null
@@ -1,10 +0,0 @@
-const { execSync } = require('child_process');
-
-module.exports = function () {
- console.log('npm install');
- execSync('npm install', {
- encoding: 'utf-8',
- stdio: 'inherit',
- cwd: 'test-run',
- });
-};
diff --git a/tests/integration/jest.config.ts b/tests/integration/jest.config.ts
index 346f6faad..67a118269 100644
--- a/tests/integration/jest.config.ts
+++ b/tests/integration/jest.config.ts
@@ -1,30 +1,10 @@
+import baseConfig from '../../jest.config';
+
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
export default {
- // Automatically clear mock calls, instances, contexts and results before every test
- clearMocks: true,
-
- // A map from regular expressions to paths to transformers
- transform: { '^.+\\.tsx?$': 'ts-jest' },
-
- testTimeout: 300000,
-
+ ...baseConfig,
setupFilesAfterEnv: ['./test-setup.ts'],
-
- // Indicates whether the coverage information should be collected while executing the test
- collectCoverage: true,
-
- // The directory where Jest should output its coverage files
- coverageDirectory: 'tests/coverage',
-
- // An array of regexp pattern strings used to skip coverage collection
- coveragePathIgnorePatterns: ['/node_modules/', '/tests/'],
-
- // Indicates which provider should be used to instrument code for coverage
- coverageProvider: 'v8',
-
- // A list of reporter names that Jest uses when writing coverage reports
- coverageReporters: ['json', 'text', 'lcov', 'clover'],
};
diff --git a/tests/integration/package.json b/tests/integration/package.json
index 40627f354..8aed0b6c8 100644
--- a/tests/integration/package.json
+++ b/tests/integration/package.json
@@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"lint": "eslint . --ext .ts",
- "test": "ZENSTACK_TEST=1 jest"
+ "test": "jest"
},
"keywords": [],
"author": "",
diff --git a/tests/integration/tests/cli/generate.test.ts b/tests/integration/tests/cli/generate.test.ts
index 0367033bd..90f9e2311 100644
--- a/tests/integration/tests/cli/generate.test.ts
+++ b/tests/integration/tests/cli/generate.test.ts
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-var-requires */
///
+import { installPackage } from '@zenstackhq/testtools';
import * as fs from 'fs';
import path from 'path';
import * as tmp from 'tmp';
import { createProgram } from '../../../../packages/schema/src/cli';
-import { execSync } from '../../../../packages/schema/src/utils/exec-utils';
import { createNpmrc } from './share';
describe('CLI generate command tests', () => {
@@ -43,8 +43,8 @@ model Post {
// set up project
fs.writeFileSync('package.json', JSON.stringify({ name: 'my app', version: '1.0.0' }));
createNpmrc();
- execSync('npm install prisma @prisma/client zod');
- execSync(`npm install ${path.join(__dirname, '../../../../packages/runtime/dist')}`);
+ installPackage('prisma @prisma/client zod');
+ installPackage(path.join(__dirname, '../../../../packages/runtime/dist'));
// set up schema
fs.writeFileSync('schema.zmodel', MODEL, 'utf-8');
diff --git a/tests/integration/tests/cli/plugins.test.ts b/tests/integration/tests/cli/plugins.test.ts
index 005a0f69b..716ac224e 100644
--- a/tests/integration/tests/cli/plugins.test.ts
+++ b/tests/integration/tests/cli/plugins.test.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-var-requires */
///
-import { getWorkspaceNpmCacheFolder, run } from '@zenstackhq/testtools';
+import { getWorkspaceNpmCacheFolder, installPackage, run } from '@zenstackhq/testtools';
import * as fs from 'fs';
import * as path from 'path';
import * as tmp from 'tmp';
@@ -94,8 +94,8 @@ describe('CLI Plugins Tests', () => {
switch (pm) {
case 'npm':
- run('npm install ' + deps);
- run('npm install -D ' + devDeps);
+ installPackage(deps);
+ installPackage(devDeps, true);
break;
// case 'yarn':
// run('yarn add ' + deps);
diff --git a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts
index 55b9c5cee..bb505ca55 100644
--- a/tests/integration/tests/enhancements/with-policy/field-validation.test.ts
+++ b/tests/integration/tests/enhancements/with-policy/field-validation.test.ts
@@ -45,7 +45,7 @@ describe('With Policy: field validation', () => {
id String @id @default(cuid())
user User @relation(fields: [userId], references: [id])
userId String
- slug String @regex("^[0-9a-zA-Z]{4,16}$")
+ slug String @regex("^[0-9a-zA-Z]{4,16}$") @lower
@@allow('all', true)
}
@@ -508,50 +508,104 @@ describe('With Policy: field validation', () => {
},
});
- await expect(
- db.userData.create({
- data: {
- userId: '1',
- a: 1,
- b: 0,
- c: -1,
- d: 0,
- text1: 'abc123',
- text2: 'def',
- text3: 'aaa',
- text4: 'abcab',
- text6: ' AbC ',
- text7: 'abc',
+ let ud = await db.userData.create({
+ data: {
+ userId: '1',
+ a: 1,
+ b: 0,
+ c: -1,
+ d: 0,
+ text1: 'abc123',
+ text2: 'def',
+ text3: 'aaa',
+ text4: 'abcab',
+ text6: ' AbC ',
+ text7: 'abc',
+ },
+ });
+ expect(ud).toMatchObject({ text6: 'abc', text7: 'ABC' });
+
+ ud = await db.userData.update({
+ where: { id: ud.id },
+ data: {
+ text4: 'xyz',
+ text6: ' bCD ',
+ text7: 'bcd',
+ },
+ });
+ expect(ud).toMatchObject({ text4: 'xyz', text6: 'bcd', text7: 'BCD' });
+
+ let u = await db.user.create({
+ data: {
+ id: '2',
+ password: 'abc123!@#',
+ email: 'who@myorg.com',
+ handle: 'user2',
+ userData: {
+ create: {
+ a: 1,
+ b: 0,
+ c: -1,
+ d: 0,
+ text1: 'abc123',
+ text2: 'def',
+ text3: 'aaa',
+ text4: 'abcab',
+ text6: ' AbC ',
+ text7: 'abc',
+ },
},
- })
- ).resolves.toMatchObject({ text6: 'abc', text7: 'ABC' });
+ },
+ include: { userData: true },
+ });
+ expect(u.userData).toMatchObject({
+ text6: 'abc',
+ text7: 'ABC',
+ });
- await expect(
- db.user.create({
- data: {
- id: '2',
- password: 'abc123!@#',
- email: 'who@myorg.com',
- handle: 'user2',
- userData: {
- create: {
- a: 1,
- b: 0,
- c: -1,
- d: 0,
- text1: 'abc123',
- text2: 'def',
- text3: 'aaa',
- text4: 'abcab',
- text6: ' AbC ',
- text7: 'abc',
- },
+ u = await db.user.update({
+ where: { id: u.id },
+ data: {
+ userData: {
+ update: {
+ data: { text4: 'xyz', text6: ' bCD ', text7: 'bcd' },
},
},
- include: { userData: true },
- })
- ).resolves.toMatchObject({
- userData: expect.objectContaining({ text6: 'abc', text7: 'ABC' }),
+ },
+ include: { userData: true },
+ });
+ expect(u.userData).toMatchObject({ text4: 'xyz', text6: 'bcd', text7: 'BCD' });
+
+ // upsert create
+ u = await db.user.update({
+ where: { id: u.id },
+ data: {
+ tasks: {
+ upsert: {
+ where: { id: 'unknown' },
+ create: { slug: 'SLUG1' },
+ update: {},
+ },
+ },
+ },
+ include: { tasks: true },
+ });
+ expect(u.tasks[0]).toMatchObject({ slug: 'slug1' });
+
+ // upsert update
+ u = await db.user.update({
+ where: { id: u.id },
+ data: {
+ tasks: {
+ upsert: {
+ where: { id: u.tasks[0].id },
+ create: {},
+ update: { slug: 'SLUG2' },
+ },
+ },
+ },
+ include: { tasks: true },
});
+ expect(u.tasks[0]).toMatchObject({ slug: 'slug2' });
});
});
diff --git a/tests/integration/tests/enhancements/with-policy/refactor.test.ts b/tests/integration/tests/enhancements/with-policy/refactor.test.ts
index 126c038fa..6a329a739 100644
--- a/tests/integration/tests/enhancements/with-policy/refactor.test.ts
+++ b/tests/integration/tests/enhancements/with-policy/refactor.test.ts
@@ -144,12 +144,15 @@ describe('With Policy: refactor tests', () => {
// read back check
await expect(
anonDb.user.create({
- data: { id: 1, email: 'user1@zenstack.dev' },
+ data: { id: 1, email: 'User1@zenstack.dev' },
})
).rejects.toThrow(/not allowed to be read back/);
// success
- await expect(user1Db.user.findUnique({ where: { id: 1 } })).toResolveTruthy();
+ await expect(user1Db.user.findUnique({ where: { id: 1 } })).resolves.toMatchObject({
+ // email to lower
+ email: 'user1@zenstack.dev',
+ });
// nested creation failure
await expect(
@@ -202,7 +205,7 @@ describe('With Policy: refactor tests', () => {
posts: {
create: {
id: 2,
- title: 'Post 2',
+ title: ' Post 2 ',
published: true,
comments: {
create: {
@@ -213,8 +216,14 @@ describe('With Policy: refactor tests', () => {
},
},
},
+ include: { posts: true },
})
- ).toResolveTruthy();
+ ).resolves.toMatchObject({
+ posts: expect.arrayContaining([
+ // title is trimmed
+ expect.objectContaining({ title: 'Post 2' }),
+ ]),
+ });
// create with connect: posts
await expect(
@@ -389,7 +398,7 @@ describe('With Policy: refactor tests', () => {
data: [
{ id: 7, title: 'Post 7.1' },
{ id: 7, title: 'Post 7.2' },
- { id: 8, title: 'Post 8' },
+ { id: 8, title: ' Post 8 ' },
],
skipDuplicates: true,
},
@@ -400,7 +409,10 @@ describe('With Policy: refactor tests', () => {
// success
await expect(adminDb.user.findUnique({ where: { id: 7 } })).toResolveTruthy();
await expect(adminDb.post.findUnique({ where: { id: 7 } })).toResolveTruthy();
- await expect(adminDb.post.findUnique({ where: { id: 8 } })).toResolveTruthy();
+ await expect(adminDb.post.findUnique({ where: { id: 8 } })).resolves.toMatchObject({
+ // title is trimmed
+ title: 'Post 8',
+ });
});
it('createMany', async () => {
@@ -412,11 +424,18 @@ describe('With Policy: refactor tests', () => {
await expect(
user1Db.post.createMany({
data: [
- { id: 1, title: 'Post 1', authorId: 1 },
+ { id: 1, title: ' Post 1 ', authorId: 1 },
{ id: 2, title: 'Post 2', authorId: 1 },
],
})
- ).resolves.toMatchObject({ count: 2 });
+ ).toResolveTruthy();
+
+ await expect(user1Db.post.findMany()).resolves.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ title: 'Post 1' }), // title is trimmed
+ expect.objectContaining({ title: 'Post 2' }),
+ ])
+ );
// unique constraint violation
await expect(
@@ -502,8 +521,8 @@ describe('With Policy: refactor tests', () => {
user2Db.user.update({ where: { id: 1 }, data: { email: 'user2@zenstack.dev' } })
).toBeRejectedByPolicy();
await expect(
- adminDb.user.update({ where: { id: 1 }, data: { email: 'user1-nice@zenstack.dev' } })
- ).toResolveTruthy();
+ adminDb.user.update({ where: { id: 1 }, data: { email: 'User1-nice@zenstack.dev' } })
+ ).resolves.toMatchObject({ email: 'user1-nice@zenstack.dev' });
// update nested profile
await expect(
@@ -561,9 +580,10 @@ describe('With Policy: refactor tests', () => {
await expect(
user1Db.user.update({
where: { id: 1 },
- data: { posts: { update: { where: { id: 1 }, data: { published: false } } } },
+ data: { posts: { update: { where: { id: 1 }, data: { title: ' New ', published: false } } } },
+ include: { posts: true },
})
- ).toResolveTruthy();
+ ).resolves.toMatchObject({ posts: expect.arrayContaining([expect.objectContaining({ title: 'New' })]) });
// update nested comment prevent update of toplevel
await expect(
@@ -588,23 +608,24 @@ describe('With Policy: refactor tests', () => {
await expect(adminDb.comment.findFirst({ where: { content: 'Comment 2 updated' } })).toResolveFalsy();
// update with create
- await expect(
- user1Db.user.update({
- where: { id: 1 },
- data: {
- posts: {
- create: {
- id: 3,
- title: 'Post 3',
- published: true,
- comments: {
- create: { author: { connect: { id: 1 } }, content: 'Comment 3' },
- },
+ const r1 = await user1Db.user.update({
+ where: { id: 1 },
+ data: {
+ posts: {
+ create: {
+ id: 3,
+ title: 'Post 3',
+ published: true,
+ comments: {
+ create: { author: { connect: { id: 1 } }, content: ' Comment 3 ' },
},
},
},
- })
- ).toResolveTruthy();
+ },
+ include: { posts: { include: { comments: true } } },
+ });
+ expect(r1.posts[r1.posts.length - 1].comments[0].content).toEqual('Comment 3');
+
await expect(
user1Db.user.update({
where: { id: 1 },
@@ -636,7 +657,7 @@ describe('With Policy: refactor tests', () => {
posts: {
createMany: {
data: [
- { id: 4, title: 'Post 4' },
+ { id: 4, title: ' Post 4 ' },
{ id: 5, title: 'Post 5' },
],
},
@@ -644,6 +665,7 @@ describe('With Policy: refactor tests', () => {
},
})
).toResolveTruthy();
+ await expect(user1Db.post.findUnique({ where: { id: 4 } })).resolves.toMatchObject({ title: 'Post 4' });
await expect(
user1Db.user.update({
include: { posts: true },
@@ -723,12 +745,13 @@ describe('With Policy: refactor tests', () => {
posts: {
update: {
where: { id: 1 },
- data: { title: 'Post1-1' },
+ data: { title: ' Post1-1' },
},
},
},
})
).toResolveTruthy();
+ await expect(user1Db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ title: 'Post1-1' });
await expect(
user1Db.user.update({
where: { id: 1 },
@@ -799,14 +822,14 @@ describe('With Policy: refactor tests', () => {
posts: {
upsert: {
where: { id: 1 },
- update: { title: 'Post 1-1' }, // update
+ update: { title: ' Post 2' }, // update
create: { id: 7, title: 'Post 1' },
},
},
},
})
).toResolveTruthy();
- await expect(user1Db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ title: 'Post 1-1' });
+ await expect(user1Db.post.findUnique({ where: { id: 1 } })).resolves.toMatchObject({ title: 'Post 2' });
await expect(
user1Db.user.update({
where: { id: 1 },
@@ -815,7 +838,7 @@ describe('With Policy: refactor tests', () => {
upsert: {
where: { id: 7 },
update: { title: 'Post 7-1' },
- create: { id: 7, title: 'Post 7' }, // create
+ create: { id: 7, title: ' Post 7' }, // create
},
},
},
@@ -1094,9 +1117,10 @@ describe('With Policy: refactor tests', () => {
).toBeRejectedByPolicy();
await expect(
user1Db.post.updateMany({
- data: { title: 'My post' },
+ data: { title: ' My post' },
})
).resolves.toMatchObject({ count: 2 });
+ await expect(user1Db.post.findFirst()).resolves.toMatchObject({ title: 'My post' });
});
it('delete single', async () => {
diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts
index fd12d8b60..dd82f6786 100644
--- a/tests/integration/tests/plugins/zod.test.ts
+++ b/tests/integration/tests/plugins/zod.test.ts
@@ -503,6 +503,50 @@ describe('Zod plugin tests', () => {
).toBeFalsy();
});
+ it('does date coercion', async () => {
+ const { zodSchemas } = await loadSchema(
+ `
+ datasource db {
+ provider = 'postgresql'
+ url = env('DATABASE_URL')
+ }
+
+ generator js {
+ provider = 'prisma-client-js'
+ }
+
+ plugin zod {
+ provider = "@core/zod"
+ }
+
+ model Model {
+ id Int @id @default(autoincrement())
+ dt DateTime
+ }
+ `,
+ { addPrelude: false, pushDb: false }
+ );
+ const schemas = zodSchemas.models;
+
+ expect(
+ schemas.ModelCreateSchema.safeParse({
+ dt: new Date(),
+ }).success
+ ).toBeTruthy();
+
+ expect(
+ schemas.ModelCreateSchema.safeParse({
+ dt: '2023-01-01T00:00:00.000Z',
+ }).success
+ ).toBeTruthy();
+
+ expect(
+ schemas.ModelCreateSchema.safeParse({
+ dt: '2023-13-01',
+ }).success
+ ).toBeFalsy();
+ });
+
it('generate for selected models full', async () => {
const { projectDir } = await loadSchema(
`
diff --git a/tests/integration/tests/regression/issue-1014.test.ts b/tests/integration/tests/regression/issue-1014.test.ts
new file mode 100644
index 000000000..ad862db42
--- /dev/null
+++ b/tests/integration/tests/regression/issue-1014.test.ts
@@ -0,0 +1,52 @@
+import { loadSchema } from '@zenstackhq/testtools';
+
+describe('issue 1014', () => {
+ it('update', async () => {
+ const { prisma, enhance } = await loadSchema(
+ `
+ model User {
+ id Int @id() @default(autoincrement())
+ name String
+ posts Post[]
+ }
+
+ model Post {
+ id Int @id() @default(autoincrement())
+ title String
+ content String?
+ author User? @relation(fields: [authorId], references: [id])
+ authorId Int? @allow('update', true, true)
+
+ @@allow('read', true)
+ }
+ `
+ );
+
+ const db = enhance();
+
+ const user = await prisma.user.create({ data: { name: 'User1' } });
+ const post = await prisma.post.create({ data: { title: 'Post1' } });
+ await expect(db.post.update({ where: { id: post.id }, data: { authorId: user.id } })).toResolveTruthy();
+ });
+
+ it('read', async () => {
+ const { prisma, enhance } = await loadSchema(
+ `
+ model Post {
+ id Int @id() @default(autoincrement())
+ title String @allow('read', true, true)
+ content String
+ }
+ `,
+ { logPrismaQuery: true }
+ );
+
+ const db = enhance();
+
+ const post = await prisma.post.create({ data: { title: 'Post1', content: 'Content' } });
+ await expect(db.post.findUnique({ where: { id: post.id } })).toResolveNull();
+ await expect(db.post.findUnique({ where: { id: post.id }, select: { title: true } })).resolves.toEqual({
+ title: 'Post1',
+ });
+ });
+});
diff --git a/tests/integration/tests/regression/issue-177.test.ts b/tests/integration/tests/regression/issue-177.test.ts
new file mode 100644
index 000000000..d270580c5
--- /dev/null
+++ b/tests/integration/tests/regression/issue-177.test.ts
@@ -0,0 +1,27 @@
+import { loadModelWithError } from '@zenstackhq/testtools';
+
+describe('issue 177', () => {
+ it('regression', async () => {
+ await expect(
+ loadModelWithError(
+ `
+ model Foo {
+ id String @id @default(cuid())
+
+ bar Bar @relation(fields: [barId1, barId2], references: [id1, id2])
+ barId1 String?
+ barId2 String
+ }
+
+ model Bar {
+ id1 String @default(cuid())
+ id2 String @default(cuid())
+ foos Foo[]
+
+ @@id([id1, id2])
+ }
+ `
+ )
+ ).resolves.toContain('relation "bar" is not optional, but field "barId1" is optional');
+ });
+});
diff --git a/tests/integration/tests/schema/refactor-pg.zmodel b/tests/integration/tests/schema/refactor-pg.zmodel
index f52f36c98..d0b4579e1 100644
--- a/tests/integration/tests/schema/refactor-pg.zmodel
+++ b/tests/integration/tests/schema/refactor-pg.zmodel
@@ -5,7 +5,7 @@ enum Role {
model User {
id Int @id @default(autoincrement())
- email String @unique @email
+ email String @unique @email @lower
role Role @default(USER)
profile Profile?
posts Post[]
@@ -52,7 +52,7 @@ model Image {
model Post {
id Int @id @default(autoincrement())
- title String @length(1, 8)
+ title String @length(1, 8) @trim
published Boolean @default(false)
comments Comment[]
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@ -67,7 +67,7 @@ model Post {
model Comment {
id Int @id @default(autoincrement())
- content String
+ content String @trim
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
diff --git a/tests/integration/tests/schema/todo.zmodel b/tests/integration/tests/schema/todo.zmodel
index 733391bd1..079e3b1ef 100644
--- a/tests/integration/tests/schema/todo.zmodel
+++ b/tests/integration/tests/schema/todo.zmodel
@@ -14,7 +14,7 @@ generator js {
plugin zod {
provider = '@core/zod'
- preserveTsFiles = true
+ // preserveTsFiles = true
}
/*
diff --git a/tests/integration/tests/tsconfig.template.json b/tests/integration/tests/tsconfig.template.json
deleted file mode 100644
index 18a6bedec..000000000
--- a/tests/integration/tests/tsconfig.template.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "compilerOptions": {
- "target": "es2016",
- "module": "commonjs",
- "esModuleInterop": true,
- "forceConsistentCasingInFileNames": true,
- "strict": true,
- "skipLibCheck": true
- }
-}