ZModel Language Reference
Overview
ZModel, the modeling DSL of ZenStack, is the main concept you'll deal with when using this toolkit. The ZModel syntax is a superset of Prisma Schema. Therefore, every valid Prisma schema is a valid ZModel.
We made that choice to extend the Prisma schema for several reasons:
-
Creating a new ORM adds little value to the community. Instead, extending Prisma - the overall best ORM toolkit for Typescript - sounds more sensible.
-
Prisma's schema language is simple and intuitive.
-
Extending an existing popular language lowers the learning curve compared to inventing a new one.
However, the standard capability of Prisma schema doesn't allow us to build the functionalities we want in a natural way, so we made several extensions to the language by adding the following:
- Custom attributes
- Custom attribute functions
- Built-in attributes and functions for defining access policies
- Built-in attributes for defining field validation rules
- Utility attributes like
@password
and@omit
- Multi-schema files support
Some of these extensions have been asked for by the Prisma community for some time, so we hope that ZenStack can be helpful even just as an extensible version of Prisma.
This section provides detailed descriptions of all aspects of the ZModel language, so you don't have to jump over to Prisma's documentation for extra learning.
Import
ZModel allows to import other ZModel files. This is useful when you want to split your schema into multiple files for better organization. Under the hood, it will recursively merge all the imported schemas, and generate a single Prisma schema file for the Prisma CLI to consume.
Syntax
import [IMPORT_SPECIFICATION]
-
[IMPORT_SPECIFICATION]: Path to the ZModel file to be imported. It can be:
- An absolute path, e.g., "/path/to/user".
- A relative path, e.g., "./user".
- A module resolved to an installed NPM package, e.g., "my-package/base".
If the import specification doesn't end with ".zmodel", the resolver will automatically append it. Once a file is imported, all the declarations in that file will be included in the building process.
Examples
// there is a file called "user.zmodel" in the same directory
import "user"
Data source
Every model needs to include exactly one datasource
declaration, providing information on how to connect to the underlying database.
Syntax
datasource [NAME] {
provider = [PROVIDER]
url = [DB_URL]
}
-
[NAME]:
Name of the data source. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. Name is only informational and serves no other purposes. -
[PROVIDER]:
Name of database connector. Valid values:
- sqlite
- postgresql
- mysql
- sqlserver
- cockroachdb
-
[DB_URL]:
Database connection string. Either a plain string or an invocation of
env
function to fetch from an environment variable.
Examples
datasource db {
provider = "postgresql"
url = "postgresql://postgres:abc123@localhost:5432/todo?schema=public"
}
It's highly recommended that you not commit sensitive database connection strings into source control. Alternatively, you can load it from an environment variable:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
Supported databases
ZenStack uses Prisma to talk to databases, so all relational databases supported by Prisma are also supported by ZenStack.
Here's a list for your reference:
Database | Version |
---|---|
PostgreSQL | 9.6 |
PostgreSQL | 10 |
PostgreSQL | 11 |
PostgreSQL | 12 |
PostgreSQL | 13 |
PostgreSQL | 14 |
PostgreSQL | 15 |
MySQL | 5.6 |
MySQL | 5.7 |
MySQL | 8 |
MariaDB | 10 |
SQLite | * |
AWS Aurora | * |
AWS Aurora Serverless | * |
Microsoft SQL Server | 2022 |
Microsoft SQL Server | 2019 |
Microsoft SQL Server | 2017 |
Azure SQL | * |
CockroachDB | 21.2.4+ |
You can find the orignal list here.
Generator
Generators are used for creating assets (usually code) from a Prisma schema. Check here for a list of official and community generators.
Syntax
generator [GENERATOR_NAME] {
[OPTION]*
}
-
[GENERATOR_NAME]
Name of the generator. Needs to be unique in the entire model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. -
[OPTION]
A generator configuration option, in form of "[NAME] = [VALUE]". A generator needs to have at least a "provider" option that specify its provider.
Example
generator client {
provider = "prisma-client-js"
output = "./generated/prisma-client-js"
Plugin
Plugins are ZenStack's extensibility mechanism. It's usage is similar to Generator. Users can define their own plugins to generate artifacts from the ZModel schema. Plugins differ from generators mainly in the following ways:
- They have a cleaner interface without the complexity of JSON-RPC.
- They use an easier-to-program AST representation than generators.
- They have access to language features that ZenStack adds to Prisma, like custom attributes and functions.
Syntax
plugin [PLUGIN_NAME] {
[OPTION]*
}
-
[PLUGIN_NAME]
Name of the plugin. Needs to be unique in the entire model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. -
[OPTION]
A plugin configuration option, in form of "[NAME] = [VALUE]". A plugin needs to have at least a "provider" option that specify its provider.
Example
plugin swr {
provider = '@zenstackhq/swr'
output = 'lib/hooks'
}
Enum
Enums are container declarations for grouping constant identifiers. You can use them to express concepts like user roles, product categories, etc.
Syntax
enum [ENUM_NAME] {
[FIELD]*
}
-
[ENUM_NAME]
Name of the enum. Needs to be unique in the entire model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. -
[FIELD]
Field identifier. Needs to be unique in the model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
.
Example
enum UserRole {
USER
ADMIN
}
Model
Models represent the business entities of your application. A model inherits all fields and attributes from extended abstract models. Abstract models are eliminated in the generated prisma schema file.
Syntax
(abstract)? model [NAME] (extends [ABSTRACT_MODEL_NAME](,[ABSTRACT_MODEL_NAME])*)? {
[FIELD]*
}
-
[abstract]:
Optional. If present, the model is marked as abstract would not be mapped to a database table. Abstract models are only used as base classes for other models.
-
[NAME]:
Name of the model. Needs to be unique in the entire model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. -
[FIELD]:
Arbitrary number of fields. See Field for details.
-
[ABSTRACT_MODEL_NAME]:
Name of an abstract model.
Note
A model must include a field marked with @id
attribute. The id
field serves as a unique identifier for a model entity and is mapped to the database table's primary key.
See here for more details about attributes.
Example
abstract model Basic {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model User extends Basic {
name String
}
The generated prisma file only contains one User
model:
model User {
id String @id
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @id
}
Type
Types provide a way to modeling object shapes without mapping them to a database table. The use cases of "types" include:
- Typing JSON fields in models.
- Typing plugin options.
- Interfacing with data models from external sources (auth providers like Clerk, payment providers like Stripe, etc.).
Syntax
type [NAME] {
[FIELD]*
}
-
[NAME]:
Name of the model. Needs to be unique in the entire model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. -
[FIELD]:
Arbitrary number of fields. See Field for details.
Example
type Profile {
email String @email
name String
}
model User {
id String @id
profile Profile? @json
}
Limitations
- Inheritance is not supported for types yet. A type cannot inherit from another type. A model cannot extend a type.
- Type cannot have type-level attributes like
@@validate
. However, fields in a type can have field-level attributes.
We may address these limitations in future versions.
Attribute
Attributes decorate fields and models and attach extra behaviors or constraints to them.
Syntax
Field attribute
Field attribute name is prefixed by a single @
.
id String @[ATTR_NAME](ARGS)?
- [ATTR_NAME]
Attribute name. See below for a full list of attributes.
- [ARGS]
See attribute arguments.
Model attribute
Field attribute name is prefixed double @@
.
model Model {
@@[ATTR_NAME](ARGS)?
}
- [ATTR_NAME]
Attribute name. See below for a full list of attributes.
- [ARGS]
See attribute arguments.
Arguments
Attribute can be declared with a list of parameters and applied with a comma-separated list of arguments.
Arguments are mapped to parameters by position or by name. For example, for the @default
attribute declared as:
attribute @default(_ value: ContextType)
, the following two ways of applying it are equivalent:
published Boolean @default(value: false)
published Boolean @default(false)
Parameter types
Attribute parameters are typed. The following types are supported:
-
Int
Integer literal can be passed as argument.
E.g., declaration:
attribute @password(saltLength: Int?, salt: String?)
application:
password String @password(saltLength: 10)
-
String
String literal can be passed as argument.
E.g., declaration:
attribute @id(map: String?)
application:
id String @id(map: "_id")
-
Boolean
Boolean literal or expression can be passed as argument.
E.g., declaration:
attribute @@allow(_ operation: String, _ condition: Boolean)
application:
@@allow("read", true)
@@allow("update", auth() != null) -
ContextType
A special type that represents the type of the field onto which the attribute is attached.
E.g., declaration:
attribute @default(_ value: ContextType)
application:
f1 String @default("hello")
f2 Int @default(1) -
FieldReference
References to fields defined in the current model.
E.g., declaration:
attribute @relation(
_ name: String?,
fields: FieldReference[]?,
references: FieldReference[]?,
onDelete: ReferentialAction?,
onUpdate: ReferentialAction?,
map: String?)application:
model Model {
...
// [ownerId] is a list of FieldReference
owner Owner @relation(fields: [ownerId], references: [id])
ownerId
} -
Enum
Attribute parameter can also be typed as predefined enum.
E.g., declaration:
attribute @relation(
_ name: String?,
fields: FieldReference[]?,
references: FieldReference[]?,
// ReferentialAction is a predefined enum
onDelete: ReferentialAction?,
onUpdate: ReferentialAction?,
map: String?)application:
model Model {
// 'Cascade' is a predefined enum value
owner Owner @relation(..., onDelete: Cascade)
}
An attribute parameter can be typed as any of the types above, a list of the above type, or an optional of the types above.
model Model {
...
f1 String
f2 String
// a list of FieldReference
@@unique([f1, f2])
}
Attribute functions
Attribute functions are used for providing values for attribute arguments, e.g., current DateTime
, an autoincrement Int
, etc. They can be used in place of attribute arguments, like:
model Model {
...
serial Int @default(autoincrement())
createdAt DateTime @default(now())
}
You can find a list of predefined attribute functions here.
Predefined attributes
Field attributes
@id
attribute @id(map: String?)
Defines an ID on the model.
Params:
Name | Description |
---|---|
map | The name of the underlying primary key constraint in the database |
@default
attribute @default(_ value: ContextType)
Defines a default value for a field.
Params:
Name | Description |
---|---|
value | The default value expression |
@unique
attribute @unique(map: String?)
Defines a unique constraint for this field.
Params:
Name | Description |
---|---|
map | The name of the underlying primary key constraint in the database |
@relation
attribute @relation(
_ name: String?,
fields: FieldReference[]?,
references: FieldReference[]?,
onDelete: ReferentialAction?,
onUpdate: ReferentialAction?,
map: String?)
Defines meta information about a relation.
Params:
Name | Description |
---|---|
name | The name of the relationship |
fields | A list of fields defined in the current model |
references | A list of fields of the model on the other side of the relation |
onDelete | Referential action to take on delete. See details here. |
onUpdate | Referential action to take on update. See details here. |
@map
attribute @map(_ name: String)
Maps a field name or enum value from the schema to a column with a different name in the database.
Params:
Name | Description |
---|---|
map | The name of the underlying column in the database |
@updatedAt
attribute @updatedAt()
Automatically stores the time when a record was last updated.
@ignore
attribute @ignore()
Exclude a field from the Prisma Client (for example, a field that you do not want Prisma users to update).
@allow
attribute @allow(_ operation: String, _ condition: Boolean)
Defines an access policy that allows the annotated field to be read or updated. Read more about access policies here.
Params:
Name | Description |
---|---|
operation | Comma separated list of operations to control, including "read" and "update" . Pass "all" as an abbreviation for including all operations. |
condition | Boolean expression indicating if the operations should be allowed |
@deny
attribute @deny(_ operation: String, _ condition: Boolean)
Defines an access policy that denies the annotated field to be read or updated. Read more about access policies here.
Params:
Name | Description |
---|---|
operation | Comma separated list of operations to control, including "read" and "update" . Pass "all" as an abbreviation for including all operations. |
condition | Boolean expression indicating if the operations should be denied |
@password
attribute @password(saltLength: Int?, salt: String?)
Indicates that the field is a password field and needs to be hashed before persistence.
NOTE: ZenStack uses the "bcryptjs" library to hash passwords. You can use the saltLength
parameter to configure the cost of hashing or use salt
parameter to provide an explicit salt. By default, a salt length of 12 is used. See here for more details.
Params:
Name | Description |
---|---|
saltLength | The length of salt to use (cost factor for the hash function) |
salt | The salt to use (a pregenerated valid salt) |
@omit
attribute @omit()
Indicates that the field should be omitted when read from the generated services. Commonly used together with @password
attribute.
@json
attribute @json()
Marks a field to be strong-typed JSON. The field's type must be a Type declaration.
@prisma.passthrough
attribute @prisma.passthrough(_ text: String)
A utility attribute for passing arbitrary text to the generated Prisma schema. This is useful as a workaround for dealing with discrepancies between Prisma schema and ZModel.
Params:
Name | Description |
---|---|
text | Text to passthrough to Prisma schema |
E.g., the following ZModel content:
model User {
id Int @id @default(autoincrement())
name String @prisma.passthrough("@unique")
}
wil be translated to the following Prisma schema:
model User {
id Int @id @default(autoincrement())
name String @unique
}
Model attributes
@@id
attribute @@id(_ fields: FieldReference[], name: String?, map: String?)
Defines a multi-field ID (composite ID) on the model.
Params:
Name | Description |
---|---|
fields | A list of fields defined in the current model |
name | The name that the Client API will expose for the argument covering all fields |
map | The name of the underlying primary key constraint in the database |
@@unique
attribute @@unique(_ fields: FieldReference[], name: String?, map: String?)
Defines a compound unique constraint for the specified fields.
Params:
Name | Description |
---|---|
fields | A list of fields defined in the current model |
name | The name of the unique combination of fields |
map | The name of the underlying unique constraint in the database |
@@schema
attribute @@schema(_ name: String)
Specifies the database schema to use in a multi-schema setup.
Params:
Name | Description |
---|---|
name | The name of the database schema |
@@index
attribute @@index(_ fields: FieldReference[], map: String?)
Defines an index in the database.
Params:
Name | Description |
---|---|
fields | A list of fields defined in the current model |
map | The name of the underlying index in the database |
@@map
attribute @@map(_ name: String)
Maps the schema model name to a table with a different name, or an enum name to a different underlying enum in the database.
Params:
Name | Description |
---|---|
name | The name of the underlying table or enum in the database |
@@ignore
attribute @@ignore()
Exclude a model from the Prisma Client (for example, a model that you do not want Prisma users to update).
@@allow
attribute @@allow(_ operation: String, _ condition: Boolean)
Defines an access policy that allows a set of operations when the given condition is true. Read more about access policies here.
Params:
Name | Description |
---|---|
operation | Comma separated list of operations to control, including "create" , "read" , "update" , and "delete" . Pass "all" as an abbriviation for including all operations. |
condition | Boolean expression indicating if the operations should be allowed |
@@deny
attribute @@deny(_ operation: String, _ condition: Boolean)
Defines an access policy that denies a set of operations when the given condition is true. Read more about access policies here.
Params:
Name | Description |
---|---|
operation | Comma separated list of operations to control, including "create" , "read" , "update" , and "delete" . Pass "all" as an abbreviation for including all operations. |
condition | Boolean expression indicating if the operations should be denied |
@@auth
attribute @@auth()
Specify the model for resolving auth()
function call in access policies. By default, the model named "User" is used. You can use this attribute to override the default behavior. A Zmodel can have at most one model with this attribute.
@@auth
attribute @@delegate(_ discriminator: FieldReference)
Marks a model to be a delegated type. Used for modeling a polymorphic hierarchy.
Params:
Name | Description |
---|---|
discriminator | A String or enum field in the same model used to store the name of the concrete model that inherit from this base model. |
@@prisma.passthrough
attribute @@prisma.passthrough(_ text: String)
A utility attribute for passing arbitrary text to the generated Prisma schema. This is useful as a workaround for dealing with discrepancies between Prisma schema and ZModel.
Params:
Name | Description |
---|---|
text | Text to passthrough to Prisma schema |
E.g., the following ZModel content:
model User {
id Int @id @default(autoincrement())
name String
@@prisma.passthrough("@@unique([name])")
}
wil be translated to the following Prisma schema:
model User {
id Int @id @default(autoincrement())
name String
@@unique([name])
}
Predefined attribute functions
uuid()
function uuid(): String {}
Generates a globally unique identifier based on the UUID spec.
cuid()
function cuid(): String {}
Generates a globally unique identifier based on the CUID spec.
nanoid()
function nanoid(length: Int?): String {}
Generates an identifier based on the nanoid spec.
now()
function now(): DateTime {}
Gets current date-time.
autoincrement()
function autoincrement(): Int {}
Creates a sequence of integers in the underlying database and assign the incremented values to the ID values of the created records based on the sequence.
dbgenerated()
function dbgenerated(expr: String): Any {}
Represents default values that cannot be expressed in the Prisma schema (such as random()).
auth()
function auth(): User {}
Gets the current login user. The return type of the function is the User
model defined in the current ZModel.
future()
function future(): Any {}
Gets the "post-update" state of an entity. Only valid when used in a "update" access policy. Read more about access policies here.
check()
function check(field: FieldReference, operation String?): Boolean {}
Checks if the current user can perform the given operation on the given field.
Params
field
: The field to check access for. Must be a relation field.operation
: The operation to check access for. Can be "read", "create", "update", or "delete". If the operation is not provided, it defaults the operation of the containing policy rule.
Example
// delegating a single operation kind
model Post {
id Int @id
author User @relation(fields: [authorId], references: [id])
authorId Int
// delegate "read" check to the author, equivalent to
// @@allow('read', check(author))
@@allow('read', check(author, 'read'))
}
// delegating all operations
model Post {
id Int @id
author User @relation(fields: [authorId], references: [id])
authorId Int
// delegate all access policies to the author, equivalent to:
// @@allow('read', check(author))
// @@allow('create', check(author))
// @@allow('update', check(author))
// @@allow('delete', check(author))
@@allow('all', check(author))
}
// delegating field access control
model Post {
id Int @id
title String @allow('update', check(author))
author User @relation(fields: [authorId], references: [id])
authorId Int
}
The check()
function only supports singular relation fields and cannot be used with "to-many" relations. We may add support for it in the future.
contains()
function contains(field: String, search: String, caseInSensitive: Boolean?): Boolean {}
Checks if the given field contains the search string. The search string is case-sensitive by default. Use caseInSensitive
to toggle the case sensitivity.
Equivalent to Prisma's contains operator.
search()
function search(field: String, search: String): Boolean {}
Checks if the given field contains the search string using full-text-search.
Equivalent to Prisma's search operator.
startsWith()
function startsWith(field: String, search: String): Boolean {}
Checks if the given field starts with the search string.
Equivalent to Prisma's startsWith operator.
endsWith()
function endsWith(field: String, search: String): Boolean {}
Checks if the given field ends with the search string.
Equivalent to Prisma's endsWith operator.
has()
function has(field: Any[], search: Any): Boolean {}
Check if the given field (list) contains the search value.
Equivalent to Prisma's has operator.
hasEvery()
function hasEvery(field: Any[], search: Any[]): Boolean {}
Check if the given field (list) contains every element of the search list.
Equivalent to Prisma's hasEvery operator.
hasSome
function hasSome(field: Any[], search: Any[]): Boolean {}
Check if the given field (list) contains at least one element of the search list.
Equivalent to Prisma's hasSome operator.
isEmpty
function isEmpty(field: Any[]): Boolean {}
Check if the given field (list) is empty.
Equivalent to Prisma's isEmpty operator.
Examples
Here're some examples on using field and model attributes:
model User {
// unique id field with a default UUID value
id String @id @default(uuid())
// require email field to be unique
email String @unique
// password is hashed with bcrypt with length of 16, omitted when returned from the CRUD services
password String @password(saltLength: 16) @omit
// default to current date-time
createdAt DateTime @default(now())
// auto-updated when the entity is modified
updatedAt DateTime @updatedAt
// mapping to a different column name in database
description String @map("desc")
// mapping to a different table name in database
@@map("users")
// use @@index to specify fields to create database index for
@@index([email])
// use @@allow to specify access policies
@@allow("create,read", true)
// use auth() to reference the current user
// use future() to access the "post-update" state
@@allow("update", auth() == this && future().email == email)
}
Custom attributes and functions
You can find examples of custom attributes and functions in ZModel Standard Library.
Field
Fields are typed members of models and types.
Syntax
model Model {
[FIELD_NAME] [FIELD_TYPE] (FIELD_ATTRIBUTES)?
}
Or
type Type {
[FIELD_NAME] [FIELD_TYPE] (FIELD_ATTRIBUTES)?
}
-
[FIELD_NAME]
Name of the field. Needs to be unique in the containing model. Needs to be a valid identifier matching regular expression
[A-Za-z][a-za-z0-9_]\*
. -
[FIELD_TYPE]
Type of the field. Can be a scalar type, a reference to another model if the field belongs to a model, or a reference to another type if it belongs to a type.
The following scalar types are supported:
- String
- Boolean
- Int
- BigInt
- Float
- Decimal
- Json
- Bytes
- Unsupported types
A field's type can be any of the scalar or reference type, a list of the aforementioned type (suffixed with
[]
), or an optional of the aforementioned type (suffixed with?
). -
[FIELD_ATTRIBUTES]
Field attributes attach extra behaviors or constraints to the field. See Attribute for more information.
Example
model Post {
// "id" field is a mandatory unique identifier of this model
id String @id @default(uuid())
// fields can be DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// or string
title String
// or integer
viewCount Int @default(0)
// and optional
content String?
// and a list too
tags String[]
// and can reference another model too
comments Comment[]
}
Relation
Relations are connections among models. There're three types of relations:
- One-to-one
- One-to-many
- Many-to-many
Relations are expressed with a pair of fields and together with the special @relation
field attribute. One side of the relation field carries the @relation
attribute to indicate how the connection is established.
One-to-one relation
The owner side of the relation declares an optional field typed as the model of the owned side of the relation.
On the owned side, a reference field is declared with @relation
attribute, together with a foreign key field storing the id of the owner entity.
model User {
id String @id
profile Profile?
}
model Profile {
id String @id
user User @relation(fields: [userId], references: [id])
userId String @unique
}
One-to-many relation
The owner side of the relation declares a list field typed as the model of the owned side of the relation.
On the owned side, a reference field is declared with @relation
attribute, together with a foreign key field storing the id of the owner entity.
model User {
id String @id
posts Post[]
}
model Post {
id String @id
author User? @relation(fields: [authorId], references: [id])
authorId String?
}
Many-to-many relation
A join model is declared to connect the two sides of the relation using two one-to-one relations.
Each side of the relation then establishes a one-to-many relation with the join model.
model Space {
id String @id
// one-to-many with the "join-model"
members Membership[]
}
// Membership is the "join-model" between User and Space
model Membership {
id String @id()
// one-to-many from Space
space Space @relation(fields: [spaceId], references: [id])
spaceId String
// one-to-many from User
user User @relation(fields: [userId], references: [id])
userId String
// a user can be member of a space for only once
@@unique([userId, spaceId])
}
model User {
id String @id
// one-to-many with the "join-model"
membership Membership[]
}
Self-relations
A relation field referencing its own model is called "self-relation". ZModel's represents self-relation in the same way as Prisma does. Please refer to the Prisma documentation for more details.
Referential action
When defining a relation, you can specify what happens when one side of a relation is updated or deleted. See Referential action for details.
Access policy
Model-level policy
Model-level access policies are defined with @@allow
and @@deny
attributes. They specify the eligibility of an operation over a model entity. The signatures of the attributes are:
-
@@allow
attribute @@allow(_ operation: String, _ condition: Boolean)
Params:
Name Description operation Comma separated list of operations to control, including "create"
,"read"
,"update"
, and"delete"
. Pass"all"
as an abbreviation for including all operations.condition Boolean expression indicating if the operations should be allowed -
@@deny
attribute @@deny(_ operation: String, _ condition: Boolean)
Params:
Name Description operation Comma separated list of operations to control, including "create"
,"read"
,"update"
, and"delete"
. Pass"all"
as an abbreviation for including all operations.condition Boolean expression indicating if the operations should be denied
Field-level policy
Field-level access policies are defined with @allow
and @deny
attributes. They control whether the annotated field can be read or updated. If a field fails "read" check, it'll be deleted when returned. If a field is set to be updated but fails "update" check, the update operation will be rejected.
Note that it's not allowed to put "update" rule on relation fields, because whether an entity can be updated shouldn't be determined indirectly by a relation, but directly by the entity itself. However, you can put "update" rule on a foreign key field to control how a a relation can be updated.
The signatures of the attributes are:
-
@allow
attribute @allow(_ operation: String, _ condition: Boolean, _ override: Boolean?)
Params:
Name Description Default operation Comma separated list of operations to control, including "read"
and"update"
. Pass"all"
as an abbreviation for including all operations.condition Boolean expression indicating if the operations should be allowed override Boolean indicating if the field-level policy should override model-level ones. See here for more details. false -
@deny
attribute @deny(_ operation: String, _ condition: Boolean)
Params:
Name Description operation Comma separated list of operations to control, including ``"read" and
"update". Pass
"all"` as an abbreviation for including all operations.condition Boolean expression indicating if the operations should be denied
Policy expressions
Policy rules use boolean expressions to make verdicts. ZModel provides a set of literals and operators for constructing expressions of arbitrary complexity.
Expression ::= Literal | Array | This | Null | Reference | MemberAccess | Invocation | Binary | Unary | CollectionPredicate
Literal ::= String | Number | Boolean
Array ::= "[" Expression [, Expression]* "]"
This ::= "this"
Null ::= "null"
Reference ::= Identifier
MemberAccess ::= Expression "." Identifier
Operator_Precedence#table
Binary ::= Expression ("==" | "!=" | ">" | "<" | ">=" | "<=" | "&&" | "||" || "in")
Unary ::= "!" Expression
CollectionPredicate ::= Expression ("?" | "!" | "^") "[" Expression "]"
Binary operator precedence follows Javascript's rules.
Collection predicate expressions are used for reaching into relation fields. You can find more details here.
Using authentication in policy rules
It's very common to use the current login user to verdict if an operation should be permitted. Therefore, ZenStack provides a built-in auth()
attribute function that evaluates to the User
entity corresponding to the current user. To use the function, your ZModel file must define a User
model or a model marked with the @@auth
attribute.
You can use auth()
to:
-
Check if a user is logged in
@@deny('all', auth() == null)
-
Access user's fields
@@allow('update', auth().role == 'ADMIN')
-
Compare user identity
// owner is a relation field to User model
@@allow('update', auth() == owner)
Accessing relation fields in policy
As you've seen in the examples above, you can access fields from relations in policy expressions. For example, to express "a user can be read by any user sharing a space" in the User
model, you can directly read into its membership
field.
@@allow('read', membership?[space.members?[user == auth()]])
In most cases, when you use a "to-many" relation in a policy rule, you'll use "Collection Predicate" to express a condition. See next section for details.
Collection predicate expressions
Collection predicate expressions are boolean expressions used to express conditions over a list. It's mainly designed for building policy rules for "to-many" relations. It has three forms of syntaxes:
-
Any
<collection>?[condition]
Any element in
collection
matchescondition
-
All
<collection>![condition]
All elements in
collection
matchcondition
-
None
<collection>^[condition]
None element in
collection
matchescondition
The condition
expression has direct access to fields defined in the model of collection
. E.g.:
@@allow('read', members?[user == auth()])
, in condition user == auth()
, user
refers to the user
field in model Membership
, because the collection members
is resolved to Membership
model.
Also, collection predicates can be nested to express complex conditions involving multi-level relation lookup. E.g.:
@@allow('read', membership?[space.members?[user == auth()]])
In this example, user
refers to user
field of Membership
model because space.members
is resolved to Membership
model.
Combining multiple rules
A model can contain an arbitrary number of policy rules. The logic of combining model-level rules is as follows:
- The operation is rejected if any of the conditions in
@@deny
rules evaluate totrue
. - Otherwise, the operation is permitted if any of the conditions in
@@allow
rules evaluate totrue
. - Otherwise, the operation is rejected.
A field can also contain an arbitrary number of policy rules. The logic of combining field-level rules is as follows:
- The operation is rejected if any of the conditions in
@deny
rules evaluate totrue
. - Otherwise, if there exists any
@allow
rule and at least one of them evaluates totrue
, the operation is permitted. - Otherwise, if there exists any
@allow
rule but none one of them evaluates totrue
, the operation is rejected. - Otherwise, the operation is permitted.
Please note the difference between model-level and field-level rules. Model-level access are by-default denied, while field-level access are by-default allowed.
"Create" rules
The "create" policy rules should be understood as: if an entity were to be created, would it satisfy the rules. Or, in other words, the rules are checked "post create".
An entity creating process works like the following:
- Initiate a transaction and create the entity.
- In the same transaction, try to read the created entity with the "create" rules as filter, and see if it succeeds.
- If the read fails, the transaction is rolled back; otherwise it's committed.
The "post-create check" semantic allows the rules to access relations of the entity being created since they are only accessible after the create happens. For simple cases, ZenStack may apply optimizations to reject a create request without initiating a transaction, but generally speaking the "post-create check" semantic is the correct way to think about it.
We may introduce a "pre-create" policy type in the future.
"Pre-update" vs." post-update" rules
When an access policy rule is applied to a mutate operation, the entities under operation have a "pre" and "post" state. For a "create" rule, its "pre" state is empty, so the rule implicitly refers to the "post" state. For a "delete" rule, its "post" state is empty, so the rule implicitly refers to the "pre" state.
However, for "update" rules it is ambiguous; both the "pre" and the "post" states exist. By default, for "update" rules, fields referenced in the expressions refer to the "pre" state, and you can use the future()
function to refer to the "post" state explicitly.
In the following example, the "update" rule uses future()
to ensure an update cannot alter the post's owner.
model Post {
id String @id @default(uuid())
title String @length(1, 100)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String
// update can only be done by the author, and is not allowed to change author
@@allow('update', author == auth() && future().author == author)
}
The future()
function is not supported in field-level access policies. To express post-update rules, put them into model-level policies.
Examples
A simple example with Post model
model Post {
// reject all operations if user's not logged in
@@deny('all', auth() == null)
// allow all operations if the entity's owner matches the current user
@@allow('all', auth() == owner)
// posts are readable to anyone
@allow('read', true)
}
A more complex example with multi-user spaces
model Space {
id String @id
members Membership[]
owner User @relation(fields: [ownerId], references: [id])
ownerId String
// require login
@@deny('all', auth() == null)
// everyone can create a space
@@allow('create', true)
// owner can do everything
@@allow('all', auth() == owner)
// any user in the space can read the space
//
// Here the <collection>?[condition] syntax is called
// "Collection Predicate", used to check if any element
// in the "collection" matches the "condition"
@@allow('read', members?[user == auth()])
}
// Membership is the "join-model" between User and Space
model Membership {
id String @id()
// one-to-many from Space
space Space @relation(fields: [spaceId], references: [id])
spaceId String
// one-to-many from User
user User @relation(fields: [userId], references: [id])
userId String
// a user can be member of a space for only once
@@unique([userId, spaceId])
// require login
@@deny('all', auth() == null)
// space owner can create/update/delete
@@allow('create,update,delete', space.owner == auth())
// user can read entries for spaces which he's a member of
@@allow('read', space.members?[user == auth()])
}
model User {
id String @id
email String @unique
membership Membership[]
ownedSpaces Space[]
// allow signup
@@allow('create', true)
// user can do everything to herself; note that "this" represents
// the current entity
@@allow('all', auth() == this)
// can be read by users sharing a space
@@allow('read', membership?[space.members?[user == auth()]])
}
Data validation
Overview
Data validation is used for attaching constraints to field values. Unlike access policies, field validation rules cannot access the current user with the auth()
function and are only checked for 'create' and 'update' operations. The main purpose of field validation is to ensure data integrity and consistency, not for access control.
The @core/zod
plugin recognizes the validation attributes and includes them into the generated Zod schemas.
Field-level validation attributes
The following attributes can be used to attach validation rules to individual fields:
String
-
@length(_ min: Int?, _ max: Int?, _ message: String?)
Validates length of a string field.
-
@startsWith(_ text: String, _ message: String?)
Validates a string field value starts with the given text.
-
@endsWith(_ text: String, _ message: String?)
Validates a string field value ends with the given text.
-
@contains(_text: String, _ message: String?)
Validates a string field value contains the given text.
-
@email(_ message: String?)
Validates a string field value is a valid email address.
-
@url(_ message: String?)
Validates a string field value is a valid url.
-
@datetime(_ message: String?)
Validates a string field value is a valid ISO datetime.
-
@regex(_ regex: String, _ message: String?)
Validates a string field value matches a regex.
-
@trim(_ value: String)
Trims whitespace.
-
@lower(_ value: String)
Converts to lowercase.
-
@upper(_ value: String)
Converts to uppercase.
Attributes @trim
, @lower
, and @upper
are actually "transformation" instead of "validation". They make sure the values are transformed before storing into the database.
Number
-
@gt(_ value: Int, _ message: String?)
Validates a number field is greater than the given value.
-
@gte(_ value: Int, _ message: String?)
Validates a number field is greater than or equal to the given value.
-
@lt(_ value: Int, _ message: String?)
Validates a number field is less than the given value.
-
@lte(_ value: Int, _ message: String?)
Validates a number field is less than or equal to the given value.
Model-level validation attributes
You can use the @@validate
attribute to attach validation rules to a model. Use the message
parameter to provide an optional custom error message, and the path
parameter to provide an optional path to the field that caused the error.
@@validate(_ value: Boolean, _ message: String?, _ path: String[]?)
Model-level rules can reference multiple fields, use relation operators (==
, !=
, >
, >=
, <
, <=
) to compare fields, use boolean operators (&&
, ||
, and !
) to compose conditions, and can use the following functions to evaluate conditions for fields:
-
function length(field: String, min: Int, max: Int?): Boolean
Validates length of a string field.
-
function regex(field: String, regex: String): Boolean
Validates a string field value matches a regex.
-
function email(field: String): Boolean
Validates a string field value is a valid email address.
-
function datetime(field: String): Boolean
Validates a string field value is a valid ISO datetime.
-
function url(field: String)
Validates a string field value is a valid url.
-
function contains(field: String, search: String, caseInSensitive: Boolean?): Boolean
Validates a string field contains the search string.
-
function startsWith(field: String, search: String): Boolean
Validates a string field starts with the search string.
-
function endsWith(field: String, search: String): Boolean
Validates a string field ends with the search string.
-
function has(field: Any[], search: Any): Boolean
Validates a list field contains the search value.
-
function hasEvery(field: Any[], search: Any[]): Boolean
Validates a list field contains every element in the search list.
-
function hasSome(field: Any[], search: Any[]): Boolean
Validates a list field contains some elements in the search list.
-
function isEmpty(field: Any[]): Boolean
Validates a list field is null or empty.
Example
model User {
id String @id
handle String @regex("^[0-9a-zA-Z]{4,16}$")
email String? @email @endsWith("@myorg.com", "must be an email from myorg.com")
profileImage String? @url
age Int @gte(18)
activated Boolean @default(false)
@@validate(!activated || email != null, "activated user must have an email")
}
Referential action
Overview
When defining a relation, you can use referential action to control what happens when one side of a relation is updated or deleted by setting the onDelete
and onUpdate
parameters in the @relation
attribute.
attribute @relation(
_ name: String?,
fields: FieldReference[]?,
references: FieldReference[]?,
onDelete: ReferentialAction?,
onUpdate: ReferentialAction?,
map: String?)
The ReferentialAction
enum is defined as:
enum ReferentialAction {
Cascade
Restrict
NoAction
SetNull
SetDefault
}
-
Cascade
-
onDelete: deleting a referenced record will trigger the deletion of referencing record.
-
onUpdate: updates the relation scalar fields if the referenced scalar fields of the dependent record are updated.
-
-
Restrict
- onDelete: prevents the deletion if any referencing records exist.
- onUpdate: prevents the identifier of a referenced record from being changed.
-
NoAction
Similar to 'Restrict', the difference between the two is dependent on the database being used.
See details here
-
SetNull
- onDelete: the scalar field of the referencing object will be set to NULL.
- onUpdate: when updating the identifier of a referenced object, the scalar fields of the referencing objects will be set to NULL.
-
SetDefault
- onDelete: the scalar field of the referencing object will be set to the fields default value.
- onUpdate: the scalar field of the referencing object will be set to the fields default value.
Example
model User {
id String @id
profile Profile?
}
model Profile {
id String @id
user @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade)
userId String @unique
}
Comments
ZModel supports both line comments (starting with //
) and block comments (starting with /*
and ending with */
). Comments on declarations (models, enums, fields, etc.) starting with triple slashes (///
) are treated as documentation:
- They show up as hover tooltips in IDEs.
- They are passed along to the generated Prisma schema.
/// A user model
model User {
id String @id
/// The user's email
email String @unique
}
You can also use JSDoc-style comments as documentation, however they are not passed along to the generated Prisma schema.
/**
* A user model
*/
model User {
id String @id
/**
* The user's email
*/
email String @unique
}