Skip to content

Commit 9aed6b7

Browse files
authored
blog: zenstack next chapter part 2 (#441)
* blog: zenstack next chapter part 2 * update * update * update * update * update
1 parent 308ce1b commit 9aed6b7

File tree

4 files changed

+188
-2
lines changed

4 files changed

+188
-2
lines changed

blog/next-chapter-1/cover.png

-323 KB
Loading

blog/next-chapter-1/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Kysely is a very popular, strongly typed SQL query builder. Using it as the data
123123
124124
2. Access control injection (and other query transformations) can be done at Kysely's query tree level, which offers much more flexibility than injecting `PrismaClient`.
125125
126-
3. Kysely's expression builder can be used as a generic extensibility mechanism (more about this in the next post).
126+
3. Kysely's expression builder can be used as a generic extensibility mechanism (more about this in the [next post](../next-chapter-2/index.md)).
127127
128128
One question we repeatedly got was, "Why not build above Drizzle, given its rising popularity?". Drizzle is an excellent ORM that addresses some of Prisma's issues. However, the primary decision factor is the abstraction level. ZenStack needs a simple yet flexible database access layer as a foundation. Kysely satisfies this criterion perfectly, while Prisma, Drizzle, and other ORMs are too high-level and comprehensive.
129129

blog/next-chapter-2/index.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
---
2+
title: ZenStack - The Next Chapter (Part II. An Extensible ORM)
3+
description: This post explores how ZenStack V3 will become a more extensible ORM.
4+
tags: [zenstack]
5+
authors: yiming
6+
image: ../next-chapter-1/cover.png
7+
date: 2025-04-10
8+
---
9+
10+
# ZenStack - The Next Chapter (Part II. An Extensible ORM)
11+
12+
![Cover Image](../next-chapter-1/cover.png)
13+
14+
In the [previous post](../next-chapter-1/index.md), we discussed the general plan for ZenStack v3 and the big refactor. This post will explore the extensibility opportunities the new architecture brings to the core ORM.
15+
16+
<!-- truncate -->
17+
18+
## High-Level CRUD and Low-Level Query Builder
19+
20+
While continuing to provide the fully typed CRUD API like `PrismaClient` (`findMany`, `create`, etc.), using [Kysely](https://kysely.dev/) as the underlying data access layer allows us to easily offer a low-level, query-builder style API too.
21+
22+
```ts
23+
// CRUD API (the same as PrismaClient)
24+
await db.user.findMany({ include: { posts: true } });
25+
26+
// Query builder API (backed by Kysely)
27+
await db.$qb.selectFrom('User')
28+
.leftJoin('Post', 'Post.authorId', 'User.id')
29+
.select(['User.id', 'User.email', 'Post.title'])
30+
.execute()
31+
```
32+
33+
Both the CRUD API and the query builder API are automatically inferred from the ZModel schema, so you don't need any extra configuration to use them, and they're always consistent. Given Kysely query builder's awesome flexibility, we believe you'll rarely need to resort to raw SQL anymore.
34+
35+
What's even more powerful is that you can blend query builder into CRUD calls. For complex queries, you can still enjoy the terse syntax of the CRUD API, and mix in the query builder for extra expressiveness. Here's an example:
36+
37+
```ts
38+
await db.user.findMany({
39+
where: {
40+
age: { gt: 18 },
41+
// "eb" is a Kysely expression builder
42+
$expr: (eb) => eb('email', 'like', '%@zenstack.dev')
43+
}
44+
});
45+
```
46+
47+
It's similar to the long-awaited [whereRaw](https://github.com/prisma/prisma/issues/5560) feature request in Prisma, but better thanks to Kysely's type-safe query builder. You can implement arbitrarily complex filters involving joins or subqueries with the `$expr` clause.
48+
49+
Kysely's query builder syntax may take some time to get used to, but once you get the hang of it, it's pleasant to write and incredibly powerful.
50+
51+
## Computed Fields
52+
53+
One major limitation of Prisma and ZenStack v2 is the lack of real "computed fields". Prisma's client extension allows you to [add custom fields to models](https://www.prisma.io/docs/orm/prisma-client/queries/computed-fields), but it's purely client-side. It's good for simple computations like combining `firstName` and `lastName` into full name, but you can't do anything that needs database-side computation, like adding a `postCount` field to return the number of posts a user has.
54+
55+
ZenStack v3 is determined to solve this problem. It'll introduce a new `@computed` attribute that allows you to define computed fields in ZModel.
56+
57+
```zmodel
58+
model User {
59+
id Int @id
60+
posts[] Post
61+
postCount Int @computed
62+
}
63+
```
64+
65+
Of course, you'll need to provide an "implementation" for computing the field, on the database side. Again, Kysely's query builder is perfect for this. When creating a ZenStack client instance, you'll need to provide a callback that returns a Kysely expression builder for each computed field.
66+
67+
```ts
68+
import { ZenStackClient } from '@zenstackhq/runtime';
69+
70+
const client = new ZenStackClient({
71+
computed: {
72+
user: {
73+
// select count(*) as postCount from Post where Post.authorId = User.id
74+
postCount: (eb) =>
75+
eb
76+
.selectFrom('Post')
77+
.whereRef('Post.authorId', '=', 'User.id')
78+
.select(({ fn }) => fn.countAll<number>().as('postCount'))
79+
}
80+
}
81+
});
82+
```
83+
84+
Then, when you query the `User` model, the `postCount` field will be returned in the result. You can also use it to filter, sort, etc., just like any other field.
85+
86+
```ts
87+
// find users with more than 10 posts and sort by post count
88+
const users = await client.user.findMany({
89+
where: { postCount: { gt: 10 } },
90+
orderBy: { postCount: 'desc' }
91+
});
92+
```
93+
94+
Since the fields are declared in ZModel, you can use it in access policies as well:
95+
96+
```zmodel
97+
model User {
98+
...
99+
@@deny('delete', postCount > 0)
100+
}
101+
```
102+
103+
Another benefit of having the computed fields declared in ZModel is that it'll be visible to all tools that consume the schema. For example, The OpenAPI spec generator can include them as fields in the generated APIs.
104+
105+
## Custom Procedures
106+
107+
An ORM provides a set of data access primitives that allow applications to compose them into higher-level operations with business meaning. Such composition can be encapsulated in many ways: utility functions, application services, database stored procedures, etc. ZenStack v3 will introduce a new `proc` construct to allow defining such encapsulation in ZModel.
108+
109+
```zmodel
110+
model User {
111+
id Int @id
112+
email String @unique
113+
name String?
114+
}
115+
116+
type SignUpInput {
117+
email String
118+
name String?
119+
}
120+
121+
proc signUp(args: SignUpInput): User
122+
```
123+
124+
Again, when creating a ZenStack client instance, you must provide implementations for the procedures.
125+
126+
```ts
127+
import { ZenStackClient } from '@zenstackhq/runtime';
128+
129+
const client = new ZenStackClient({
130+
procs: {
131+
signUp: async (client, args) => {
132+
// create user
133+
const user = await client.user.create({ data: { email: args.email, name: args.name } });
134+
// send a welcome email
135+
await sendWelcomeEmail(user.email);
136+
return user;
137+
}
138+
}
139+
});
140+
```
141+
142+
Then, you can call the type-safe procedures just like any other CRUD operation:
143+
144+
```ts
145+
const user = await client.$procs.signUp({ email, name });
146+
```
147+
148+
You may wonder why we bother to declare the procedure in ZModel. Again, the purpose is to make them visible to upstream tools. For example, ZenStack's auto CRUD Http API can expose them as endpoints:
149+
150+
```bash
151+
POST /api/$procs/signUp
152+
{
153+
"email": ...,
154+
"name": ...
155+
}
156+
```
157+
158+
And, for the frontend, TanStack query hooks can be used to call the procedures:
159+
160+
```tsx
161+
import { useHooks } from '@zenstackhq/tanstack-query/react';
162+
import { schema } from '~/schema';
163+
164+
export default function SignUp() {
165+
const crud = useHooks(schema);
166+
const { mutateAsync: signUp } = crud.$procs.signUp();
167+
168+
const handleSubmit = async (data) => {
169+
await signUp(data);
170+
};
171+
172+
return (
173+
<form onSubmit={handleSubmit}>
174+
{/* form fields */}
175+
</form>
176+
);
177+
}
178+
```
179+
180+
Combined with access policies, custom procedures provide a powerful way to encapsulate complex business logic and expose them as APIs with minimum effort.
181+
182+
## Conclusion
183+
184+
ZenStack v3 will be a big step forward in terms of extensibility and flexibility. The new architecture allows us to provide a more powerful and expressive ORM experience while still maintaining simplicity and ease of use.
185+
186+
In the next post, we'll continue to explore how the new plugin system will allow you to make deep customizations to ZenStack in a clean and maintainable way.

src/lib/prism-zmodel.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
Prism.languages.zmodel = Prism.languages.extend('clike', {
2-
keyword: /\b(?:datasource|enum|generator|model|type|abstract|import|extends|attribute|view|plugin)\b/,
2+
keyword: /\b(?:datasource|enum|generator|model|type|abstract|import|extends|attribute|view|plugin|proc)\b/,
33
'type-class-name': /(\b()\s+)[\w.\\]+/,
44
});
55

0 commit comments

Comments
 (0)