diff --git a/README.md b/README.md index 070b960e5..75cab857b 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ The following diagram gives a high-level architecture overview of ZenStack. ### Plugins - Prisma schema generator -- Zod schema generator +- [Zod](https://zod.dev/) schema generator - [SWR](https://github.com/vercel/swr) and [TanStack Query](https://github.com/TanStack/query) hooks generator - OpenAPI specification generator - [tRPC](https://trpc.io) router generator @@ -166,7 +166,7 @@ The following diagram gives a high-level architecture overview of ZenStack. ### Framework adapters -- [Next.js](https://zenstack.dev/docs/reference/server-adapters/next) (including support for the new "app directory" in Next.js 13) +- [Next.js](https://zenstack.dev/docs/reference/server-adapters/next) - [Nuxt](https://zenstack.dev/docs/reference/server-adapters/nuxt) - [SvelteKit](https://zenstack.dev/docs/reference/server-adapters/sveltekit) - [Fastify](https://zenstack.dev/docs/reference/server-adapters/fastify) @@ -180,7 +180,7 @@ The following diagram gives a high-level architecture overview of ZenStack. - [Custom attributes and functions](https://zenstack.dev/docs/reference/zmodel-language#custom-attributes-and-functions) - [Multi-file schema and model inheritance](https://zenstack.dev/docs/guides/multiple-schema) - [Polymorphic Relations](https://zenstack.dev/docs/guides/polymorphism) -- Strong-typed JSON field (coming soon) +- [Strongly typed JSON field](https://zenstack.dev/docs/guides/typing-json) - 🙋🏻 [Request for an extension](https://discord.gg/Ykhr738dUe) ## Examples @@ -200,19 +200,19 @@ You can use [this blog post](https://zenstack.dev/blog/model-authz) as an introd Check out the [Multi-tenant Todo App](https://zenstack-todo.vercel.app/) for a running example. You can find different implementations below: -- [Next.js 13 + NextAuth + SWR](https://github.com/zenstackhq/sample-todo-nextjs) -- [Next.js 13 + NextAuth + TanStack Query](https://github.com/zenstackhq/sample-todo-nextjs-tanstack) -- [Next.js 13 + NextAuth + tRPC](https://github.com/zenstackhq/sample-todo-trpc) -- [Nuxt V3 + TanStack Query](https://github.com/zenstackhq/sample-todo-nuxt) +- [Next.js + NextAuth + TanStack Query](https://github.com/zenstackhq/sample-todo-nextjs-tanstack) +- [Next.js + NextAuth + SWR](https://github.com/zenstackhq/sample-todo-nextjs) +- [Next.js + NextAuth + tRPC](https://github.com/zenstackhq/sample-todo-trpc) +- [Nuxt + TanStack Query](https://github.com/zenstackhq/sample-todo-nuxt) - [SvelteKit + TanStack Query](https://github.com/zenstackhq/sample-todo-sveltekit) - [RedwoodJS](https://github.com/zenstackhq/sample-todo-redwood) ### Blog App -- [Next.js 13 + Pages Route + SWR](https://github.com/zenstackhq/docs-tutorial-nextjs) -- [Next.js 13 + App Route + ReactQuery](https://github.com/zenstackhq/docs-tutorial-nextjs-app-dir) -- [Next.js 13 + App Route + tRPC](https://github.com/zenstackhq/sample-blog-nextjs-app-trpc) -- [Nuxt V3 + TanStack Query](https://github.com/zenstackhq/docs-tutorial-nuxt) +- [Next.js + App Route + TanStack Query](https://github.com/zenstackhq/docs-tutorial-nextjs-app-dir) +- [Next.js + Pages Route + SWR](https://github.com/zenstackhq/docs-tutorial-nextjs) +- [Next.js + App Route + tRPC](https://github.com/zenstackhq/sample-blog-nextjs-app-trpc) +- [Nuxt + TanStack Query](https://github.com/zenstackhq/docs-tutorial-nuxt) - [SvelteKit](https://github.com/zenstackhq/docs-tutorial-sveltekit) - [Remix](https://github.com/zenstackhq/docs-tutorial-remix) - [NestJS Backend API](https://github.com/zenstackhq/docs-tutorial-nestjs) @@ -225,7 +225,7 @@ Join our [discord server](https://discord.gg/Ykhr738dUe) for chat and updates! ## Contributing -If you like ZenStack, join us to make it a better tool! Please use the [Contributing Guide](CONTRIBUTING.md) for details on how to get started, and don't hesitate to join [Discord](https://discord.gg/Ykhr738dUe) to share your thoughts. +If you like ZenStack, join us to make it a better tool! Please use the [Contributing Guide](CONTRIBUTING.md) for details on how to get started, and don't hesitate to join [Discord](https://discord.gg/Ykhr738dUe) to share your thoughts. Documentations reside in a separate repo: [zenstack-docs](https://github.com/zenstackhq/zenstack-docs). Please also consider [sponsoring our work](https://github.com/sponsors/zenstackhq) to speed up the development. Your contribution will be 100% used as a bounty reward to encourage community members to help fix bugs, add features, and improve documentation. @@ -241,7 +241,6 @@ Thank you for your generous support! Mermaid Chart
Mermaid Chart
CodeRabbit
CodeRabbit
Johann Rohn
Johann Rohn
- Benjamin Zecirovic
Benjamin Zecirovic
@@ -249,6 +248,7 @@ Thank you for your generous support! + diff --git a/package.json b/package.json index 5865b0c14..519137c35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "2.7.5", + "version": "2.8.0", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/ide/jetbrains/CHANGELOG.md b/packages/ide/jetbrains/CHANGELOG.md index 7587a190d..75dd964c5 100644 --- a/packages/ide/jetbrains/CHANGELOG.md +++ b/packages/ide/jetbrains/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- Type declaration support. + +## 2.7.0 + ### Fixed - ZModel validation issues importing zmodel files from npm packages. diff --git a/packages/ide/jetbrains/build.gradle.kts b/packages/ide/jetbrains/build.gradle.kts index 0fc8a6f16..067e6567e 100644 --- a/packages/ide/jetbrains/build.gradle.kts +++ b/packages/ide/jetbrains/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "dev.zenstack" -version = "2.7.5" +version = "2.8.0" repositories { mavenCentral() diff --git a/packages/ide/jetbrains/package.json b/packages/ide/jetbrains/package.json index 76cfe0e0c..372462b17 100644 --- a/packages/ide/jetbrains/package.json +++ b/packages/ide/jetbrains/package.json @@ -1,6 +1,6 @@ { "name": "jetbrains", - "version": "2.7.5", + "version": "2.8.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 03d937974..cf01ce9ad 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "2.7.5", + "version": "2.8.0", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 98b461285..b4d7e6d82 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -20,7 +20,7 @@ export const ZModelTerminals = { SL_COMMENT: /\/\/[^\n\r]*/, }; -export type AbstractDeclaration = Attribute | DataModel | DataSource | Enum | FunctionDecl | GeneratorDecl | Plugin; +export type AbstractDeclaration = Attribute | DataModel | DataSource | Enum | FunctionDecl | GeneratorDecl | Plugin | TypeDef; export const AbstractDeclaration = 'AbstractDeclaration'; @@ -78,10 +78,10 @@ export function isReferenceTarget(item: unknown): item is ReferenceTarget { return reflection.isInstance(item, ReferenceTarget); } -export type RegularID = 'abstract' | 'attribute' | 'datasource' | 'enum' | 'import' | 'in' | 'model' | 'plugin' | 'view' | string; +export type RegularID = 'abstract' | 'attribute' | 'datasource' | 'enum' | 'import' | 'in' | 'model' | 'plugin' | 'type' | 'view' | string; export function isRegularID(item: unknown): item is RegularID { - return item === 'model' || item === 'enum' || item === 'attribute' || item === 'datasource' || item === 'plugin' || item === 'abstract' || item === 'in' || item === 'view' || item === 'import' || (typeof item === 'string' && (/[_a-zA-Z][\w_]*/.test(item))); + return item === 'model' || item === 'enum' || item === 'attribute' || item === 'datasource' || item === 'plugin' || item === 'abstract' || item === 'in' || item === 'view' || item === 'import' || item === 'type' || (typeof item === 'string' && (/[_a-zA-Z][\w_]*/.test(item))); } export type RegularIDWithTypeNames = 'Any' | 'BigInt' | 'Boolean' | 'Bytes' | 'DateTime' | 'Decimal' | 'Float' | 'Int' | 'Json' | 'Null' | 'Object' | 'String' | 'Unsupported' | RegularID; @@ -90,7 +90,7 @@ export function isRegularIDWithTypeNames(item: unknown): item is RegularIDWithTy return isRegularID(item) || item === 'String' || item === 'Boolean' || item === 'Int' || item === 'BigInt' || item === 'Float' || item === 'Decimal' || item === 'DateTime' || item === 'Json' || item === 'Bytes' || item === 'Null' || item === 'Object' || item === 'Any' || item === 'Unsupported'; } -export type TypeDeclaration = DataModel | Enum; +export type TypeDeclaration = DataModel | Enum | TypeDef; export const TypeDeclaration = 'TypeDeclaration'; @@ -305,7 +305,7 @@ export function isDataModelField(item: unknown): item is DataModelField { } export interface DataModelFieldAttribute extends AstNode { - readonly $container: DataModelField | EnumField; + readonly $container: DataModelField | EnumField | TypeDefField; readonly $type: 'DataModelFieldAttribute'; args: Array decl: Reference @@ -620,6 +620,50 @@ export function isThisExpr(item: unknown): item is ThisExpr { return reflection.isInstance(item, ThisExpr); } +export interface TypeDef extends AstNode { + readonly $container: Model; + readonly $type: 'TypeDef'; + comments: Array + fields: Array + name: RegularID +} + +export const TypeDef = 'TypeDef'; + +export function isTypeDef(item: unknown): item is TypeDef { + return reflection.isInstance(item, TypeDef); +} + +export interface TypeDefField extends AstNode { + readonly $container: TypeDef; + readonly $type: 'TypeDefField'; + attributes: Array + comments: Array + name: RegularIDWithTypeNames + type: TypeDefFieldType +} + +export const TypeDefField = 'TypeDefField'; + +export function isTypeDefField(item: unknown): item is TypeDefField { + return reflection.isInstance(item, TypeDefField); +} + +export interface TypeDefFieldType extends AstNode { + readonly $container: TypeDefField; + readonly $type: 'TypeDefFieldType'; + array: boolean + optional: boolean + reference?: Reference + type?: BuiltinType +} + +export const TypeDefFieldType = 'TypeDefFieldType'; + +export function isTypeDefFieldType(item: unknown): item is TypeDefFieldType { + return reflection.isInstance(item, TypeDefFieldType); +} + export interface UnaryExpr extends AstNode { readonly $container: Argument | ArrayExpr | AttributeArg | BinaryExpr | ConfigArrayExpr | ConfigField | ConfigInvocationArg | FieldInitializer | FunctionDecl | MemberAccessExpr | PluginField | ReferenceArg | UnaryExpr | UnsupportedFieldType; readonly $type: 'UnaryExpr'; @@ -691,6 +735,9 @@ export type ZModelAstType = { StringLiteral: StringLiteral ThisExpr: ThisExpr TypeDeclaration: TypeDeclaration + TypeDef: TypeDef + TypeDefField: TypeDefField + TypeDefFieldType: TypeDefFieldType UnaryExpr: UnaryExpr UnsupportedFieldType: UnsupportedFieldType } @@ -698,7 +745,7 @@ export type ZModelAstType = { export class ZModelAstReflection extends AbstractAstReflection { getAllTypes(): string[] { - return ['AbstractDeclaration', 'Argument', 'ArrayExpr', 'Attribute', 'AttributeArg', 'AttributeParam', 'AttributeParamType', 'BinaryExpr', 'BooleanLiteral', 'ConfigArrayExpr', 'ConfigExpr', 'ConfigField', 'ConfigInvocationArg', 'ConfigInvocationExpr', 'DataModel', 'DataModelAttribute', 'DataModelField', 'DataModelFieldAttribute', 'DataModelFieldType', 'DataSource', 'Enum', 'EnumField', 'Expression', 'FieldInitializer', 'FunctionDecl', 'FunctionParam', 'FunctionParamType', 'GeneratorDecl', 'InternalAttribute', 'InvocationExpr', 'LiteralExpr', 'MemberAccessExpr', 'Model', 'ModelImport', 'NullExpr', 'NumberLiteral', 'ObjectExpr', 'Plugin', 'PluginField', 'ReferenceArg', 'ReferenceExpr', 'ReferenceTarget', 'StringLiteral', 'ThisExpr', 'TypeDeclaration', 'UnaryExpr', 'UnsupportedFieldType']; + return ['AbstractDeclaration', 'Argument', 'ArrayExpr', 'Attribute', 'AttributeArg', 'AttributeParam', 'AttributeParamType', 'BinaryExpr', 'BooleanLiteral', 'ConfigArrayExpr', 'ConfigExpr', 'ConfigField', 'ConfigInvocationArg', 'ConfigInvocationExpr', 'DataModel', 'DataModelAttribute', 'DataModelField', 'DataModelFieldAttribute', 'DataModelFieldType', 'DataSource', 'Enum', 'EnumField', 'Expression', 'FieldInitializer', 'FunctionDecl', 'FunctionParam', 'FunctionParamType', 'GeneratorDecl', 'InternalAttribute', 'InvocationExpr', 'LiteralExpr', 'MemberAccessExpr', 'Model', 'ModelImport', 'NullExpr', 'NumberLiteral', 'ObjectExpr', 'Plugin', 'PluginField', 'ReferenceArg', 'ReferenceExpr', 'ReferenceTarget', 'StringLiteral', 'ThisExpr', 'TypeDeclaration', 'TypeDef', 'TypeDefField', 'TypeDefFieldType', 'UnaryExpr', 'UnsupportedFieldType']; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -729,7 +776,8 @@ export class ZModelAstReflection extends AbstractAstReflection { return this.isSubtype(ConfigExpr, supertype); } case DataModel: - case Enum: { + case Enum: + case TypeDef: { return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); } case DataModelField: @@ -772,6 +820,9 @@ export class ZModelAstReflection extends AbstractAstReflection { case 'ReferenceExpr:target': { return ReferenceTarget; } + case 'TypeDefFieldType:reference': { + return TypeDef; + } default: { throw new Error(`${referenceId} is not a valid reference id.`); } @@ -989,6 +1040,33 @@ export class ZModelAstReflection extends AbstractAstReflection { ] }; } + case 'TypeDef': { + return { + name: 'TypeDef', + mandatory: [ + { name: 'comments', type: 'array' }, + { name: 'fields', type: 'array' } + ] + }; + } + case 'TypeDefField': { + return { + name: 'TypeDefField', + mandatory: [ + { name: 'attributes', type: 'array' }, + { name: 'comments', type: 'array' } + ] + }; + } + case 'TypeDefFieldType': { + return { + name: 'TypeDefFieldType', + mandatory: [ + { name: 'array', type: 'boolean' }, + { name: 'optional', type: 'boolean' } + ] + }; + } default: { return { name: type, diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 3b172570d..01c492bd5 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -70,7 +70,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@64" + "$ref": "#/rules@67" }, "arguments": [] } @@ -126,21 +126,28 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@43" + "$ref": "#/rules@44" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@48" + "$ref": "#/rules@46" + }, + "arguments": [] + }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@51" }, "arguments": [] } @@ -162,7 +169,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -178,7 +185,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -222,7 +229,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -238,7 +245,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -282,7 +289,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -294,7 +301,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -333,7 +340,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -349,7 +356,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -393,7 +400,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -405,7 +412,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -481,7 +488,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@65" + "$ref": "#/rules@68" }, "arguments": [] } @@ -503,7 +510,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@64" + "$ref": "#/rules@67" }, "arguments": [] } @@ -525,7 +532,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@58" + "$ref": "#/rules@61" }, "arguments": [] } @@ -649,7 +656,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] } @@ -747,7 +754,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] } @@ -956,7 +963,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@50" }, "arguments": [] }, @@ -1055,7 +1062,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] } @@ -1169,14 +1176,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@64" + "$ref": "#/rules@67" }, "arguments": [] } @@ -1221,7 +1228,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@43" + "$ref": "#/rules@46" }, "deprecatedSyntax": false } @@ -1894,7 +1901,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -1927,7 +1934,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -1997,7 +2004,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2032,7 +2039,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@55" }, "arguments": [] } @@ -2066,7 +2073,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2079,7 +2086,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2103,7 +2110,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@54" }, "arguments": [] }, @@ -2134,7 +2141,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@57" + "$ref": "#/rules@60" }, "arguments": [] } @@ -2146,7 +2153,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@43" }, "arguments": [] } @@ -2163,7 +2170,217 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" + }, + "arguments": [] + }, + "deprecatedSyntax": false + } + } + ] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "array", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "[" + } + }, + { + "$type": "Keyword", + "value": "]" + } + ], + "cardinality": "?" + }, + { + "$type": "Assignment", + "feature": "optional", + "operator": "?=", + "terminal": { + "$type": "Keyword", + "value": "?" + }, + "cardinality": "?" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "TypeDef", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "comments", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@69" + }, + "arguments": [] + }, + "cardinality": "*" + }, + { + "$type": "Keyword", + "value": "type" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@49" + }, + "arguments": [] + } + }, + { + "$type": "Keyword", + "value": "{" + }, + { + "$type": "Assignment", + "feature": "fields", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@41" + }, + "arguments": [] + }, + "cardinality": "+" + }, + { + "$type": "Keyword", + "value": "}" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "TypeDefField", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Assignment", + "feature": "comments", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@69" + }, + "arguments": [] + }, + "cardinality": "*" + }, + { + "$type": "Assignment", + "feature": "name", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@50" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "type", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@42" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "attributes", + "operator": "+=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@54" + }, + "arguments": [] + }, + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "fragment": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "TypeDefFieldType", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Alternatives", + "elements": [ + { + "$type": "Assignment", + "feature": "type", + "operator": "=", + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@60" + }, + "arguments": [] + } + }, + { + "$type": "Assignment", + "feature": "reference", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@40" + }, + "terminal": { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@49" }, "arguments": [] }, @@ -2262,7 +2479,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2279,7 +2496,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2298,7 +2515,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@42" + "$ref": "#/rules@45" }, "arguments": [] } @@ -2310,7 +2527,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@52" + "$ref": "#/rules@55" }, "arguments": [] } @@ -2344,7 +2561,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2357,7 +2574,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@47" + "$ref": "#/rules@50" }, "arguments": [] } @@ -2369,7 +2586,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@51" + "$ref": "#/rules@54" }, "arguments": [] }, @@ -2393,7 +2610,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -2409,7 +2626,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2428,7 +2645,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@47" }, "arguments": [] } @@ -2447,7 +2664,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@44" + "$ref": "#/rules@47" }, "arguments": [] } @@ -2473,7 +2690,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@48" }, "arguments": [] } @@ -2506,7 +2723,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -2530,7 +2747,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -2542,7 +2759,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2558,7 +2775,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@45" + "$ref": "#/rules@48" }, "arguments": [] } @@ -2598,7 +2815,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@59" }, "arguments": [] } @@ -2615,7 +2832,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, @@ -2662,7 +2879,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@63" + "$ref": "#/rules@66" }, "arguments": [] }, @@ -2701,6 +2918,10 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Keyword", "value": "import" + }, + { + "$type": "Keyword", + "value": "type" } ] }, @@ -2721,7 +2942,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, @@ -2799,7 +3020,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2819,21 +3040,21 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@63" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@64" }, "arguments": [] }, { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@65" }, "arguments": [] } @@ -2854,7 +3075,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2873,7 +3094,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@49" + "$ref": "#/rules@52" }, "arguments": [] } @@ -2895,7 +3116,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -2923,7 +3144,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [] }, @@ -2946,7 +3167,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -2962,7 +3183,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@50" + "$ref": "#/rules@53" }, "arguments": [] } @@ -2974,7 +3195,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@53" + "$ref": "#/rules@56" }, "arguments": [] }, @@ -3008,7 +3229,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@56" + "$ref": "#/rules@59" }, "arguments": [] }, @@ -3039,7 +3260,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] }, @@ -3099,12 +3320,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@48" + "$ref": "#/rules@51" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@62" + "$ref": "#/rules@65" }, "arguments": [] }, @@ -3121,7 +3342,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@57" }, "arguments": [], "cardinality": "?" @@ -3151,7 +3372,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@66" + "$ref": "#/rules@69" }, "arguments": [], "cardinality": "*" @@ -3163,12 +3384,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@48" + "$ref": "#/rules@51" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@61" + "$ref": "#/rules@64" }, "arguments": [] }, @@ -3185,7 +3406,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@57" }, "arguments": [], "cardinality": "?" @@ -3219,12 +3440,12 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/rules@48" + "$ref": "#/rules@51" }, "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@60" + "$ref": "#/rules@63" }, "arguments": [] }, @@ -3241,7 +3462,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@54" + "$ref": "#/rules@57" }, "arguments": [], "cardinality": "?" @@ -3276,7 +3497,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@58" }, "arguments": [] } @@ -3295,7 +3516,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@55" + "$ref": "#/rules@58" }, "arguments": [] } @@ -3327,7 +3548,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@46" + "$ref": "#/rules@49" }, "arguments": [] } @@ -3599,7 +3820,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@44" + "$ref": "#/rules@47" } }, { @@ -3611,7 +3832,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@42" + "$ref": "#/rules@45" } } ] @@ -3632,7 +3853,13 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" + } + }, + { + "$type": "SimpleType", + "typeRef": { + "$ref": "#/rules@44" } } ] diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 4f12159d6..d66ea5f32 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -11,7 +11,7 @@ ModelImport: 'import' path=STRING ';'?; AbstractDeclaration: - DataSource | GeneratorDecl| Plugin | DataModel | Enum | FunctionDecl | Attribute; + DataSource | GeneratorDecl| Plugin | DataModel | TypeDef | Enum | FunctionDecl | Attribute; // datasource DataSource: @@ -113,22 +113,6 @@ CollectionPredicateExpr infers Expression: '[' right=Expression ']' )*; -// TODO: support arithmetics? -// -// MultDivExpr infers Expression: -// CollectionPredicateExpr ( -// {infer BinaryExpr.left=current} -// operator=('*'|'/') -// right=CollectionPredicateExpr -// )*; - -// AddSubExpr infers Expression: -// MultDivExpr ( -// {infer BinaryExpr.left=current} -// operator=('+'|'-') -// right=MultDivExpr -// )*; - InExpr infers Expression: CollectionPredicateExpr ( {infer BinaryExpr.left=current} @@ -195,6 +179,20 @@ DataModelField: DataModelFieldType: (type=BuiltinType | unsupported=UnsupportedFieldType | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; +TypeDef: + (comments+=TRIPLE_SLASH_COMMENT)* + 'type' name=RegularID '{' ( + fields+=TypeDefField + )+ + '}'; + +TypeDefField: + (comments+=TRIPLE_SLASH_COMMENT)* + name=RegularIDWithTypeNames type=TypeDefFieldType (attributes+=DataModelFieldAttribute)*; + +TypeDefFieldType: + (type=BuiltinType | reference=[TypeDef:RegularID]) (array?='[' ']')? (optional?='?')?; + UnsupportedFieldType: 'Unsupported' '(' (value=LiteralExpr) ')'; @@ -224,7 +222,7 @@ FunctionParamType: // https://github.com/langium/langium/discussions/1012 RegularID returns string: // include keywords that we'd like to work as ID in most places - ID | 'model' | 'enum' | 'attribute' | 'datasource' | 'plugin' | 'abstract' | 'in' | 'view' | 'import'; + ID | 'model' | 'enum' | 'attribute' | 'datasource' | 'plugin' | 'abstract' | 'in' | 'view' | 'import' | 'type'; RegularIDWithTypeNames returns string: RegularID | 'String' | 'Boolean' | 'Int' | 'BigInt' | 'Float' | 'Decimal' | 'DateTime' | 'Json' | 'Bytes' | 'Null' | 'Object' | 'Any' | 'Unsupported'; @@ -241,7 +239,7 @@ AttributeParam: AttributeParamType: (type=(ExpressionType | 'FieldReference' | 'TransitiveFieldReference' | 'ContextType') | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; -type TypeDeclaration = DataModel | Enum; +type TypeDeclaration = DataModel | TypeDef | Enum; DataModelFieldAttribute: decl=[Attribute:FIELD_ATTRIBUTE_NAME] ('(' AttributeArgList? ')')?; diff --git a/packages/language/syntaxes/zmodel.tmLanguage b/packages/language/syntaxes/zmodel.tmLanguage index 6102b919d..40b92fb9a 100644 --- a/packages/language/syntaxes/zmodel.tmLanguage +++ b/packages/language/syntaxes/zmodel.tmLanguage @@ -20,7 +20,7 @@ name keyword.control.zmodel match - \b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|view)\b + \b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|type|view)\b name diff --git a/packages/language/syntaxes/zmodel.tmLanguage.json b/packages/language/syntaxes/zmodel.tmLanguage.json index aad6a38c7..0fb0227e5 100644 --- a/packages/language/syntaxes/zmodel.tmLanguage.json +++ b/packages/language/syntaxes/zmodel.tmLanguage.json @@ -10,7 +10,7 @@ }, { "name": "keyword.control.zmodel", - "match": "\\b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|view)\\b" + "match": "\\b(Any|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|false|function|generator|import|in|model|null|plugin|this|true|type|view)\\b" }, { "name": "string.quoted.double.zmodel", diff --git a/packages/misc/redwood/package.json b/packages/misc/redwood/package.json index 41edf7ead..554556519 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": "2.7.5", + "version": "2.8.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 870667ded..41cfaf7eb 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": "2.7.5", + "version": "2.8.0", "description": "ZenStack plugin and runtime supporting OpenAPI", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/package.json b/packages/plugins/swr/package.json index 8cc181aa6..b72fb3810 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": "2.7.5", + "version": "2.8.0", "description": "ZenStack plugin for generating SWR hooks", "main": "index.js", "repository": { diff --git a/packages/plugins/swr/src/generator.ts b/packages/plugins/swr/src/generator.ts index 46d18d296..967be9727 100644 --- a/packages/plugins/swr/src/generator.ts +++ b/packages/plugins/swr/src/generator.ts @@ -10,7 +10,7 @@ import { resolvePath, saveProject, } from '@zenstackhq/sdk'; -import { DataModel, DataModelFieldType, Model, isEnum } from '@zenstackhq/sdk/ast'; +import { DataModel, DataModelFieldType, Model, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { getPrismaClientImportSpec, supportCreateMany, type DMMF } from '@zenstackhq/sdk/prisma'; import { paramCase } from 'change-case'; import path from 'path'; @@ -28,8 +28,9 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. const warnings: string[] = []; const models = getDataModels(model); + const typeDefs = model.declarations.filter(isTypeDef); - await generateModelMeta(project, models, { + await generateModelMeta(project, models, typeDefs, { output: path.join(outDir, '__model_meta.ts'), generateAttributes: false, }); diff --git a/packages/plugins/swr/src/runtime/index.ts b/packages/plugins/swr/src/runtime/index.ts index 11e73692d..71004d66a 100644 --- a/packages/plugins/swr/src/runtime/index.ts +++ b/packages/plugins/swr/src/runtime/index.ts @@ -428,7 +428,7 @@ function marshal(value: unknown) { function unmarshal(value: string) { const parsed = JSON.parse(value); - if (parsed.data && parsed.meta?.serialization) { + if (typeof parsed === 'object' && parsed?.data && parsed?.meta?.serialization) { const deserializedData = deserialize(parsed.data, parsed.meta.serialization); return { ...parsed, data: deserializedData }; } else { diff --git a/packages/plugins/tanstack-query/package.json b/packages/plugins/tanstack-query/package.json index ba0d1545b..4ac5a58bb 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": "2.7.5", + "version": "2.8.0", "description": "ZenStack plugin for generating tanstack-query hooks", "main": "index.js", "exports": { diff --git a/packages/plugins/tanstack-query/src/generator.ts b/packages/plugins/tanstack-query/src/generator.ts index afb86f9c7..8833f77f9 100644 --- a/packages/plugins/tanstack-query/src/generator.ts +++ b/packages/plugins/tanstack-query/src/generator.ts @@ -11,7 +11,7 @@ import { resolvePath, saveProject, } from '@zenstackhq/sdk'; -import { DataModel, DataModelFieldType, Model, isEnum } from '@zenstackhq/sdk/ast'; +import { DataModel, DataModelFieldType, Model, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { getPrismaClientImportSpec, supportCreateMany, type DMMF } from '@zenstackhq/sdk/prisma'; import { paramCase } from 'change-case'; import { lowerCaseFirst } from 'lower-case-first'; @@ -29,6 +29,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. const project = createProject(); const warnings: string[] = []; const models = getDataModels(model); + const typeDefs = model.declarations.filter(isTypeDef); const target = requireOption(options, 'target', name); if (!supportedTargets.includes(target)) { @@ -44,7 +45,7 @@ export async function generate(model: Model, options: PluginOptions, dmmf: DMMF. outDir = resolvePath(outDir, options); ensureEmptyDir(outDir); - await generateModelMeta(project, models, { + await generateModelMeta(project, models, typeDefs, { output: path.join(outDir, '__model_meta.ts'), generateAttributes: false, }); diff --git a/packages/plugins/tanstack-query/src/runtime/common.ts b/packages/plugins/tanstack-query/src/runtime/common.ts index a595e3423..f63a235fe 100644 --- a/packages/plugins/tanstack-query/src/runtime/common.ts +++ b/packages/plugins/tanstack-query/src/runtime/common.ts @@ -221,7 +221,7 @@ export function marshal(value: unknown) { export function unmarshal(value: string) { const parsed = JSON.parse(value); - if (parsed.data && parsed.meta?.serialization) { + if (typeof parsed === 'object' && parsed?.data && parsed?.meta?.serialization) { const deserializedData = deserialize(parsed.data, parsed.meta.serialization); return { ...parsed, data: deserializedData }; } else { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index c62b0c70a..676f40b2c 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": "2.7.5", + "version": "2.8.0", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index dcf4bb9ae..f63f40ced 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "2.7.5", + "version": "2.8.0", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", @@ -111,7 +111,7 @@ "zod-validation-error": "^1.5.0" }, "peerDependencies": { - "@prisma/client": "5.0.0 - 5.21.x" + "@prisma/client": "5.0.0 - 5.22.x" }, "author": { "name": "ZenStack Team" diff --git a/packages/runtime/src/cross/model-meta.ts b/packages/runtime/src/cross/model-meta.ts index 3eb6b3786..3b27f1686 100644 --- a/packages/runtime/src/cross/model-meta.ts +++ b/packages/runtime/src/cross/model-meta.ts @@ -44,6 +44,11 @@ export type FieldInfo = { */ isDataModel?: boolean; + /** + * If the field type is a type def (or an optional/array of type def) + */ + isTypeDef?: boolean; + /** * If the field is an array */ @@ -143,6 +148,21 @@ export type ModelInfo = { discriminator?: string; }; +/** + * Metadata for a type def + */ +export type TypeDefInfo = { + /** + * TypeDef name + */ + name: string; + + /** + * Fields + */ + fields: Record; +}; + /** * ZModel data model metadata */ @@ -152,6 +172,11 @@ export type ModelMeta = { */ models: Record; + /** + * Type defs + */ + typeDefs?: Record; + /** * Mapping from model name to models that will be deleted because of it due to cascade delete */ @@ -171,15 +196,21 @@ export type ModelMeta = { /** * Resolves a model field to its metadata. Returns undefined if not found. */ -export function resolveField(modelMeta: ModelMeta, model: string, field: string): FieldInfo | undefined { - return modelMeta.models[lowerCaseFirst(model)]?.fields?.[field]; +export function resolveField( + modelMeta: ModelMeta, + modelOrTypeDef: string, + field: string, + isTypeDef = false +): FieldInfo | undefined { + const container = isTypeDef ? modelMeta.typeDefs : modelMeta.models; + return container?.[lowerCaseFirst(modelOrTypeDef)]?.fields?.[field]; } /** * Resolves a model field to its metadata. Throws an error if not found. */ -export function requireField(modelMeta: ModelMeta, model: string, field: string) { - const f = resolveField(modelMeta, model, field); +export function requireField(modelMeta: ModelMeta, model: string, field: string, isTypeDef = false) { + const f = resolveField(modelMeta, model, field, isTypeDef); if (!f) { throw new Error(`Field ${model}.${field} cannot be resolved`); } diff --git a/packages/runtime/src/cross/utils.ts b/packages/runtime/src/cross/utils.ts index 304b9b618..b1cb67e12 100644 --- a/packages/runtime/src/cross/utils.ts +++ b/packages/runtime/src/cross/utils.ts @@ -1,5 +1,5 @@ import { lowerCaseFirst } from 'lower-case-first'; -import { requireField, type ModelInfo, type ModelMeta } from '.'; +import { requireField, type ModelInfo, type ModelMeta, type TypeDefInfo } from '.'; /** * Gets field names in a data model entity, filtering out internal fields. @@ -46,6 +46,9 @@ export function zip(x: Enumerable, y: Enumerable): Array<[T1, T2 } } +/** + * Gets ID fields of a model. + */ export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound = false) { const uniqueConstraints = modelMeta.models[lowerCaseFirst(model)]?.uniqueConstraints ?? {}; @@ -60,6 +63,9 @@ export function getIdFields(modelMeta: ModelMeta, model: string, throwIfNotFound return entries[0].fields.map((f) => requireField(modelMeta, model, f)); } +/** + * Gets info for a model. + */ export function getModelInfo( modelMeta: ModelMeta, model: string, @@ -72,6 +78,25 @@ export function getModelInfo( return info; } +/** + * Gets info for a type def. + */ +export function getTypeDefInfo( + modelMeta: ModelMeta, + typeDef: string, + throwIfNotFound: Throw = false as Throw +): Throw extends true ? TypeDefInfo : TypeDefInfo | undefined { + const info = modelMeta.typeDefs?.[lowerCaseFirst(typeDef)]; + if (!info && throwIfNotFound) { + throw new Error(`Unable to load info for ${typeDef}`); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return info as any; +} + +/** + * Checks if a model is a delegate model. + */ export function isDelegateModel(modelMeta: ModelMeta, model: string) { return !!getModelInfo(modelMeta, model)?.attributes?.some((attr) => attr.name === '@@delegate'); } diff --git a/packages/runtime/src/enhancements/edge/json-processor.ts b/packages/runtime/src/enhancements/edge/json-processor.ts new file mode 120000 index 000000000..4144fc6f4 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/json-processor.ts @@ -0,0 +1 @@ +../node/json-processor.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/node/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts index 263e12192..adec1fdf2 100644 --- a/packages/runtime/src/enhancements/node/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -10,6 +10,7 @@ import type { } from '../../types'; import { withDefaultAuth } from './default-auth'; import { withDelegate } from './delegate'; +import { withJsonProcessor } from './json-processor'; import { Logger } from './logger'; import { withOmit } from './omit'; import { withPassword } from './password'; @@ -90,10 +91,18 @@ export function createEnhancement( // TODO: move the detection logic into each enhancement // TODO: how to properly cache the detection result? + const allFields = Object.values(options.modelMeta.models).flatMap((modelInfo) => Object.values(modelInfo.fields)); + if (options.modelMeta.typeDefs) { + allFields.push( + ...Object.values(options.modelMeta.typeDefs).flatMap((typeDefInfo) => Object.values(typeDefInfo.fields)) + ); + } + const hasPassword = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@password')); const hasOmit = allFields.some((field) => field.attributes?.some((attr) => attr.name === '@omit')); const hasDefaultAuth = allFields.some((field) => field.defaultValueProvider); + const hasTypeDefField = allFields.some((field) => field.isTypeDef); const kinds = options.kinds ?? ALL_ENHANCEMENTS; let result = prisma; @@ -142,5 +151,9 @@ export function createEnhancement( result = withOmit(result, options); } + if (hasTypeDefField) { + result = withJsonProcessor(result, options); + } + return result; } diff --git a/packages/runtime/src/enhancements/node/default-auth.ts b/packages/runtime/src/enhancements/node/default-auth.ts index 3852069c8..03ce3750c 100644 --- a/packages/runtime/src/enhancements/node/default-auth.ts +++ b/packages/runtime/src/enhancements/node/default-auth.ts @@ -8,6 +8,7 @@ import { clone, enumerate, getFields, + getTypeDefInfo, requireField, } from '../../cross'; import { DbClientContract, EnhancementContext } from '../../types'; @@ -70,6 +71,11 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { const processCreatePayload = (model: string, data: any) => { const fields = getFields(this.options.modelMeta, model); for (const fieldInfo of Object.values(fields)) { + if (fieldInfo.isTypeDef) { + this.setDefaultValueForTypeDefData(fieldInfo.type, data[fieldInfo.name]); + continue; + } + if (fieldInfo.name in data) { // create payload already sets field value continue; @@ -80,10 +86,10 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { continue; } - const authDefaultValue = this.getDefaultValueFromAuth(fieldInfo); - if (authDefaultValue !== undefined) { + const defaultValue = this.getDefaultValue(fieldInfo); + if (defaultValue !== undefined) { // set field value extracted from `auth()` - this.setAuthDefaultValue(fieldInfo, model, data, authDefaultValue); + this.setDefaultValueForModelData(fieldInfo, model, data, defaultValue); } } }; @@ -109,7 +115,7 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { return newArgs; } - private setAuthDefaultValue(fieldInfo: FieldInfo, model: string, data: any, authDefaultValue: unknown) { + private setDefaultValueForModelData(fieldInfo: FieldInfo, model: string, data: any, authDefaultValue: unknown) { if (fieldInfo.isForeignKey && fieldInfo.relationField && fieldInfo.relationField in data) { // if the field is a fk, and the relation field is already set, we should not override it return; @@ -155,7 +161,7 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { return entry?.[0]; } - private getDefaultValueFromAuth(fieldInfo: FieldInfo) { + private getDefaultValue(fieldInfo: FieldInfo) { if (!this.userContext) { throw prismaClientValidationError( this.prisma, @@ -165,4 +171,34 @@ class DefaultAuthHandler extends DefaultPrismaProxyHandler { } return fieldInfo.defaultValueProvider?.(this.userContext); } + + private setDefaultValueForTypeDefData(type: string, data: any) { + if (!data || (typeof data !== 'object' && !Array.isArray(data))) { + return; + } + + const typeDef = getTypeDefInfo(this.options.modelMeta, type); + if (!typeDef) { + return; + } + + enumerate(data).forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + + for (const fieldInfo of Object.values(typeDef.fields)) { + if (fieldInfo.isTypeDef) { + // recurse + this.setDefaultValueForTypeDefData(fieldInfo.type, item[fieldInfo.name]); + } else if (!(fieldInfo.name in item)) { + // set default value if the payload doesn't set the field + const defaultValue = this.getDefaultValue(fieldInfo); + if (defaultValue !== undefined) { + item[fieldInfo.name] = defaultValue; + } + } + } + }); + } } diff --git a/packages/runtime/src/enhancements/node/delegate.ts b/packages/runtime/src/enhancements/node/delegate.ts index 8a7d613a1..78523b837 100644 --- a/packages/runtime/src/enhancements/node/delegate.ts +++ b/packages/runtime/src/enhancements/node/delegate.ts @@ -93,7 +93,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { if (args.orderBy) { // `orderBy` may contain fields from base types - this.injectWhereHierarchy(this.model, args.orderBy); + enumerate(args.orderBy).forEach((item) => this.injectWhereHierarchy(model, item)); } if (this.options.logPrismaQuery) { @@ -206,7 +206,9 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { if (fieldValue !== undefined) { if (fieldValue.orderBy) { // `orderBy` may contain fields from base types - this.injectWhereHierarchy(fieldInfo.type, fieldValue.orderBy); + enumerate(fieldValue.orderBy).forEach((item) => + this.injectWhereHierarchy(fieldInfo.type, item) + ); } if (this.injectBaseFieldSelect(model, field, fieldValue, args, kind)) { @@ -1037,7 +1039,7 @@ export class DelegateProxyHandler extends DefaultPrismaProxyHandler { } if (args.orderBy) { - this.injectWhereHierarchy(this.model, args.orderBy); + enumerate(args.orderBy).forEach((item) => this.injectWhereHierarchy(this.model, item)); } if (args.where) { diff --git a/packages/runtime/src/enhancements/node/json-processor.ts b/packages/runtime/src/enhancements/node/json-processor.ts new file mode 100644 index 000000000..6cf204a6f --- /dev/null +++ b/packages/runtime/src/enhancements/node/json-processor.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { enumerate, getModelFields, resolveField } from '../../cross'; +import { DbClientContract } from '../../types'; +import { InternalEnhancementOptions } from './create-enhancement'; +import { DefaultPrismaProxyHandler, makeProxy, PrismaProxyActions } from './proxy'; +import { QueryUtils } from './query-utils'; + +/** + * Gets an enhanced Prisma client that post-processes JSON values. + * + * @private + */ +export function withJsonProcessor( + prisma: DbClient, + options: InternalEnhancementOptions +): DbClient { + return makeProxy( + prisma, + options.modelMeta, + (_prisma, model) => new JsonProcessorHandler(_prisma as DbClientContract, model, options), + 'json-processor' + ); +} + +class JsonProcessorHandler extends DefaultPrismaProxyHandler { + private queryUtils: QueryUtils; + + constructor(prisma: DbClientContract, model: string, options: InternalEnhancementOptions) { + super(prisma, model, options); + this.queryUtils = new QueryUtils(prisma, options); + } + + protected override async processResultEntity(_method: PrismaProxyActions, data: T): Promise { + for (const value of enumerate(data)) { + await this.doPostProcess(value, this.model); + } + return data; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async doPostProcess(entityData: any, model: string) { + const realModel = this.queryUtils.getDelegateConcreteModel(model, entityData); + + for (const field of getModelFields(entityData)) { + const fieldInfo = await resolveField(this.options.modelMeta, realModel, field); + if (!fieldInfo) { + continue; + } + + if (fieldInfo.isTypeDef) { + this.fixJsonDateFields(entityData[field], fieldInfo.type); + } else if (fieldInfo.isDataModel) { + const items = + fieldInfo.isArray && Array.isArray(entityData[field]) ? entityData[field] : [entityData[field]]; + for (const item of items) { + // recurse + await this.doPostProcess(item, fieldInfo.type); + } + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private fixJsonDateFields(entityData: any, typeDef: string) { + if (typeof entityData !== 'object' && !Array.isArray(entityData)) { + return; + } + + enumerate(entityData).forEach((item) => { + if (!item || typeof item !== 'object') { + return; + } + + for (const [key, value] of Object.entries(item)) { + const fieldInfo = resolveField(this.options.modelMeta, typeDef, key, true); + if (!fieldInfo) { + continue; + } + if (fieldInfo.isTypeDef) { + // recurse + this.fixJsonDateFields(value, fieldInfo.type); + } else if (fieldInfo.type === 'DateTime' && typeof value === 'string') { + // convert to Date + const parsed = Date.parse(value); + if (!isNaN(parsed)) { + item[key] = new Date(parsed); + } + } + } + }); + } +} diff --git a/packages/runtime/src/enhancements/node/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts index ec8a2cfc8..b39ac5b00 100644 --- a/packages/runtime/src/enhancements/node/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -3,6 +3,7 @@ import deepmerge from 'deepmerge'; import { isPlainObject } from 'is-plain-object'; import { lowerCaseFirst } from 'lower-case-first'; +import traverse from 'traverse'; import { upperCaseFirst } from 'upper-case-first'; import { z, type ZodError, type ZodObject, type ZodSchema } from 'zod'; import { fromZodError } from 'zod-validation-error'; @@ -31,7 +32,15 @@ import { getVersion } from '../../../version'; import type { InternalEnhancementOptions } from '../create-enhancement'; import { Logger } from '../logger'; import { QueryUtils } from '../query-utils'; -import type { EntityChecker, ModelPolicyDef, PermissionCheckerFunc, PolicyDef, PolicyFunc } from '../types'; +import type { + DelegateConstraint, + EntityChecker, + ModelPolicyDef, + PermissionCheckerFunc, + PolicyDef, + PolicyFunc, + VariableConstraint, +} from '../types'; import { formatObject, prismaClientKnownRequestError } from '../utils'; /** @@ -667,7 +676,47 @@ export class PolicyUtil extends QueryUtils { } // call checker function - return checker({ user: this.user }); + let result = checker({ user: this.user }); + + // the constraint may contain "delegate" ones that should be resolved + // by evaluating the corresponding checker of the delegated models + + const isVariableConstraint = (value: any): value is VariableConstraint => { + return value && typeof value === 'object' && value.kind === 'variable'; + }; + + const isDelegateConstraint = (value: any): value is DelegateConstraint => { + return value && typeof value === 'object' && value.kind === 'delegate'; + }; + + // here we prefix the constraint variables coming from delegated checkers + // with the relation field name to avoid conflicts + const prefixConstraintVariables = (constraint: unknown, prefix: string) => { + return traverse(constraint).map(function (value) { + if (isVariableConstraint(value)) { + this.update( + { + ...value, + name: `${prefix}${value.name}`, + }, + true + ); + } + }); + }; + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; + result = traverse(result).forEach(function (value) { + if (isDelegateConstraint(value)) { + const { model: delegateModel, relation, operation: delegateOp } = value; + let newValue = that.getCheckerConstraint(delegateModel, delegateOp ?? operation); + newValue = prefixConstraintVariables(newValue, `${relation}.`); + this.update(newValue, true); + } + }); + + return result; } //#endregion diff --git a/packages/runtime/src/enhancements/node/types.ts b/packages/runtime/src/enhancements/node/types.ts index 37a304b99..c9a90baa8 100644 --- a/packages/runtime/src/enhancements/node/types.ts +++ b/packages/runtime/src/enhancements/node/types.ts @@ -18,6 +18,11 @@ export interface CommonEnhancementOptions { prismaModule?: any; } +/** + * CRUD operations + */ +export type CRUD = 'create' | 'read' | 'update' | 'delete'; + /** * Function for getting policy guard with a given context */ @@ -74,6 +79,17 @@ export type LogicalConstraint = { children: PermissionCheckerConstraint[]; }; +/** + * Constraint delegated to another model through `check()` function call + * on a relation field. + */ +export type DelegateConstraint = { + kind: 'delegate'; + model: string; + relation: string; + operation?: CRUD; +}; + /** * Operation allowability checking constraint */ @@ -81,7 +97,8 @@ export type PermissionCheckerConstraint = | ValueConstraint | VariableConstraint | ComparisonConstraint - | LogicalConstraint; + | LogicalConstraint + | DelegateConstraint; /** * Policy definition diff --git a/packages/schema/package.json b/packages/schema/package.json index dcff63940..a99ebeee7 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "FullStack enhancement for Prisma ORM: seamless integration from database to UI", - "version": "2.7.5", + "version": "2.8.0", "author": { "name": "ZenStack Team" }, @@ -123,10 +123,10 @@ "zod-validation-error": "^1.5.0" }, "peerDependencies": { - "prisma": "5.0.0 - 5.21.x" + "prisma": "5.0.0 - 5.22.x" }, "devDependencies": { - "@prisma/client": "5.21.x", + "@prisma/client": "5.22.x", "@types/async-exit-hook": "^2.0.0", "@types/pluralize": "^0.0.29", "@types/semver": "^7.3.13", diff --git a/packages/schema/src/cli/actions/repl.ts b/packages/schema/src/cli/actions/repl.ts index df15e30fb..fa291a3e2 100644 --- a/packages/schema/src/cli/actions/repl.ts +++ b/packages/schema/src/cli/actions/repl.ts @@ -9,13 +9,18 @@ import { inspect } from 'util'; /** * CLI action for starting a REPL session */ -export async function repl(projectPath: string, options: { prismaClient?: string; debug?: boolean; table?: boolean }) { +export async function repl( + projectPath: string, + options: { loadPath?: string; 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.'); + 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') + const prettyRepl = await import('pretty-repl'); console.log('Welcome to ZenStack REPL. See help with the ".help" command.'); console.log('Global variables:'); @@ -47,7 +52,9 @@ export async function repl(projectPath: string, options: { prismaClient?: string } } - const { enhance } = require('@zenstackhq/runtime'); + const { enhance } = options.loadPath + ? require(path.join(path.resolve(options.loadPath), 'enhance')) + : require('@zenstackhq/runtime'); let debug = !!options.debug; let table = !!options.table; @@ -63,7 +70,11 @@ export async function repl(projectPath: string, options: { prismaClient?: string let r: any = undefined; let isPrismaCall = false; - if (cmd.includes('await ')) { + if (/^\s*user\s*=[^=]/.test(cmd)) { + // assigning to user variable, reset auth + eval(cmd); + setAuth(user); + } else if (/^\s*await\s+/.test(cmd)) { // eval can't handle top-level await, so we wrap it in an async function cmd = `(async () => (${cmd}))()`; r = eval(cmd); @@ -137,7 +148,7 @@ export async function repl(projectPath: string, options: { prismaClient?: string // .auth command replServer.defineCommand('auth', { - help: 'Set current user. Run without argument to switch to anonymous. Pass an user object to set current user.', + help: 'Set current user. Run without argument to switch to anonymous. Pass an user object to set current user. Run ".auth info" to show current user.', action(value: string) { this.clearBufferedCommand(); try { @@ -145,6 +156,10 @@ export async function repl(projectPath: string, options: { prismaClient?: string // set anonymous setAuth(undefined); console.log(`Auth user: anonymous. Use ".auth { id: ... }" to change.`); + } else if (value.trim() === 'info') { + // refresh auth user + setAuth(user); + console.log(`Current user: ${user ? inspect(user) : 'anonymous'}`); } else { // set current user const user = eval(`(${value})`); diff --git a/packages/schema/src/cli/index.ts b/packages/schema/src/cli/index.ts index e8773fddd..c58db8c43 100644 --- a/packages/schema/src/cli/index.ts +++ b/packages/schema/src/cli/index.ts @@ -133,7 +133,8 @@ export function createProgram() { program .command('repl') .description('Start a REPL session.') - .option('--prisma-client ', 'path to Prisma client module') + .option('--load-path ', 'path to load modules generated by ZenStack') + .option('--prisma-client ', 'path to Prisma client module') .option('--debug', 'enable debug output') .option('--table', 'enable table format output') .action(replAction); diff --git a/packages/schema/src/cli/plugin-runner.ts b/packages/schema/src/cli/plugin-runner.ts index 2912bfb60..4158bd256 100644 --- a/packages/schema/src/cli/plugin-runner.ts +++ b/packages/schema/src/cli/plugin-runner.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-var-requires */ -import { isPlugin, Model, Plugin } from '@zenstackhq/language/ast'; +import { DataModel, isPlugin, isTypeDef, Model, Plugin } from '@zenstackhq/language/ast'; import { createProject, emitProject, @@ -311,7 +311,11 @@ export class PluginRunner { } private hasValidation(schema: Model) { - return getDataModels(schema).some((model) => hasValidationAttributes(model)); + return getDataModels(schema).some((model) => hasValidationAttributes(model) || this.hasTypeDefFields(model)); + } + + private hasTypeDefFields(model: DataModel) { + return model.fields.some((f) => isTypeDef(f.type.reference?.ref)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/packages/schema/src/language-server/validator/attribute-application-validator.ts b/packages/schema/src/language-server/validator/attribute-application-validator.ts index 9ec2074be..2a7f43c29 100644 --- a/packages/schema/src/language-server/validator/attribute-application-validator.ts +++ b/packages/schema/src/language-server/validator/attribute-application-validator.ts @@ -14,8 +14,11 @@ import { isDataModelField, isEnum, isReferenceExpr, + isTypeDef, + isTypeDefField, } from '@zenstackhq/language/ast'; import { + hasAttribute, isDataModelFieldReference, isDelegateModel, isFutureExpr, @@ -62,6 +65,10 @@ export default class AttributeApplicationValidator implements AstValidator(); for (const arg of attr.args) { @@ -345,6 +352,9 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) case 'ModelField': allowed = allowed || isDataModel(targetDecl.type.reference?.ref); break; + case 'TypeDefField': + allowed = allowed || isTypeDef(targetDecl.type.reference?.ref); + break; default: break; } diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index e463f740f..f2a3d6737 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -6,8 +6,16 @@ import { isDataModel, isEnum, isStringLiteral, + isTypeDef, } from '@zenstackhq/language/ast'; -import { getModelFieldsWithBases, getModelIdFields, getModelUniqueFields, isDelegateModel } from '@zenstackhq/sdk'; +import { + getDataSourceProvider, + getModelFieldsWithBases, + getModelIdFields, + getModelUniqueFields, + hasAttribute, + isDelegateModel, +} from '@zenstackhq/sdk'; import { AstNode, DiagnosticInfo, ValidationAcceptor, getDocument } from 'langium'; import { findUpInheritance } from '../../utils/ast-utils'; import { IssueCodes, SCALAR_TYPES } from '../constants'; @@ -95,6 +103,16 @@ export default class DataModelValidator implements AstValidator { } field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); + + if (isTypeDef(field.type.reference?.ref)) { + if (!hasAttribute(field, '@json')) { + accept('error', 'Custom-typed field must have @json attribute', { node: field }); + } + + if (getDataSourceProvider(field.$container.$container) !== 'postgresql') { + accept('error', 'Custom-typed field is only supported with "postgresql" provider', { node: field }); + } + } } private validateAttributes(dm: DataModel, accept: ValidationAcceptor) { diff --git a/packages/schema/src/language-server/validator/typedef-validator.ts b/packages/schema/src/language-server/validator/typedef-validator.ts new file mode 100644 index 000000000..55c127d7d --- /dev/null +++ b/packages/schema/src/language-server/validator/typedef-validator.ts @@ -0,0 +1,23 @@ +import { TypeDef, TypeDefField } from '@zenstackhq/language/ast'; +import { ValidationAcceptor } from 'langium'; +import { AstValidator } from '../types'; +import { validateAttributeApplication } from './attribute-application-validator'; +import { validateDuplicatedDeclarations } from './utils'; + +/** + * Validates type def declarations. + */ +export default class TypeDefValidator implements AstValidator { + validate(typeDef: TypeDef, accept: ValidationAcceptor): void { + validateDuplicatedDeclarations(typeDef, typeDef.fields, accept); + this.validateFields(typeDef, accept); + } + + private validateFields(typeDef: TypeDef, accept: ValidationAcceptor) { + typeDef.fields.forEach((field) => this.validateField(field, accept)); + } + + private validateField(field: TypeDefField, accept: ValidationAcceptor): void { + field.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); + } +} diff --git a/packages/schema/src/language-server/validator/zmodel-validator.ts b/packages/schema/src/language-server/validator/zmodel-validator.ts index 493bc5f89..c1dcbb09e 100644 --- a/packages/schema/src/language-server/validator/zmodel-validator.ts +++ b/packages/schema/src/language-server/validator/zmodel-validator.ts @@ -7,6 +7,7 @@ import { FunctionDecl, InvocationExpr, Model, + TypeDef, ZModelAstType, } from '@zenstackhq/language/ast'; import { AstNode, LangiumDocument, ValidationAcceptor, ValidationChecks, ValidationRegistry } from 'langium'; @@ -19,6 +20,7 @@ import ExpressionValidator from './expression-validator'; import FunctionDeclValidator from './function-decl-validator'; import FunctionInvocationValidator from './function-invocation-validator'; import SchemaValidator from './schema-validator'; +import TypeDefValidator from './typedef-validator'; /** * Registry for validation checks. @@ -31,6 +33,7 @@ export class ZModelValidationRegistry extends ValidationRegistry { Model: validator.checkModel, DataSource: validator.checkDataSource, DataModel: validator.checkDataModel, + TypeDef: validator.checkTypeDef, Enum: validator.checkEnum, Attribute: validator.checkAttribute, Expression: validator.checkExpression, @@ -73,6 +76,10 @@ export class ZModelValidator { this.shouldCheck(node) && new DataModelValidator().validate(node, accept); } + checkTypeDef(node: TypeDef, accept: ValidationAcceptor): void { + this.shouldCheck(node) && new TypeDefValidator().validate(node, accept); + } + checkEnum(node: Enum, accept: ValidationAcceptor): void { this.shouldCheck(node) && new EnumValidator().validate(node, accept); } diff --git a/packages/schema/src/language-server/zmodel-documentation-provider.ts b/packages/schema/src/language-server/zmodel-documentation-provider.ts new file mode 100644 index 000000000..f960507bc --- /dev/null +++ b/packages/schema/src/language-server/zmodel-documentation-provider.ts @@ -0,0 +1,16 @@ +import { AstNode, JSDocDocumentationProvider } from 'langium'; + +/** + * Documentation provider that first tries to use triple-slash comments and falls back to JSDoc comments. + */ +export class ZModelDocumentationProvider extends JSDocDocumentationProvider { + getDocumentation(node: AstNode): string | undefined { + // prefer to use triple-slash comments + if ('comments' in node && Array.isArray(node.comments) && node.comments.length > 0) { + return node.comments.map((c: string) => c.replace(/^[/]*\s*/, '')).join('\n'); + } + + // fall back to JSDoc comments + return super.getDocumentation(node); + } +} diff --git a/packages/schema/src/language-server/zmodel-module.ts b/packages/schema/src/language-server/zmodel-module.ts index 116d486da..701d31d87 100644 --- a/packages/schema/src/language-server/zmodel-module.ts +++ b/packages/schema/src/language-server/zmodel-module.ts @@ -27,13 +27,14 @@ import { ZModelValidationRegistry, ZModelValidator } from './validator/zmodel-va import { ZModelCodeActionProvider } from './zmodel-code-action'; import { ZModelCompletionProvider } from './zmodel-completion-provider'; import { ZModelDefinitionProvider } from './zmodel-definition'; +import { ZModelDocumentationProvider } from './zmodel-documentation-provider'; import { ZModelFormatter } from './zmodel-formatter'; import { ZModelHighlightProvider } from './zmodel-highlight'; import { ZModelHoverProvider } from './zmodel-hover'; import { ZModelLinker } from './zmodel-linker'; import { ZModelScopeComputation, ZModelScopeProvider } from './zmodel-scope'; import { ZModelSemanticTokenProvider } from './zmodel-semantic'; -import ZModelWorkspaceManager from './zmodel-workspace-manager'; +import { ZModelWorkspaceManager } from './zmodel-workspace-manager'; /** * Declaration of custom services - add your own service classes here. @@ -77,6 +78,9 @@ export const ZModelModule: Module createGrammarConfig(services), }, + documentation: { + DocumentationProvider: (services) => new ZModelDocumentationProvider(services), + }, }; // this duplicates createDefaultSharedModule except that a custom WorkspaceManager is used diff --git a/packages/schema/src/language-server/zmodel-workspace-manager.ts b/packages/schema/src/language-server/zmodel-workspace-manager.ts index 79b5bfb5e..734a785cd 100644 --- a/packages/schema/src/language-server/zmodel-workspace-manager.ts +++ b/packages/schema/src/language-server/zmodel-workspace-manager.ts @@ -9,7 +9,7 @@ import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants'; /** * Custom Langium WorkspaceManager implementation which automatically loads stdlib.zmodel */ -export default class ZModelWorkspaceManager extends DefaultWorkspaceManager { +export class ZModelWorkspaceManager extends DefaultWorkspaceManager { public pluginModels = new Set(); protected async loadAdditionalDocuments( diff --git a/packages/schema/src/plugins/enhancer/enhance/index.ts b/packages/schema/src/plugins/enhancer/enhance/index.ts index 34cf26640..0b69b6a25 100644 --- a/packages/schema/src/plugins/enhancer/enhance/index.ts +++ b/packages/schema/src/plugins/enhancer/enhance/index.ts @@ -19,6 +19,7 @@ import { isDataModel, isGeneratorDecl, isReferenceExpr, + isTypeDef, type Model, } from '@zenstackhq/sdk/ast'; import { getDMMF, getPrismaClientImportSpec, getPrismaVersion, type DMMF } from '@zenstackhq/sdk/prisma'; @@ -45,6 +46,7 @@ import { PrismaSchemaGenerator } from '../../prisma/schema-generator'; import { isDefaultWithAuth } from '../enhancer-utils'; import { generateAuthType } from './auth-type-generator'; import { generateCheckerType } from './checker-type-generator'; +import { generateTypeDefType } from './model-typedef-generator'; // information of delegate models and their sub models type DelegateInfo = [DataModel, DataModel[]][]; @@ -60,35 +62,27 @@ export class EnhancerGenerator { ) {} async generate(): Promise<{ dmmf: DMMF.Document | undefined }> { - let logicalPrismaClientDir: string | undefined; let dmmf: DMMF.Document | undefined; const prismaImport = getPrismaClientImportSpec(this.outDir, this.options); + let prismaTypesFixed = false; + let resultPrismaImport = prismaImport; - if (this.needsLogicalClient()) { - // schema contains delegate models, need to generate a logical prisma schema + if (this.needsLogicalClient || this.needsPrismaClientTypeFixes) { + prismaTypesFixed = true; + resultPrismaImport = `${LOGICAL_CLIENT_GENERATION_PATH}/index-fixed`; const result = await this.generateLogicalPrisma(); - - logicalPrismaClientDir = LOGICAL_CLIENT_GENERATION_PATH; dmmf = result.dmmf; - - // create a reexport of the logical prisma client - const prismaDts = this.project.createSourceFile( - path.join(this.outDir, 'models.d.ts'), - `export type * from '${logicalPrismaClientDir}/index-fixed';`, - { overwrite: true } - ); - await prismaDts.save(); - } else { - // just reexport the prisma client - const prismaDts = this.project.createSourceFile( - path.join(this.outDir, 'models.d.ts'), - `export type * from '${prismaImport}';`, - { overwrite: true } - ); - await prismaDts.save(); } + // reexport PrismaClient types (original or fixed) + const prismaDts = this.project.createSourceFile( + path.join(this.outDir, 'models.d.ts'), + `export type * from '${resultPrismaImport}';`, + { overwrite: true } + ); + await prismaDts.save(); + const authModel = getAuthModel(getDataModels(this.model)); const authTypes = authModel ? generateAuthType(this.model, authModel) : ''; const authTypeParam = authModel ? `auth.${authModel.name}` : 'AuthUser'; @@ -112,8 +106,8 @@ ${ } ${ - logicalPrismaClientDir - ? this.createLogicalPrismaImports(prismaImport, logicalPrismaClientDir) + prismaTypesFixed + ? this.createLogicalPrismaImports(prismaImport, resultPrismaImport) : this.createSimplePrismaImports(prismaImport) } @@ -122,7 +116,7 @@ ${authTypes} ${checkerTypes} ${ - logicalPrismaClientDir + prismaTypesFixed ? this.createLogicalPrismaEnhanceFunction(authTypeParam) : this.createSimplePrismaEnhanceFunction(authTypeParam) } @@ -185,11 +179,11 @@ export function enhance(prisma: DbClient, context?: Enh `; } - private createLogicalPrismaImports(prismaImport: string, logicalPrismaClientDir: string) { + private createLogicalPrismaImports(prismaImport: string, prismaClientImport: string) { return `import { Prisma as _Prisma, PrismaClient as _PrismaClient } from '${prismaImport}'; import type { InternalArgs, DynamicClientExtensionThis } from '${prismaImport}/runtime/library'; -import type * as _P from '${logicalPrismaClientDir}/index-fixed'; -import type { Prisma, PrismaClient } from '${logicalPrismaClientDir}/index-fixed'; +import type * as _P from '${prismaClientImport}'; +import type { Prisma, PrismaClient } from '${prismaClientImport}'; export type { PrismaClient }; `; } @@ -229,10 +223,14 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara `; } - private needsLogicalClient() { + private get needsLogicalClient() { return this.hasDelegateModel(this.model) || this.hasAuthInDefault(this.model); } + private get needsPrismaClientTypeFixes() { + return this.hasTypeDef(this.model); + } + private hasDelegateModel(model: Model) { const dataModels = getDataModels(model); return dataModels.some( @@ -246,6 +244,10 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara ); } + private hasTypeDef(model: Model) { + return model.declarations.some(isTypeDef); + } + private async generateLogicalPrisma() { const prismaGenerator = new PrismaSchemaGenerator(this.model); @@ -349,18 +351,15 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara overwrite: true, }); - if (delegateInfo.length > 0) { - // transform types for delegated models - this.transformDelegate(sf, sfNew, delegateInfo); - sfNew.formatText(); - } else { - // just copy - sfNew.replaceWithText(sf.getFullText()); - } + this.transformPrismaTypes(sf, sfNew, delegateInfo); + + this.generateExtraTypes(sfNew); + + sfNew.formatText(); await sfNew.save(); } - private transformDelegate(sf: SourceFile, sfNew: SourceFile, delegateInfo: DelegateInfo) { + private transformPrismaTypes(sf: SourceFile, sfNew: SourceFile, delegateInfo: DelegateInfo) { // copy toplevel imports sfNew.addImportDeclarations(sf.getImportDeclarations().map((n) => n.getStructure())); @@ -493,10 +492,72 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara // fix delegate payload union type source = this.fixDelegatePayloadType(typeAlias, delegateInfo, source); + // fix json field type + source = this.fixJsonFieldType(typeAlias, source); + structure.type = source; return structure; } + private fixJsonFieldType(typeAlias: TypeAliasDeclaration, source: string) { + const modelsWithTypeField = this.model.declarations.filter( + (d): d is DataModel => isDataModel(d) && d.fields.some((f) => isTypeDef(f.type.reference?.ref)) + ); + const typeName = typeAlias.getName(); + + const getTypedJsonFields = (model: DataModel) => { + return model.fields.filter((f) => isTypeDef(f.type.reference?.ref)); + }; + + const replacePrismaJson = (source: string, field: DataModelField) => { + return source.replace( + new RegExp(`(${field.name}\\??\\s*):[^\\n]+`), + `$1: ${field.type.reference!.$refText}${field.type.array ? '[]' : ''}${ + field.type.optional ? ' | null' : '' + }` + ); + }; + + // fix "$[Model]Payload" type + const payloadModelMatch = modelsWithTypeField.find((m) => `$${m.name}Payload` === typeName); + if (payloadModelMatch) { + const scalars = typeAlias + .getDescendantsOfKind(SyntaxKind.PropertySignature) + .find((p) => p.getName() === 'scalars'); + if (!scalars) { + return source; + } + + const fieldsToFix = getTypedJsonFields(payloadModelMatch); + for (const field of fieldsToFix) { + source = replacePrismaJson(source, field); + } + } + + // fix input/output types, "[Model]CreateInput", etc. + const inputOutputModelMatch = modelsWithTypeField.find((m) => typeName.startsWith(m.name)); + if (inputOutputModelMatch) { + const relevantTypePatterns = [ + 'GroupByOutputType', + '(Unchecked)?Create(\\S+?)?Input', + '(Unchecked)?Update(\\S+?)?Input', + 'CreateManyInput', + '(Unchecked)?UpdateMany(Mutation)?Input', + ]; + const typeRegex = modelsWithTypeField.map( + (m) => new RegExp(`^(${m.name})(${relevantTypePatterns.join('|')})$`) + ); + if (typeRegex.some((r) => r.test(typeName))) { + const fieldsToFix = getTypedJsonFields(inputOutputModelMatch); + for (const field of fieldsToFix) { + source = replacePrismaJson(source, field); + } + } + } + + return source; + } + private fixDelegatePayloadType(typeAlias: TypeAliasDeclaration, delegateInfo: DelegateInfo, source: string) { // change the type of `$Payload` type of delegate model to a union of concrete types const typeName = typeAlias.getName(); @@ -677,4 +738,12 @@ export function enhance(prisma: any, context?: EnhancementContext<${authTypePara private get generatePermissionChecker() { return this.options.generatePermissionChecker === true; } + + private async generateExtraTypes(sf: SourceFile) { + for (const decl of this.model.declarations) { + if (isTypeDef(decl)) { + generateTypeDefType(sf, decl); + } + } + } } diff --git a/packages/schema/src/plugins/enhancer/enhance/model-typedef-generator.ts b/packages/schema/src/plugins/enhancer/enhance/model-typedef-generator.ts new file mode 100644 index 000000000..40bc3e5b4 --- /dev/null +++ b/packages/schema/src/plugins/enhancer/enhance/model-typedef-generator.ts @@ -0,0 +1,63 @@ +import { PluginError } from '@zenstackhq/sdk'; +import { BuiltinType, TypeDef, TypeDefFieldType } from '@zenstackhq/sdk/ast'; +import { SourceFile } from 'ts-morph'; +import { match } from 'ts-pattern'; +import { name } from '..'; + +export function generateTypeDefType(sourceFile: SourceFile, decl: TypeDef) { + sourceFile.addTypeAlias({ + name: decl.name, + isExported: true, + docs: decl.comments.map((c) => unwrapTripleSlashComment(c)), + type: (writer) => { + writer.block(() => { + decl.fields.forEach((field) => { + if (field.comments.length > 0) { + writer.writeLine(` /**`); + field.comments.forEach((c) => writer.writeLine(` * ${unwrapTripleSlashComment(c)}`)); + writer.writeLine(` */`); + } + writer.writeLine( + ` ${field.name}${field.type.optional ? '?' : ''}: ${zmodelTypeToTsType(field.type)};` + ); + }); + }); + }, + }); +} + +function unwrapTripleSlashComment(c: string): string { + return c.replace(/^[/]*\s*/, ''); +} + +function zmodelTypeToTsType(type: TypeDefFieldType) { + let result: string; + + if (type.type) { + result = builtinTypeToTsType(type.type); + } else if (type.reference?.ref) { + result = type.reference.ref.name; + } else { + throw new PluginError(name, `Unsupported field type: ${type}`); + } + + if (type.array) { + result += '[]'; + } + + return result; +} + +function builtinTypeToTsType(type: BuiltinType) { + return match(type) + .with('Boolean', () => 'boolean') + .with('BigInt', () => 'bigint') + .with('Int', () => 'number') + .with('Float', () => 'number') + .with('Decimal', () => 'Prisma.Decimal') + .with('String', () => 'string') + .with('Bytes', () => 'Uint8Array') + .with('DateTime', () => 'Date') + .with('Json', () => 'unknown') + .exhaustive(); +} diff --git a/packages/schema/src/plugins/enhancer/model-meta/index.ts b/packages/schema/src/plugins/enhancer/model-meta/index.ts index 38757ae6f..53fecdb82 100644 --- a/packages/schema/src/plugins/enhancer/model-meta/index.ts +++ b/packages/schema/src/plugins/enhancer/model-meta/index.ts @@ -1,15 +1,16 @@ import { generateModelMeta, getDataModels, type PluginOptions } from '@zenstackhq/sdk'; -import type { Model } from '@zenstackhq/sdk/ast'; +import { isTypeDef, type Model } from '@zenstackhq/sdk/ast'; import path from 'path'; import type { Project } from 'ts-morph'; export async function generate(model: Model, options: PluginOptions, project: Project, outDir: string) { const outFile = path.join(outDir, 'model-meta.ts'); const dataModels = getDataModels(model); + const typeDefs = model.declarations.filter(isTypeDef); // save ts files if requested explicitly or the user provided const preserveTsFiles = options.preserveTsFiles === true || !!options.output; - await generateModelMeta(project, dataModels, { + await generateModelMeta(project, dataModels, typeDefs, { output: outFile, generateAttributes: true, preserveTsFiles, diff --git a/packages/schema/src/plugins/enhancer/policy/constraint-transformer.ts b/packages/schema/src/plugins/enhancer/policy/constraint-transformer.ts index a0b1c1dd2..674348470 100644 --- a/packages/schema/src/plugins/enhancer/policy/constraint-transformer.ts +++ b/packages/schema/src/plugins/enhancer/policy/constraint-transformer.ts @@ -1,4 +1,6 @@ import { + PluginError, + getLiteral, getRelationKeyPairs, isAuthInvocation, isDataModelFieldReference, @@ -7,9 +9,11 @@ import { import { BinaryExpr, BooleanLiteral, + DataModel, DataModelField, Expression, ExpressionType, + InvocationExpr, LiteralExpr, MemberAccessExpr, NumberLiteral, @@ -27,6 +31,8 @@ import { isUnaryExpr, } from '@zenstackhq/sdk/ast'; import { P, match } from 'ts-pattern'; +import { name } from '..'; +import { isCheckInvocation } from '../../../utils/ast-utils'; /** * Options for {@link ConstraintTransformer}. @@ -107,6 +113,8 @@ export class ConstraintTransformer { .when(isReferenceExpr, (expr) => this.transformReference(expr)) // top-level boolean member access expr .when(isMemberAccessExpr, (expr) => this.transformMemberAccess(expr)) + // `check()` invocation on a relation field + .when(isCheckInvocation, (expr) => this.transformCheckInvocation(expr as InvocationExpr)) .otherwise(() => this.nextVar()) ); } @@ -259,6 +267,30 @@ export class ConstraintTransformer { return undefined; } + private transformCheckInvocation(expr: InvocationExpr) { + // transform `check()` invocation to a special "delegate" constraint kind + // to be evaluated at runtime + + const field = expr.args[0].value as ReferenceExpr; + if (!field) { + throw new PluginError(name, 'Invalid check invocation'); + } + const fieldType = field.$resolvedType?.decl as DataModel; + + let operation: string | undefined = undefined; + if (expr.args[1]) { + operation = getLiteral(expr.args[1].value); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = { kind: 'delegate', model: fieldType.name, relation: field.target.$refText }; + if (operation) { + // operation can be explicitly specified or inferred from the context + result.operation = operation; + } + return JSON.stringify(result); + } + // normalize `auth()` access undefined value to null private normalizeToNull(expr: string) { return `(${expr} ?? null)`; diff --git a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts index aa54c80d8..62469f744 100644 --- a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts @@ -11,6 +11,7 @@ import { isMemberAccessExpr, isReferenceExpr, isThisExpr, + isTypeDef, } from '@zenstackhq/language/ast'; import { PolicyCrudKind, type PolicyOperationKind } from '@zenstackhq/runtime'; import { @@ -747,7 +748,14 @@ export class PolicyGenerator { for (const model of models) { writer.write(`${lowerCaseFirst(model.name)}:`); writer.inlineBlock(() => { - writer.write(`hasValidation: ${hasValidationAttributes(model)}`); + writer.write( + `hasValidation: ${ + // explicit validation rules + hasValidationAttributes(model) || + // type-def fields require schema validation + this.hasTypeDefFields(model) + }` + ); }); writer.writeLine(','); } @@ -755,5 +763,9 @@ export class PolicyGenerator { writer.writeLine(','); } + private hasTypeDefFields(model: DataModel): boolean { + return model.fields.some((f) => isTypeDef(f.type.reference?.ref)); + } + // #endregion } diff --git a/packages/schema/src/plugins/prisma/schema-generator.ts b/packages/schema/src/plugins/prisma/schema-generator.ts index 54d111d88..9a787ab8b 100644 --- a/packages/schema/src/plugins/prisma/schema-generator.ts +++ b/packages/schema/src/plugins/prisma/schema-generator.ts @@ -1,5 +1,4 @@ import { - AbstractDeclaration, AttributeArg, BooleanLiteral, ConfigArrayExpr, @@ -24,6 +23,7 @@ import { isNullExpr, isReferenceExpr, isStringLiteral, + isTypeDef, LiteralExpr, Model, NumberLiteral, @@ -100,6 +100,7 @@ export class PrismaSchemaGenerator { `; private mode: 'logical' | 'physical' = 'physical'; + private customAttributesAsComments = false; // a mapping from full names to shortened names private shortNameMap = new Map(); @@ -117,6 +118,14 @@ export class PrismaSchemaGenerator { this.mode = options.mode as 'logical' | 'physical'; } + if ( + options.customAttributesAsComments !== undefined && + typeof options.customAttributesAsComments !== 'boolean' + ) { + throw new PluginError(name, 'option "customAttributesAsComments" must be a boolean'); + } + this.customAttributesAsComments = options.customAttributesAsComments === true; + const prismaVersion = getPrismaVersion(); if (prismaVersion && semver.lt(prismaVersion, PRISMA_MINIMUM_VERSION)) { warnings.push( @@ -282,24 +291,21 @@ export class PrismaSchemaGenerator { this.generateContainerAttribute(model, attr); } - decl.attributes - .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) - .forEach((attr) => model.addComment('/// ' + this.zModelGenerator.generate(attr))); - // user defined comments pass-through decl.comments.forEach((c) => model.addComment(c)); + this.getCustomAttributesAsComments(decl).forEach((c) => model.addComment(c)); - // generate relation fields on base models linking to concrete models + // physical: generate relation fields on base models linking to concrete models this.generateDelegateRelationForBase(model, decl); - // generate reverse relation fields on concrete models + // physical: generate reverse relation fields on concrete models this.generateDelegateRelationForConcrete(model, decl); - // expand relations on other models that reference delegated models to concrete models + // logical: expand relations on other models that reference delegated models to concrete models this.expandPolymorphicRelations(model, decl); - // name relations inherited from delegate base models for disambiguation - this.nameRelationsInheritedFromDelegate(model, decl); + // logical: ensure relations inherited from delegate models + this.ensureRelationsInheritedFromDelegate(model, decl); } private generateDelegateRelationForBase(model: PrismaDataModel, decl: DataModel) { @@ -397,7 +403,7 @@ export class PrismaSchemaGenerator { // find concrete models that inherit from this field's model type const concreteModels = dataModel.$container.declarations.filter( - (d) => isDataModel(d) && isDescendantOf(d, fieldType) + (d): d is DataModel => isDataModel(d) && isDescendantOf(d, fieldType) ); concreteModels.forEach((concrete) => { @@ -412,10 +418,9 @@ export class PrismaSchemaGenerator { ); const relAttr = getAttribute(field, '@relation'); + let relAttrAdded = false; if (relAttr) { - const fieldsArg = getAttributeArg(relAttr, 'fields'); - const nameArg = getAttributeArg(relAttr, 'name') as LiteralExpr; - if (fieldsArg) { + if (getAttributeArg(relAttr, 'fields')) { // for reach foreign key field pointing to the delegate model, we need to create an aux foreign key // to point to the concrete model const relationFieldPairs = getRelationKeyPairs(field); @@ -444,10 +449,7 @@ export class PrismaSchemaGenerator { const addedRel = new PrismaFieldAttribute('@relation', [ // use field name as relation name for disambiguation - new PrismaAttributeArg( - undefined, - new AttributeArgValue('String', nameArg?.value || auxRelationField.name) - ), + new PrismaAttributeArg(undefined, new AttributeArgValue('String', auxRelationField.name)), new PrismaAttributeArg('fields', fieldsArg), new PrismaAttributeArg('references', referencesArg), ]); @@ -461,12 +463,12 @@ export class PrismaSchemaGenerator { ) ); } - auxRelationField.attributes.push(addedRel); - } else { - auxRelationField.attributes.push(this.makeFieldAttribute(relAttr as DataModelFieldAttribute)); + relAttrAdded = true; } - } else { + } + + if (!relAttrAdded) { auxRelationField.attributes.push( new PrismaFieldAttribute('@relation', [ // use field name as relation name for disambiguation @@ -480,8 +482,8 @@ export class PrismaSchemaGenerator { private replicateForeignKey( model: PrismaDataModel, - dataModel: DataModel, - concreteModel: AbstractDeclaration, + delegateModel: DataModel, + concreteModel: DataModel, origForeignKey: DataModelField ) { // aux fk name format: delegate_aux_[model]_[fkField]_[concrete] @@ -493,26 +495,20 @@ export class PrismaSchemaGenerator { // `@map` attribute should not be inherited addedFkField.attributes = addedFkField.attributes.filter((attr) => !('name' in attr && attr.name === '@map')); + // `@unique` attribute should be recreated with disambiguated name + addedFkField.attributes = addedFkField.attributes.filter( + (attr) => !('name' in attr && attr.name === '@unique') + ); + const uniqueAttr = addedFkField.addAttribute('@unique'); + const constraintName = this.truncate(`${delegateModel.name}_${addedFkField.name}_${concreteModel.name}_unique`); + uniqueAttr.args.push(new PrismaAttributeArg('map', new AttributeArgValue('String', constraintName))); + // fix its name - const addedFkFieldName = `${dataModel.name}_${origForeignKey.name}_${concreteModel.name}`; + const addedFkFieldName = `${delegateModel.name}_${origForeignKey.name}_${concreteModel.name}`; addedFkField.name = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${addedFkFieldName}`); - // we also need to make sure `@unique` constraint's `map` parameter is fixed to avoid conflict - const uniqueAttr = addedFkField.attributes.find( - (attr) => (attr as PrismaFieldAttribute).name === '@unique' - ) as PrismaFieldAttribute; - if (uniqueAttr) { - const mapArg = uniqueAttr.args.find((arg) => arg.name === 'map'); - const constraintName = this.truncate(`${addedFkField.name}_unique`); - if (mapArg) { - mapArg.value = new AttributeArgValue('String', constraintName); - } else { - uniqueAttr.args.push(new PrismaAttributeArg('map', new AttributeArgValue('String', constraintName))); - } - } - // we also need to go through model-level `@@unique` and replicate those involving fk fields - this.replicateForeignKeyModelLevelUnique(model, dataModel, origForeignKey, addedFkField); + this.replicateForeignKeyModelLevelUnique(model, delegateModel, origForeignKey, addedFkField); return addedFkField; } @@ -590,13 +586,11 @@ export class PrismaSchemaGenerator { return shortName; } - private nameRelationsInheritedFromDelegate(model: PrismaDataModel, decl: DataModel) { + private ensureRelationsInheritedFromDelegate(model: PrismaDataModel, decl: DataModel) { if (this.mode !== 'logical') { return; } - // the logical schema needs to name relations inherited from delegate base models for disambiguation - decl.fields.forEach((f) => { if (!isDataModel(f.type.reference?.ref)) { // only process relation fields @@ -630,30 +624,68 @@ export class PrismaSchemaGenerator { if (!oppositeRelationField) { return; } + const oppositeRelationAttr = getAttribute(oppositeRelationField, '@relation'); const fieldType = f.type.reference.ref; // relation name format: delegate_aux_[relationType]_[oppositeRelationField]_[concrete] - const relAttr = getAttribute(f, '@relation'); - const name = `${fieldType.name}_${oppositeRelationField.name}_${decl.name}`; - const relName = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${name}`); - - if (relAttr) { - const nameArg = getAttributeArg(relAttr, 'name'); - if (!nameArg) { - const prismaRelAttr = prismaField.attributes.find( - (attr) => (attr as PrismaFieldAttribute).name === '@relation' - ) as PrismaFieldAttribute; - if (prismaRelAttr) { - prismaRelAttr.args.unshift( - new PrismaAttributeArg(undefined, new AttributeArgValue('String', relName)) - ); - } - } + const relName = this.truncate( + `${DELEGATE_AUX_RELATION_PREFIX}_${fieldType.name}_${oppositeRelationField.name}_${decl.name}` + ); + + // recreate `@relation` attribute + prismaField.attributes = prismaField.attributes.filter( + (attr) => (attr as PrismaFieldAttribute).name !== '@relation' + ); + + if ( + // array relation doesn't need FK + f.type.array || + // opposite relation already has FK, we don't need to generate on this side + (oppositeRelationAttr && getAttributeArg(oppositeRelationAttr, 'fields')) + ) { + prismaField.attributes.push( + new PrismaFieldAttribute('@relation', [ + new PrismaAttributeArg(undefined, new AttributeArgValue('String', relName)), + ]) + ); } else { + // generate FK field + const oppositeModelIds = getIdFields(oppositeRelationField.$container as DataModel); + const fkFieldNames: string[] = []; + + oppositeModelIds.forEach((idField) => { + const fkFieldName = this.truncate(`${DELEGATE_AUX_RELATION_PREFIX}_${f.name}_${idField.name}`); + model.addField(fkFieldName, new ModelFieldType(idField.type.type!, false, f.type.optional), [ + // one-to-one relation requires FK field to be unique, we're just including it + // in all cases since it doesn't hurt + new PrismaFieldAttribute('@unique'), + ]); + fkFieldNames.push(fkFieldName); + }); + prismaField.attributes.push( new PrismaFieldAttribute('@relation', [ new PrismaAttributeArg(undefined, new AttributeArgValue('String', relName)), + new PrismaAttributeArg( + 'fields', + new AttributeArgValue( + 'Array', + fkFieldNames.map( + (fk) => new AttributeArgValue('FieldReference', new PrismaFieldReference(fk)) + ) + ) + ), + new PrismaAttributeArg( + 'references', + new AttributeArgValue( + 'Array', + oppositeModelIds.map( + (idField) => + new AttributeArgValue('FieldReference', new PrismaFieldReference(idField.name)) + ) + ) + ), ]) ); } @@ -684,9 +716,24 @@ export class PrismaSchemaGenerator { private getOppositeRelationField(oppositeModel: DataModel, relationField: DataModelField) { const relName = this.getRelationName(relationField); - return oppositeModel.fields.find( + const matches = oppositeModel.fields.filter( (f) => f.type.reference?.ref === relationField.$container && this.getRelationName(f) === relName ); + + if (matches.length === 0) { + return undefined; + } else if (matches.length === 1) { + return matches[0]; + } else { + // if there are multiple matches, prefer to use the one with the same field name, + // this can happen with self-relations + const withNameMatch = matches.find((f) => f.name === relationField.name); + if (withNameMatch) { + return withNameMatch; + } else { + return matches[0]; + } + } } private getRelationName(field: DataModelField) { @@ -739,13 +786,34 @@ export class PrismaSchemaGenerator { } private generateModelField(model: PrismaDataModel, field: DataModelField, addToFront = false) { - const fieldType = - field.type.type || field.type.reference?.ref?.name || this.getUnsupportedFieldType(field.type); + let fieldType: string | undefined; + + if (field.type.type) { + // intrinsic type + fieldType = field.type.type; + } else if (field.type.reference?.ref) { + // model, enum, or type-def + if (isTypeDef(field.type.reference.ref)) { + fieldType = 'Json'; + } else { + fieldType = field.type.reference.ref.name; + } + } else { + // Unsupported type + const unsupported = this.getUnsupportedFieldType(field.type); + if (unsupported) { + fieldType = unsupported; + } + } + if (!fieldType) { throw new PluginError(name, `Field type is not resolved: ${field.$container.name}.${field.name}`); } - const type = new ModelFieldType(fieldType, field.type.array, field.type.optional); + const isArray = + // typed-JSON fields should be translated to scalar Json type + isTypeDef(field.type.reference?.ref) ? false : field.type.array; + const type = new ModelFieldType(fieldType, isArray, field.type.optional); const attributes = field.attributes .filter((attr) => this.isPrismaAttribute(attr)) @@ -763,11 +831,9 @@ export class PrismaSchemaGenerator { ) .map((attr) => this.makeFieldAttribute(attr)); - const nonPrismaAttributes = field.attributes.filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)); - - const documentations = nonPrismaAttributes.map((attr) => '/// ' + this.zModelGenerator.generate(attr)); - - const result = model.addField(field.name, type, attributes, documentations, addToFront); + // user defined comments pass-through + const docs = [...field.comments, ...this.getCustomAttributesAsComments(field)]; + const result = model.addField(field.name, type, attributes, docs, addToFront); if (this.mode === 'logical') { if (field.attributes.some((attr) => isDefaultWithAuth(attr))) { @@ -777,8 +843,6 @@ export class PrismaSchemaGenerator { } } - // user defined comments pass-through - field.comments.forEach((c) => result.addComment(c)); return result; } @@ -898,12 +962,9 @@ export class PrismaSchemaGenerator { this.generateContainerAttribute(_enum, attr); } - decl.attributes - .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) - .forEach((attr) => _enum.addComment('/// ' + this.zModelGenerator.generate(attr))); - // user defined comments pass-through decl.comments.forEach((c) => _enum.addComment(c)); + this.getCustomAttributesAsComments(decl).forEach((c) => _enum.addComment(c)); } private generateEnumField(_enum: PrismaEnum, field: EnumField) { @@ -911,10 +972,18 @@ export class PrismaSchemaGenerator { .filter((attr) => this.isPrismaAttribute(attr)) .map((attr) => this.makeFieldAttribute(attr)); - const nonPrismaAttributes = field.attributes.filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)); + const docs = [...field.comments, ...this.getCustomAttributesAsComments(field)]; + _enum.addField(field.name, attributes, docs); + } - const documentations = nonPrismaAttributes.map((attr) => '/// ' + this.zModelGenerator.generate(attr)); - _enum.addField(field.name, attributes, documentations.concat(field.comments)); + private getCustomAttributesAsComments(decl: DataModel | DataModelField | Enum | EnumField) { + if (!this.customAttributesAsComments) { + return []; + } else { + return decl.attributes + .filter((attr) => attr.decl.ref && !this.isPrismaAttribute(attr)) + .map((attr) => `/// ${this.zModelGenerator.generate(attr)}`); + } } } diff --git a/packages/schema/src/plugins/zod/generator.ts b/packages/schema/src/plugins/zod/generator.ts index ca26ffabe..01a5920ff 100644 --- a/packages/schema/src/plugins/zod/generator.ts +++ b/packages/schema/src/plugins/zod/generator.ts @@ -14,12 +14,12 @@ import { parseOptionAsStrings, resolvePath, } from '@zenstackhq/sdk'; -import { DataModel, EnumField, Model, isDataModel, isEnum } from '@zenstackhq/sdk/ast'; +import { DataModel, EnumField, Model, TypeDef, isDataModel, isEnum, isTypeDef } from '@zenstackhq/sdk/ast'; import { addMissingInputObjectTypes, resolveAggregateOperationSupport } from '@zenstackhq/sdk/dmmf-helpers'; import { getPrismaClientImportSpec, supportCreateMany, type DMMF } from '@zenstackhq/sdk/prisma'; import { streamAllContents } from 'langium'; import path from 'path'; -import type { SourceFile } from 'ts-morph'; +import type { CodeBlockWriter, SourceFile } from 'ts-morph'; import { upperCaseFirst } from 'upper-case-first'; import { name } from '.'; import { getDefaultOutputFolder } from '../plugin-utils'; @@ -274,6 +274,12 @@ export class ZodSchemaGenerator { } } + for (const typeDef of this.model.declarations.filter(isTypeDef)) { + if (!excludedModels.includes(typeDef.name)) { + schemaNames.push(await this.generateTypeDefSchema(typeDef, output)); + } + } + this.sourceFiles.push( this.project.createSourceFile( path.join(output, 'models', 'index.ts'), @@ -283,6 +289,89 @@ export class ZodSchemaGenerator { ); } + private generateTypeDefSchema(typeDef: TypeDef, output: string) { + const schemaName = `${upperCaseFirst(typeDef.name)}.schema`; + const sf = this.project.createSourceFile(path.join(output, 'models', `${schemaName}.ts`), undefined, { + overwrite: true, + }); + this.sourceFiles.push(sf); + sf.replaceWithText((writer) => { + this.addPreludeAndImports(typeDef, writer, output); + + writer.write(`export const ${typeDef.name}Schema = z.object(`); + writer.inlineBlock(() => { + typeDef.fields.forEach((field) => { + writer.writeLine(`${field.name}: ${makeFieldSchema(field)},`); + }); + }); + + switch (this.options.mode) { + case 'strip': + // zod strips by default + writer.writeLine(')'); + break; + case 'passthrough': + writer.writeLine(').passthrough();'); + break; + default: + writer.writeLine(').strict();'); + break; + } + }); + + // TODO: "@@validate" refinements + + return schemaName; + } + + private addPreludeAndImports(decl: DataModel | TypeDef, writer: CodeBlockWriter, output: string) { + writer.writeLine('/* eslint-disable */'); + writer.writeLine(`import { z } from 'zod';`); + + // import user-defined enums from Prisma as they might be referenced in the expressions + const importEnums = new Set(); + for (const node of streamAllContents(decl)) { + if (isEnumFieldReference(node)) { + const field = node.target.ref as EnumField; + if (!isFromStdlib(field.$container)) { + importEnums.add(field.$container.name); + } + } + } + if (importEnums.size > 0) { + const prismaImport = computePrismaClientImport(path.join(output, 'models'), this.options); + writer.writeLine(`import { ${[...importEnums].join(', ')} } from '${prismaImport}';`); + } + + // import enum schemas + const importedEnumSchemas = new Set(); + for (const field of decl.fields) { + if (field.type.reference?.ref && isEnum(field.type.reference?.ref)) { + const name = upperCaseFirst(field.type.reference?.ref.name); + if (!importedEnumSchemas.has(name)) { + writer.writeLine(`import { ${name}Schema } from '../enums/${name}.schema';`); + importedEnumSchemas.add(name); + } + } + } + + // import Decimal + if (decl.fields.some((field) => field.type.type === 'Decimal')) { + writer.writeLine(`import { DecimalSchema } from '../common';`); + writer.writeLine(`import { Decimal } from 'decimal.js';`); + } + + // import referenced types' schemas + const referencedTypes = new Set( + decl.fields + .filter((field) => isTypeDef(field.type.reference?.ref) && field.type.reference?.ref.name !== decl.name) + .map((field) => field.type.reference!.ref!.name) + ); + for (const refType of referencedTypes) { + writer.writeLine(`import { ${upperCaseFirst(refType)}Schema } from './${upperCaseFirst(refType)}.schema';`); + } + } + private async generateModelSchema(model: DataModel, output: string) { const schemaName = `${upperCaseFirst(model.name)}.schema`; const sf = this.project.createSourceFile(path.join(output, 'models', `${schemaName}.ts`), undefined, { @@ -301,41 +390,7 @@ export class ZodSchemaGenerator { const relations = model.fields.filter((field) => isDataModel(field.type.reference?.ref)); const fkFields = model.fields.filter((field) => isForeignKeyField(field)); - writer.writeLine('/* eslint-disable */'); - writer.writeLine(`import { z } from 'zod';`); - - // import user-defined enums from Prisma as they might be referenced in the expressions - const importEnums = new Set(); - for (const node of streamAllContents(model)) { - if (isEnumFieldReference(node)) { - const field = node.target.ref as EnumField; - if (!isFromStdlib(field.$container)) { - importEnums.add(field.$container.name); - } - } - } - if (importEnums.size > 0) { - const prismaImport = computePrismaClientImport(path.join(output, 'models'), this.options); - writer.writeLine(`import { ${[...importEnums].join(', ')} } from '${prismaImport}';`); - } - - // import enum schemas - const importedEnumSchemas = new Set(); - for (const field of scalarFields) { - if (field.type.reference?.ref && isEnum(field.type.reference?.ref)) { - const name = upperCaseFirst(field.type.reference?.ref.name); - if (!importedEnumSchemas.has(name)) { - writer.writeLine(`import { ${name}Schema } from '../enums/${name}.schema';`); - importedEnumSchemas.add(name); - } - } - } - - // import Decimal - if (scalarFields.some((field) => field.type.type === 'Decimal')) { - writer.writeLine(`import { DecimalSchema } from '../common';`); - writer.writeLine(`import { Decimal } from 'decimal.js';`); - } + this.addPreludeAndImports(model, writer, output); // base schema - including all scalar fields, with optionality following the schema writer.write(`const baseSchema = z.object(`); diff --git a/packages/schema/src/plugins/zod/transformer.ts b/packages/schema/src/plugins/zod/transformer.ts index 698ad2ac6..6b83e5723 100644 --- a/packages/schema/src/plugins/zod/transformer.ts +++ b/packages/schema/src/plugins/zod/transformer.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { indentString, isDiscriminatorField, type PluginOptions } from '@zenstackhq/sdk'; -import { DataModel, isDataModel, type Model } from '@zenstackhq/sdk/ast'; +import { DataModel, isDataModel, isTypeDef, type Model } from '@zenstackhq/sdk/ast'; import { checkModelHasModelRelation, findModelByName, isAggregateInputType } from '@zenstackhq/sdk/dmmf-helpers'; import { supportCreateMany, type DMMF as PrismaDMMF } from '@zenstackhq/sdk/prisma'; import path from 'path'; @@ -88,29 +88,33 @@ export default class Transformer { } generateObjectSchema(generateUnchecked: boolean, options: PluginOptions) { - const zodObjectSchemaFields = this.generateObjectSchemaFields(generateUnchecked); - const objectSchema = this.prepareObjectSchema(zodObjectSchemaFields, options); + const { schemaFields, extraImports } = this.generateObjectSchemaFields(generateUnchecked); + const objectSchema = this.prepareObjectSchema(schemaFields, options); const filePath = path.join(Transformer.outputPath, `objects/${this.name}.schema.ts`); - const content = '/* eslint-disable */\n' + objectSchema; + const content = '/* eslint-disable */\n' + extraImports.join('\n\n') + objectSchema; this.sourceFiles.push(this.project.createSourceFile(filePath, content, { overwrite: true })); return `${this.name}.schema`; } - private delegateCreateUpdateInputRegex = /(\S+)(Unchecked)?(Create|Update).*Input/; + private createUpdateInputRegex = /(\S+?)(Unchecked)?(Create|Update|CreateMany|UpdateMany).*Input/; generateObjectSchemaFields(generateUnchecked: boolean) { let fields = this.fields; + let contextDataModel: DataModel | undefined; + const extraImports: string[] = []; // exclude discriminator fields from create/update input schemas - const createUpdateMatch = this.delegateCreateUpdateInputRegex.exec(this.name); + const createUpdateMatch = this.createUpdateInputRegex.exec(this.name); if (createUpdateMatch) { const modelName = createUpdateMatch[1]; - const dataModel = this.zmodel.declarations.find( + contextDataModel = this.zmodel.declarations.find( (d): d is DataModel => isDataModel(d) && d.name === modelName ); - if (dataModel) { - const discriminatorFields = dataModel.fields.filter(isDiscriminatorField); + + if (contextDataModel) { + // exclude discriminator fields from create/update input schemas + const discriminatorFields = contextDataModel.fields.filter(isDiscriminatorField); if (discriminatorFields.length > 0) { fields = fields.filter((field) => { return !discriminatorFields.some( @@ -118,11 +122,23 @@ export default class Transformer { ); }); } + + // import type-def's schemas + const typeDefFields = contextDataModel.fields.filter((f) => isTypeDef(f.type.reference?.ref)); + typeDefFields.forEach((field) => { + const typeName = upperCaseFirst(field.type.reference!.$refText); + const importLine = `import { ${typeName}Schema } from '../models/${typeName}.schema';`; + if (!extraImports.includes(importLine)) { + extraImports.push(importLine); + } + }); } } const zodObjectSchemaFields = fields - .map((field) => this.generateObjectSchemaField(field, generateUnchecked)) + .map((field) => + this.generateObjectSchemaField(field, contextDataModel, generateUnchecked, !!createUpdateMatch) + ) .flatMap((item) => item) .map((item) => { const [zodStringWithMainType, field, skipValidators] = item; @@ -133,12 +149,14 @@ export default class Transformer { return value.trim(); }); - return zodObjectSchemaFields; + return { schemaFields: zodObjectSchemaFields, extraImports }; } generateObjectSchemaField( field: PrismaDMMF.SchemaArg, - generateUnchecked: boolean + contextDataModel: DataModel | undefined, + generateUnchecked: boolean, + replaceJsonWithTypeDef = false ): [string, PrismaDMMF.SchemaArg, boolean][] { const lines = field.inputTypes; @@ -146,64 +164,75 @@ export default class Transformer { return []; } - let alternatives = lines.reduce((result, inputType) => { - if (!generateUnchecked && typeof inputType.type === 'string' && inputType.type.includes('Unchecked')) { - return result; - } + let alternatives: string[] | undefined = undefined; - if (inputType.type.includes('CreateMany') && !supportCreateMany(this.zmodel)) { - return result; + if (replaceJsonWithTypeDef) { + const dmField = contextDataModel?.fields.find((f) => f.name === field.name); + if (isTypeDef(dmField?.type.reference?.ref)) { + alternatives = [`z.lazy(() => ${upperCaseFirst(dmField?.type.reference!.$refText)}Schema)`]; } + } - // TODO: unify the following with `schema-gen.ts` - - if (inputType.type === 'String') { - result.push(this.wrapWithZodValidators('z.string()', field, inputType)); - } else if (inputType.type === 'Int' || inputType.type === 'Float') { - result.push(this.wrapWithZodValidators('z.number()', field, inputType)); - } else if (inputType.type === 'Decimal') { - this.hasDecimal = true; - result.push(this.wrapWithZodValidators('DecimalSchema', field, inputType)); - } else if (inputType.type === 'BigInt') { - result.push(this.wrapWithZodValidators('z.bigint()', field, inputType)); - } else if (inputType.type === 'Boolean') { - result.push(this.wrapWithZodValidators('z.boolean()', field, inputType)); - } else if (inputType.type === 'DateTime') { - result.push(this.wrapWithZodValidators(['z.date()', 'z.string().datetime()'], field, inputType)); - } else if (inputType.type === 'Bytes') { - result.push( - this.wrapWithZodValidators( - `z.custom(data => data instanceof Uint8Array)`, - field, - inputType - ) - ); - } else if (inputType.type === 'Json') { - this.hasJson = true; - result.push(this.wrapWithZodValidators('jsonSchema', field, inputType)); - } else if (inputType.type === 'True') { - result.push(this.wrapWithZodValidators('z.literal(true)', field, inputType)); - } else if (inputType.type === 'Null') { - result.push(this.wrapWithZodValidators('z.null()', field, inputType)); - } else { - const isEnum = inputType.location === 'enumTypes'; - const isFieldRef = inputType.location === 'fieldRefTypes'; - - if ( - // fieldRefTypes refer to other fields in the model and don't need to be generated as part of schema - !isFieldRef && - (inputType.namespace === 'prisma' || isEnum) - ) { - if (inputType.type !== this.originalName && typeof inputType.type === 'string') { - this.addSchemaImport(inputType.type); - } + if (!alternatives) { + alternatives = lines.reduce((result, inputType) => { + if (!generateUnchecked && typeof inputType.type === 'string' && inputType.type.includes('Unchecked')) { + return result; + } - result.push(this.generatePrismaStringLine(field, inputType, lines.length)); + if (inputType.type.includes('CreateMany') && !supportCreateMany(this.zmodel)) { + return result; + } + + // TODO: unify the following with `schema-gen.ts` + + if (inputType.type === 'String') { + result.push(this.wrapWithZodValidators('z.string()', field, inputType)); + } else if (inputType.type === 'Int' || inputType.type === 'Float') { + result.push(this.wrapWithZodValidators('z.number()', field, inputType)); + } else if (inputType.type === 'Decimal') { + this.hasDecimal = true; + result.push(this.wrapWithZodValidators('DecimalSchema', field, inputType)); + } else if (inputType.type === 'BigInt') { + result.push(this.wrapWithZodValidators('z.bigint()', field, inputType)); + } else if (inputType.type === 'Boolean') { + result.push(this.wrapWithZodValidators('z.boolean()', field, inputType)); + } else if (inputType.type === 'DateTime') { + result.push(this.wrapWithZodValidators(['z.date()', 'z.string().datetime()'], field, inputType)); + } else if (inputType.type === 'Bytes') { + result.push( + this.wrapWithZodValidators( + `z.custom(data => data instanceof Uint8Array)`, + field, + inputType + ) + ); + } else if (inputType.type === 'Json') { + this.hasJson = true; + result.push(this.wrapWithZodValidators('jsonSchema', field, inputType)); + } else if (inputType.type === 'True') { + result.push(this.wrapWithZodValidators('z.literal(true)', field, inputType)); + } else if (inputType.type === 'Null') { + result.push(this.wrapWithZodValidators('z.null()', field, inputType)); + } else { + const isEnum = inputType.location === 'enumTypes'; + const isFieldRef = inputType.location === 'fieldRefTypes'; + + if ( + // fieldRefTypes refer to other fields in the model and don't need to be generated as part of schema + !isFieldRef && + (inputType.namespace === 'prisma' || isEnum) + ) { + if (inputType.type !== this.originalName && typeof inputType.type === 'string') { + this.addSchemaImport(inputType.type); + } + + result.push(this.generatePrismaStringLine(field, inputType, lines.length)); + } } - } - return result; - }, []); + return result; + }, []); + } if (alternatives.length === 0) { return []; diff --git a/packages/schema/src/plugins/zod/utils/schema-gen.ts b/packages/schema/src/plugins/zod/utils/schema-gen.ts index ee46390ff..c130934b2 100644 --- a/packages/schema/src/plugins/zod/utils/schema-gen.ts +++ b/packages/schema/src/plugins/zod/utils/schema-gen.ts @@ -1,32 +1,34 @@ import { ExpressionContext, - PluginError, - TypeScriptExpressionTransformer, - TypeScriptExpressionTransformerError, getAttributeArg, getAttributeArgLiteral, getLiteral, getLiteralArray, isDataModelFieldReference, isFromStdlib, + PluginError, + TypeScriptExpressionTransformer, + TypeScriptExpressionTransformerError, } from '@zenstackhq/sdk'; import { DataModel, DataModelField, DataModelFieldAttribute, - isDataModel, isArrayExpr, + isBooleanLiteral, + isDataModel, isEnum, isInvocationExpr, isNumberLiteral, isStringLiteral, - isBooleanLiteral + isTypeDef, + TypeDefField, } from '@zenstackhq/sdk/ast'; import { upperCaseFirst } from 'upper-case-first'; import { name } from '..'; import { isDefaultWithAuth } from '../../enhancer/enhancer-utils'; -export function makeFieldSchema(field: DataModelField) { +export function makeFieldSchema(field: DataModelField | TypeDefField) { if (isDataModel(field.type.reference?.ref)) { if (field.type.array) { // array field is always optional @@ -172,11 +174,17 @@ export function makeFieldSchema(field: DataModelField) { return schema; } -function makeZodSchema(field: DataModelField) { +function makeZodSchema(field: DataModelField | TypeDefField) { let schema: string; - if (field.type.reference?.ref && isEnum(field.type.reference?.ref)) { - schema = `${upperCaseFirst(field.type.reference.ref.name)}Schema`; + if (field.type.reference?.ref) { + if (isEnum(field.type.reference?.ref)) { + schema = `${upperCaseFirst(field.type.reference.ref.name)}Schema`; + } else if (isTypeDef(field.type.reference?.ref)) { + schema = `z.lazy(() => ${upperCaseFirst(field.type.reference.ref.name)}Schema)`; + } else { + schema = 'z.any()'; + } } else { switch (field.type.type) { case 'Int': @@ -227,7 +235,8 @@ export function makeValidationRefinements(model: DataModel) { const message = messageArg ? `message: ${JSON.stringify(messageArg)},` : ''; const pathArg = getAttributeArg(attr, 'path'); - const path = pathArg && isArrayExpr(pathArg) ? `path: ['${getLiteralArray(pathArg)?.join(`', '`)}'],` : ''; + const path = + pathArg && isArrayExpr(pathArg) ? `path: ['${getLiteralArray(pathArg)?.join(`', '`)}'],` : ''; const options = `, { ${message} ${path} }`; @@ -272,7 +281,7 @@ function refineDecimal(op: 'gt' | 'gte' | 'lt' | 'lte', value: number, messageAr }${messageArg})`; } -export function getFieldSchemaDefault(field: DataModelField) { +export function getFieldSchemaDefault(field: DataModelField | TypeDefField) { const attr = field.attributes.find((attr) => attr.decl.ref?.name === '@default'); if (!attr) { return undefined; diff --git a/packages/schema/src/res/starter.zmodel b/packages/schema/src/res/starter.zmodel index c23cbbbeb..978724dc7 100644 --- a/packages/schema/src/res/starter.zmodel +++ b/packages/schema/src/res/starter.zmodel @@ -1,8 +1,6 @@ // This is a sample model to get you started. -/** - * A sample data source using local sqlite db. - */ +/// A sample data source using local sqlite db. datasource db { provider = 'sqlite' url = 'file:./dev.db' @@ -12,9 +10,7 @@ generator client { provider = "prisma-client-js" } -/** - * User model - */ +/// User model model User { id String @id @default(cuid()) email String @unique @email @length(6, 32) @@ -28,9 +24,7 @@ model User { @@allow('all', auth() == this) } -/** - * Post model - */ +/// Post model model Post { id String @id @default(cuid()) createdAt DateTime @default(now()) diff --git a/packages/schema/src/res/stdlib.zmodel b/packages/schema/src/res/stdlib.zmodel index a436ea4a8..62cefd36e 100644 --- a/packages/schema/src/res/stdlib.zmodel +++ b/packages/schema/src/res/stdlib.zmodel @@ -47,6 +47,7 @@ enum AttributeTargetField { JsonField BytesField ModelField + TypeDefField } /** @@ -175,6 +176,11 @@ function isEmpty(field: Any[]): Boolean { */ attribute @@@targetField(_ targetField: AttributeTargetField[]) +/** + * Marks an attribute to be applicable to type defs and fields. + */ +attribute @@@supportTypeDef() + /** * Marks an attribute to be used for data validation. */ @@ -209,7 +215,7 @@ attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) * Defines a default value for a field. * @param value: An expression (e.g. 5, true, now(), auth()). */ -attribute @default(_ value: ContextType, map: String?) @@@prisma +attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeDef /** * Defines a unique constraint for this field. @@ -558,77 +564,77 @@ attribute @omit() /** * Validates length of a string field. */ -attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value starts with the given text. */ -attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value ends with the given text. */ -attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value contains the given text. */ -attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value matches a regex. */ -attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation +attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value is a valid email address. */ -attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value is a valid ISO datetime. */ -attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a string field value is a valid url. */ -attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation +attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Trims whitespaces from the start and end of the string. */ -attribute @trim() @@@targetField([StringField]) @@@validation +attribute @trim() @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Transform entire string toLowerCase. */ -attribute @lower() @@@targetField([StringField]) @@@validation +attribute @lower() @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Transform entire string toUpperCase. */ -attribute @upper() @@@targetField([StringField]) @@@validation +attribute @upper() @@@targetField([StringField]) @@@validation @@@supportTypeDef /** * Validates a number field is greater than the given value. */ -attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates a number field is greater than or equal to the given value. */ -attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates a number field is less than the given value. */ -attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates a number field is less than or equal to the given value. */ -attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation +attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef /** * Validates the entity with a complex condition. @@ -700,3 +706,8 @@ attribute @@delegate(_ discriminator: FieldReference) */ function raw(value: String): Any { } @@@expressionContext([Index]) + +/** + * Marks a field to be strong-typed JSON. + */ +attribute @json() @@@targetField([TypeDefField]) diff --git a/packages/schema/src/utils/ast-utils.ts b/packages/schema/src/utils/ast-utils.ts index b040d4cf3..effd472f0 100644 --- a/packages/schema/src/utils/ast-utils.ts +++ b/packages/schema/src/utils/ast-utils.ts @@ -17,7 +17,13 @@ import { ModelImport, ReferenceExpr, } from '@zenstackhq/language/ast'; -import { getModelFieldsWithBases, getRecursiveBases, isDelegateModel, isFromStdlib } from '@zenstackhq/sdk'; +import { + getInheritanceChain, + getModelFieldsWithBases, + getRecursiveBases, + isDelegateModel, + isFromStdlib, +} from '@zenstackhq/sdk'; import { AstNode, copyAstNode, @@ -61,7 +67,7 @@ export function mergeBaseModels(model: Model, linker: Linker) { .concat(dataModel.fields); dataModel.attributes = bases - .flatMap((base) => base.attributes.filter((attr) => filterBaseAttribute(base, attr))) + .flatMap((base) => base.attributes.filter((attr) => filterBaseAttribute(dataModel, base, attr))) .map((attr) => cloneAst(attr, dataModel, buildReference)) .concat(dataModel.attributes); } @@ -85,7 +91,7 @@ export function mergeBaseModels(model: Model, linker: Linker) { linkContentToContainer(model); } -function filterBaseAttribute(base: DataModel, attr: DataModelAttribute) { +function filterBaseAttribute(forModel: DataModel, base: DataModel, attr: DataModelAttribute) { if (attr.$inheritedFrom) { // don't inherit from skip-level base return false; @@ -101,13 +107,26 @@ function filterBaseAttribute(base: DataModel, attr: DataModelAttribute) { return false; } - if (isDelegateModel(base) && uninheritableFromDelegateAttributes.includes(attr.decl.$refText)) { + if ( + // checks if the inheritance is from a delegate model or through one, if so, + // the attribute shouldn't be inherited as the delegate already inherits it + isInheritedFromOrThroughDelegate(forModel, base) && + uninheritableFromDelegateAttributes.includes(attr.decl.$refText) + ) { return false; } return true; } +function isInheritedFromOrThroughDelegate(model: DataModel, base: DataModel) { + if (isDelegateModel(base)) { + return true; + } + const chain = getInheritanceChain(model, base); + return !!chain?.some(isDelegateModel); +} + // deep clone an AST, relink references, and set its container function cloneAst( node: T, diff --git a/packages/schema/tests/generator/prisma-generator.test.ts b/packages/schema/tests/generator/prisma-generator.test.ts index 5affcec77..b4f58dcf1 100644 --- a/packages/schema/tests/generator/prisma-generator.test.ts +++ b/packages/schema/tests/generator/prisma-generator.test.ts @@ -47,10 +47,35 @@ describe('Prisma generator test', () => { provider = '@core/prisma' } + /// User roles + enum Role { + /// Admin role + ADMIN + /// Regular role + USER + + @@schema("auth") + } + + /// My user model + /// defined here model User { - id String @id + /// the id field + id String @id @allow('read', this == auth()) + role Role @@schema("auth") + @@allow('all', true) + @@deny('update', this != auth()) + } + + /** + * My post model + * defined here + */ + model Post { + id String @id + @@schema("public") } `); @@ -60,6 +85,7 @@ describe('Prisma generator test', () => { schemaPath: 'schema.zmodel', output: 'schema.prisma', format: false, + customAttributesAsComments: true, }); const content = fs.readFileSync('schema.prisma', 'utf-8'); @@ -70,6 +96,14 @@ describe('Prisma generator test', () => { 'extensions = [pg_trgm, postgis(version: "3.3.2"), uuid_ossp(map: "uuid-ossp", schema: "extensions")]' ); expect(content).toContain('schemas = ["auth", "public"]'); + expect(content).toContain('/// My user model'); + expect(content).toContain(`/// @@allow('all', true)`); + expect(content).toContain(`/// the id field`); + expect(content).toContain(`/// @allow('read', this == auth())`); + expect(content).not.toContain('/// My post model'); + expect(content).toContain('/// User roles'); + expect(content).toContain('/// Admin role'); + expect(content).toContain('/// Regular role'); await getDMMF({ datamodel: content }); }); @@ -172,6 +206,7 @@ describe('Prisma generator test', () => { provider: '@core/prisma', schemaPath: 'schema.zmodel', output: name, + customAttributesAsComments: true, }); const content = fs.readFileSync(name, 'utf-8'); @@ -204,6 +239,7 @@ describe('Prisma generator test', () => { provider: '@core/prisma', schemaPath: 'schema.zmodel', output: name, + customAttributesAsComments: true, }); const content = fs.readFileSync(name, 'utf-8'); @@ -397,6 +433,7 @@ describe('Prisma generator test', () => { schemaPath: 'schema.zmodel', output: name, generateClient: false, + customAttributesAsComments: true, }); const content = fs.readFileSync(name, 'utf-8'); @@ -447,6 +484,7 @@ describe('Prisma generator test', () => { schemaPath: 'schema.zmodel', output: name, format: true, + customAttributesAsComments: true, }); const content = fs.readFileSync(name, 'utf-8'); @@ -478,6 +516,7 @@ describe('Prisma generator test', () => { schemaPath: 'schema.zmodel', output: name, format: true, + customAttributesAsComments: true, }); const content = fs.readFileSync(name, 'utf-8'); diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts index 0c89c61ae..4d86837d0 100644 --- a/packages/schema/tests/schema/validation/attribute-validation.test.ts +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -1354,4 +1354,19 @@ describe('Attribute tests', () => { 'Field-level policy rules with "update" or "all" kind are not allowed for relation fields. Put rules on foreign-key fields instead.' ); }); + + it('type def field attribute', async () => { + await expect( + loadModelWithError(` + model User { + id String @id + profile Profile + } + + type Profile { + email String @omit + } + `) + ).resolves.toContain(`attribute "@omit" cannot be used on type declaration fields`); + }); }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 5d9af1782..6222c5500 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "2.7.5", + "version": "2.8.0", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { @@ -18,8 +18,8 @@ "author": "", "license": "MIT", "dependencies": { - "@prisma/generator-helper": "5.21.x", - "@prisma/internals": "5.21.x", + "@prisma/generator-helper": "5.22.x", + "@prisma/internals": "5.22.x", "@zenstackhq/language": "workspace:*", "@zenstackhq/runtime": "workspace:*", "langium": "1.3.1", diff --git a/packages/sdk/src/model-meta-generator.ts b/packages/sdk/src/model-meta-generator.ts index 469cfa888..859399673 100644 --- a/packages/sdk/src/model-meta-generator.ts +++ b/packages/sdk/src/model-meta-generator.ts @@ -6,11 +6,15 @@ import { isArrayExpr, isBooleanLiteral, isDataModel, + isDataModelField, isInvocationExpr, isNumberLiteral, isReferenceExpr, isStringLiteral, + isTypeDef, ReferenceExpr, + TypeDef, + TypeDefField, } from '@zenstackhq/language/ast'; import type { RuntimeAttribute } from '@zenstackhq/runtime'; import { streamAst } from 'langium'; @@ -62,13 +66,18 @@ export type ModelMetaGeneratorOptions = { shortNameMap?: Map; }; -export async function generate(project: Project, models: DataModel[], options: ModelMetaGeneratorOptions) { +export async function generate( + project: Project, + models: DataModel[], + typeDefs: TypeDef[], + options: ModelMetaGeneratorOptions +) { const sf = project.createSourceFile(options.output, undefined, { overwrite: true }); sf.addStatements('/* eslint-disable */'); sf.addVariableStatement({ declarationKind: VariableDeclarationKind.Const, declarations: [ - { name: 'metadata', initializer: (writer) => generateModelMetadata(models, sf, writer, options) }, + { name: 'metadata', initializer: (writer) => generateModelMetadata(models, typeDefs, sf, writer, options) }, ], }); sf.addStatements('export default metadata;'); @@ -82,12 +91,14 @@ export async function generate(project: Project, models: DataModel[], options: M function generateModelMetadata( dataModels: DataModel[], + typeDefs: TypeDef[], sourceFile: SourceFile, writer: CodeBlockWriter, options: ModelMetaGeneratorOptions ) { writer.block(() => { writeModels(sourceFile, writer, dataModels, options); + writeTypeDefs(sourceFile, writer, typeDefs, options); writeDeleteCascade(writer, dataModels); writeShortNameMap(options, writer); writeAuthModel(writer, dataModels); @@ -120,6 +131,29 @@ function writeModels( writer.writeLine(','); } +function writeTypeDefs( + sourceFile: SourceFile, + writer: CodeBlockWriter, + typedDefs: TypeDef[], + options: ModelMetaGeneratorOptions +) { + if (typedDefs.length === 0) { + return; + } + writer.write('typeDefs:'); + writer.block(() => { + for (const typeDef of typedDefs) { + writer.write(`${lowerCaseFirst(typeDef.name)}:`); + writer.block(() => { + writer.write(`name: '${typeDef.name}',`); + writeFields(sourceFile, writer, typeDef, options); + }); + writer.writeLine(','); + } + }); + writer.writeLine(','); +} + function writeBaseTypes(writer: CodeBlockWriter, model: DataModel) { if (model.superTypes.length > 0) { writer.write('baseTypes: ['); @@ -189,14 +223,14 @@ function writeDiscriminator(writer: CodeBlockWriter, model: DataModel) { function writeFields( sourceFile: SourceFile, writer: CodeBlockWriter, - model: DataModel, + container: DataModel | TypeDef, options: ModelMetaGeneratorOptions ) { writer.write('fields:'); writer.block(() => { - for (const f of model.fields) { - const backlink = getBackLink(f); - const fkMapping = generateForeignKeyMapping(f); + for (const f of container.fields) { + const dmField = isDataModelField(f) ? f : undefined; + writer.write(`${f.name}: {`); writer.write(` @@ -208,7 +242,7 @@ function writeFields( f.type.type! }",`); - if (isIdField(f)) { + if (dmField && isIdField(dmField)) { writer.write(` isId: true,`); } @@ -216,6 +250,9 @@ function writeFields( if (isDataModel(f.type.reference?.ref)) { writer.write(` isDataModel: true,`); + } else if (isTypeDef(f.type.reference?.ref)) { + writer.write(` + isTypeDef: true,`); } if (f.type.array) { @@ -243,46 +280,53 @@ function writeFields( } } - if (backlink) { + const defaultValueProvider = generateDefaultValueProvider(f, sourceFile); + if (defaultValueProvider) { writer.write(` - backLink: '${backlink.name}',`); + defaultValueProvider: ${defaultValueProvider},`); } - if (isRelationOwner(f, backlink)) { - writer.write(` + if (dmField) { + // metadata specific to DataModelField + + const backlink = getBackLink(dmField); + const fkMapping = generateForeignKeyMapping(dmField); + + if (backlink) { + writer.write(` + backLink: '${backlink.name}',`); + } + + if (isRelationOwner(dmField, backlink)) { + writer.write(` isRelationOwner: true,`); - } + } - if (isForeignKeyField(f)) { - writer.write(` - isForeignKey: true,`); - const relationField = getRelationField(f); - if (relationField) { + if (isForeignKeyField(dmField)) { writer.write(` + isForeignKey: true,`); + const relationField = getRelationField(dmField); + if (relationField) { + writer.write(` relationField: '${relationField.name}',`); + } } - } - if (fkMapping && Object.keys(fkMapping).length > 0) { - writer.write(` + if (fkMapping && Object.keys(fkMapping).length > 0) { + writer.write(` foreignKeyMapping: ${JSON.stringify(fkMapping)},`); - } - - const defaultValueProvider = generateDefaultValueProvider(f, sourceFile); - if (defaultValueProvider) { - writer.write(` - defaultValueProvider: ${defaultValueProvider},`); - } + } - const inheritedFromDelegate = getInheritedFromDelegate(f); - if (inheritedFromDelegate && !isIdField(f)) { - writer.write(` + const inheritedFromDelegate = getInheritedFromDelegate(dmField); + if (inheritedFromDelegate && !isIdField(dmField)) { + writer.write(` inheritedFrom: ${JSON.stringify(inheritedFromDelegate.name)},`); - } + } - if (isAutoIncrement(f)) { - writer.write(` + if (isAutoIncrement(dmField)) { + writer.write(` isAutoIncrement: true,`); + } } writer.write(` @@ -337,7 +381,7 @@ function getRelationName(field: DataModelField) { return getAttributeArgLiteral(relAttr, 'name'); } -function getAttributes(target: DataModelField | DataModel): RuntimeAttribute[] { +function getAttributes(target: DataModelField | DataModel | TypeDefField): RuntimeAttribute[] { return target.attributes .map((attr) => { const args: Array<{ name?: string; value: unknown }> = []; @@ -498,7 +542,7 @@ function getDeleteCascades(model: DataModel): string[] { .map((m) => m.name); } -function generateDefaultValueProvider(field: DataModelField, sourceFile: SourceFile) { +function generateDefaultValueProvider(field: DataModelField | TypeDefField, sourceFile: SourceFile) { const defaultAttr = getAttribute(field, '@default'); if (!defaultAttr) { return undefined; diff --git a/packages/sdk/src/utils.ts b/packages/sdk/src/utils.ts index d6c4c0fd5..6b2bfe868 100644 --- a/packages/sdk/src/utils.ts +++ b/packages/sdk/src/utils.ts @@ -30,6 +30,7 @@ import { Model, Reference, ReferenceExpr, + TypeDefField, } from '@zenstackhq/language/ast'; import fs from 'node:fs'; import path from 'path'; @@ -123,7 +124,7 @@ export function hasAttribute( } export function getAttribute( - decl: DataModel | DataModelField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, + decl: DataModel | DataModelField | TypeDefField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, name: string ) { return (decl.attributes as (DataModelAttribute | DataModelFieldAttribute)[]).find( @@ -460,6 +461,9 @@ export function isDelegateModel(node: AstNode) { } export function isDiscriminatorField(field: DataModelField) { + if (!isDataModel(field.$container)) { + return false; + } const model = field.$inheritedFrom ?? field.$container; const delegateAttr = getAttribute(model, '@@delegate'); if (!delegateAttr) { @@ -569,3 +573,24 @@ export function getInheritedFromDelegate(field: DataModelField) { const foundBase = bases.findLast((base) => base.fields.some((f) => f.name === field.name) && isDelegateModel(base)); return foundBase; } + +/** + * Gets the inheritance chain from "from" to "to", excluding them. + */ +export function getInheritanceChain(from: DataModel, to: DataModel): DataModel[] | undefined { + if (from === to) { + return []; + } + + for (const base of from.superTypes) { + if (base.ref === to) { + return []; + } + const path = getInheritanceChain(base.ref!, to); + if (path) { + return [base.ref as DataModel, ...path]; + } + } + + return undefined; +} diff --git a/packages/sdk/src/validation.ts b/packages/sdk/src/validation.ts index e7edc21fc..9dbcd8f5c 100644 --- a/packages/sdk/src/validation.ts +++ b/packages/sdk/src/validation.ts @@ -1,4 +1,11 @@ -import type { DataModel, DataModelAttribute, DataModelFieldAttribute } from './ast'; +import { + isDataModel, + isTypeDef, + type DataModel, + type DataModelAttribute, + type DataModelFieldAttribute, + type TypeDef, +} from './ast'; function isValidationAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { return attr.decl.ref?.attributes.some((attr) => attr.decl.$refText === '@@@validation'); @@ -8,12 +15,30 @@ function isValidationAttribute(attr: DataModelAttribute | DataModelFieldAttribut * Returns if the given model contains any data validation rules (both at the model * level and at the field level). */ -export function hasValidationAttributes(model: DataModel) { - if (model.attributes.some((attr) => isValidationAttribute(attr))) { - return true; +export function hasValidationAttributes( + decl: DataModel | TypeDef, + seen: Set = new Set() +): boolean { + if (seen.has(decl)) { + return false; + } + seen.add(decl); + + if (isDataModel(decl)) { + if (decl.attributes.some((attr) => isValidationAttribute(attr))) { + return true; + } } - if (model.fields.some((field) => field.attributes.some((attr) => isValidationAttribute(attr)))) { + if ( + decl.fields.some((field) => { + if (isTypeDef(field.type.reference?.ref)) { + return hasValidationAttributes(field.type.reference?.ref, seen); + } else { + return field.attributes.some((attr) => isValidationAttribute(attr)); + } + }) + ) { return true; } diff --git a/packages/server/package.json b/packages/server/package.json index 05f905117..064fd1ea1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/server", - "version": "2.7.5", + "version": "2.8.0", "displayName": "ZenStack Server-side Adapters", "description": "ZenStack server-side adapters", "homepage": "https://zenstack.dev", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index 47cf64447..ec5208c81 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "2.7.5", + "version": "2.8.0", "description": "ZenStack Test Tools", "main": "index.js", "private": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6771d2899..292cc7495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -392,8 +392,8 @@ importers: packages/runtime: dependencies: '@prisma/client': - specifier: 5.0.0 - 5.21.x - version: 5.21.0(prisma@5.16.1) + specifier: 5.0.0 - 5.22.x + version: 5.22.0(prisma@5.16.1) bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -523,7 +523,7 @@ importers: specifier: ^4.0.0 version: 4.0.1 prisma: - specifier: 5.0.0 - 5.21.x + specifier: 5.0.0 - 5.22.x version: 5.16.1 semver: specifier: ^7.5.2 @@ -575,8 +575,8 @@ importers: version: 1.5.0(zod@3.23.8) devDependencies: '@prisma/client': - specifier: 5.21.x - version: 5.21.0(prisma@5.16.1) + specifier: 5.22.x + version: 5.22.0(prisma@5.16.1) '@types/async-exit-hook': specifier: ^2.0.0 version: 2.0.2 @@ -627,11 +627,11 @@ importers: packages/sdk: dependencies: '@prisma/generator-helper': - specifier: 5.21.x - version: 5.21.0 + specifier: 5.22.x + version: 5.22.0 '@prisma/internals': - specifier: 5.21.x - version: 5.21.0 + specifier: 5.22.x + version: 5.22.0 '@zenstackhq/language': specifier: workspace:* version: link:../language/dist @@ -706,7 +706,7 @@ importers: version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9) '@nestjs/testing': specifier: ^10.3.7 - version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-express@10.3.9) + version: 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)) '@sveltejs/kit': specifier: 1.21.0 version: 1.21.0(svelte@4.2.18)(vite@5.3.2(@types/node@20.14.9)(terser@5.31.1)) @@ -2442,8 +2442,8 @@ packages: prisma: optional: true - '@prisma/client@5.21.0': - resolution: {integrity: sha512-Qf2YleB3dsCRXiKSQZ+j0aF5E+ojpfqF8ExXaNAWBbAhKapMduwNvgo13K2pEuTiQq8voG2aQPQnxjsO9fOpTQ==} + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} peerDependencies: prisma: '*' @@ -2457,8 +2457,8 @@ packages: '@prisma/debug@5.16.1': resolution: {integrity: sha512-JsNgZAg6BD9RInLSrg7ZYzo11N7cVvYArq3fHGSD89HSgtN0VDdjV6bib7YddbcO6snzjchTiLfjeTqBjtArVQ==} - '@prisma/debug@5.21.0': - resolution: {integrity: sha512-8gX68E36OKImh7LBz5fFIuTRLZgM1ObnDA8ukhC1kZvTK7k7Unti6pJe3ZiudzuFAxae06PV1rhq1u9DZbXVnQ==} + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} '@prisma/engines-version@5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48': resolution: {integrity: sha512-ip6pNkRo1UxWv+6toxNcYvItNYaqQjXdFNGJ+Nuk2eYtRoEdoF13wxo7/jsClJFFenMPVNVqXQDV0oveXnR1cA==} @@ -2466,8 +2466,8 @@ packages: '@prisma/engines-version@5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303': resolution: {integrity: sha512-HkT2WbfmFZ9WUPyuJHhkiADxazHg8Y4gByrTSVeb3OikP6tjQ7txtSUGu9OBOBH0C13dPKN2qqH12xKtHu/Hiw==} - '@prisma/engines-version@5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95': - resolution: {integrity: sha512-hfq7c8MnkhcZTY0bGXG6bV5Cr7OsnHLERNy4xkZy6rbpWnhtfjuj3yUVM4u1GKXd6uWmFbg0+HDw8KXTgTVepQ==} + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} '@prisma/engines@5.14.0': resolution: {integrity: sha512-lgxkKZ6IEygVcw6IZZUlPIfLQ9hjSYAtHjZ5r64sCLDgVzsPFCi2XBBJgzPMkOQ5RHzUD4E/dVdpn9+ez8tk1A==} @@ -2475,8 +2475,8 @@ packages: '@prisma/engines@5.16.1': resolution: {integrity: sha512-KkyF3eIUtBIyp5A/rJHCtwQO18OjpGgx18PzjyGcJDY/+vNgaVyuVd+TgwBgeq6NLdd1XMwRCI+58vinHsAdfA==} - '@prisma/engines@5.21.0': - resolution: {integrity: sha512-IBewQJiDnFiz39pl8kEIzmzV4RAoBPBD2DoLDntMMXObg1an90Dp+xeb1mmwrTgRDE3elu/LYxyVPEkKw9LZ7A==} + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} '@prisma/fetch-engine@5.14.0': resolution: {integrity: sha512-VrheA9y9DMURK5vu8OJoOgQpxOhas3qF0IBHJ8G/0X44k82kc8E0w98HCn2nhnbOOMwbWsJWXfLC2/F8n5u0gQ==} @@ -2484,14 +2484,14 @@ packages: '@prisma/fetch-engine@5.16.1': resolution: {integrity: sha512-oOkjaPU1lhcA/Rvr4GVfd1NLJBwExgNBE36Ueq7dr71kTMwy++a3U3oLd2ZwrV9dj9xoP6LjCcky799D9nEt4w==} - '@prisma/fetch-engine@5.21.0': - resolution: {integrity: sha512-nXKJrsxVKng6yjJzl7vBjrr3S34cOmWQ9SiGTo9xidVTmVSgg5GCTwDL4r2be8DE3RntqK5BW2LWQ1gF80eINw==} + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} '@prisma/generator-helper@5.14.0': resolution: {integrity: sha512-xVc71cmTnPZ0lnSs4FAY6Ta72vFJ3webrQwKMQ2ujr6hDG1VPIEf820T1TOS3ZZQd/OKigNKXnq3co8biz9/qw==} - '@prisma/generator-helper@5.21.0': - resolution: {integrity: sha512-+DnQBW6LwsDIpj6hDAPbWoQBwU5MP+qrDt/d5wFAhsMNqg56XgSj6ZbHEkaej58xIuae5Pg6XmzwRdBPg7f/jA==} + '@prisma/generator-helper@5.22.0': + resolution: {integrity: sha512-LwqcBQ5/QsuAaLNQZAIVIAJDJBMjHwMwn16e06IYx/3Okj/xEEfw9IvrqB2cJCl3b2mCBlh3eVH0w9WGmi4aHg==} '@prisma/get-platform@5.14.0': resolution: {integrity: sha512-/yAyBvcEjRv41ynZrhdrPtHgk47xLRRq/o5eWGcUpBJ1YrUZTYB8EoPiopnP7iQrMATK8stXQdPOoVlrzuTQZw==} @@ -2499,14 +2499,14 @@ packages: '@prisma/get-platform@5.16.1': resolution: {integrity: sha512-R4IKnWnMkR2nUAbU5gjrPehdQYUUd7RENFD2/D+xXTNhcqczp0N+WEGQ3ViyI3+6mtVcjjNIMdnUTNyu3GxIgA==} - '@prisma/get-platform@5.21.0': - resolution: {integrity: sha512-NAyaAcHJhs0IysGYJtM6Fm3ccEs/LkCZqz/8riVkkJswFrRtFV93jAUIVKWO/wj1Ca1gO7HaMd/tr6e/9Xmvww==} + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} '@prisma/internals@5.14.0': resolution: {integrity: sha512-s0JRNDmR2bvcyy0toz89jy7SbbjANAs4e9KCReNvSm5czctIaZzDf68tcOXdtH0G7m9mKhVhNPdS9lMky0DhWA==} - '@prisma/internals@5.21.0': - resolution: {integrity: sha512-sfMmfp9qke/imXNAL0z2gvHUZ7jPX19w7Nh4TVcvKqbTpgTk1iM1uIPQM8CIPOBEV09UwipHe3ln9yxWYqJ6gw==} + '@prisma/internals@5.22.0': + resolution: {integrity: sha512-Rsjw2ARB9VQzDczzEimUriSBdXmYG/Z5tNRer2IEwof/O8Q6A9cqV3oNVUpJ52TgWfQqMAq5K/KEf8LvvYLLOw==} '@prisma/prisma-schema-wasm@5.14.0-17.56ca112d5a19c9925b53af75c3c6b7ada97f9f85': resolution: {integrity: sha512-SX9vE9dGYBap6xsfJuDE5b2eoA6w1vKsx8QpLUHZR+kIV6GQVUYUboEfkvYYoBVen3s9LqxJ1+LjHL/1MqBZag==} @@ -2514,14 +2514,14 @@ packages: '@prisma/prisma-schema-wasm@5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48': resolution: {integrity: sha512-WeTmJ0mK8ALoKJUQFO+465k9lm1JWS4ODUg7akJq1wjgyDU1RTAzDFli8ESmNJlMVgJgoAd6jXmzcnoA0HT9Lg==} - '@prisma/prisma-schema-wasm@5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95': - resolution: {integrity: sha512-JrZMlaWugM4JW6uiB4WirFmfMMnHnCN3LGWVTb32x2R23jPB2IBYDT61BnW8PlackUZE635IDBT8mrZ7bRy1KQ==} + '@prisma/prisma-schema-wasm@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-WPNB7SgTxF/rSHMa5o5/9AIINy4oVnRhvUkRzqR4Nfp8Hu9Q2IyUptxuiDuzRVJdjJBRi/U82sHTxyiD3oBBhQ==} '@prisma/schema-files-loader@5.14.0': resolution: {integrity: sha512-n1QHR2C63dARKPZe0WPn7biybcBHzXe+BEmiHC5Drq9KPWnpmQtIfGpqm1ZKdvCZfcA5FF3wgpSMPK4LnB0obQ==} - '@prisma/schema-files-loader@5.21.0': - resolution: {integrity: sha512-snwMUFvyC+ukJWGU6xp9aGK2mXQDu8Zn6N3CB/2O73+qYfTaTvN91z0/Pk7xrUV8tdKihRk6XCyzNEODs4YfhQ==} + '@prisma/schema-files-loader@5.22.0': + resolution: {integrity: sha512-/TNAJXvMSk6mCgZa+gIBM6sp5OUQBnb7rbjiSQm88gvcSibxEuKkVV/2pT3RmQpEAn1yiabvS4+dOvIotYe3ww==} '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3986,7 +3986,7 @@ packages: engines: {node: '>= 14'} concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} @@ -4424,7 +4424,7 @@ packages: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} electron-to-chromium@1.4.814: resolution: {integrity: sha512-GVulpHjFu1Y9ZvikvbArHmAhZXtm3wHlpjTMcXNGKl4IQ4jMQjlnz8yMQYYqdLHKi/jEL2+CBC2akWVCoIGUdw==} @@ -6100,7 +6100,7 @@ packages: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} engines: {node: '>= 0.6'} merge-descriptors@1.0.1: @@ -8260,7 +8260,7 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} engines: {node: '>= 0.4.0'} uuid@10.0.0: @@ -10080,7 +10080,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/testing@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9)(@nestjs/platform-express@10.3.9)': + '@nestjs/testing@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.9))': dependencies: '@nestjs/common': 10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1) '@nestjs/core': 10.3.9(@nestjs/common@10.3.9(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.9)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -10608,7 +10608,7 @@ snapshots: optionalDependencies: prisma: 5.16.1 - '@prisma/client@5.21.0(prisma@5.16.1)': + '@prisma/client@5.22.0(prisma@5.16.1)': optionalDependencies: prisma: 5.16.1 @@ -10616,13 +10616,13 @@ snapshots: '@prisma/debug@5.16.1': {} - '@prisma/debug@5.21.0': {} + '@prisma/debug@5.22.0': {} '@prisma/engines-version@5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48': {} '@prisma/engines-version@5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303': {} - '@prisma/engines-version@5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95': {} + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} '@prisma/engines@5.14.0': dependencies: @@ -10638,12 +10638,12 @@ snapshots: '@prisma/fetch-engine': 5.16.1 '@prisma/get-platform': 5.16.1 - '@prisma/engines@5.21.0': + '@prisma/engines@5.22.0': dependencies: - '@prisma/debug': 5.21.0 - '@prisma/engines-version': 5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95 - '@prisma/fetch-engine': 5.21.0 - '@prisma/get-platform': 5.21.0 + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 '@prisma/fetch-engine@5.14.0': dependencies: @@ -10657,19 +10657,19 @@ snapshots: '@prisma/engines-version': 5.16.0-24.34ace0eb2704183d2c05b60b52fba5c43c13f303 '@prisma/get-platform': 5.16.1 - '@prisma/fetch-engine@5.21.0': + '@prisma/fetch-engine@5.22.0': dependencies: - '@prisma/debug': 5.21.0 - '@prisma/engines-version': 5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95 - '@prisma/get-platform': 5.21.0 + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 '@prisma/generator-helper@5.14.0': dependencies: '@prisma/debug': 5.14.0 - '@prisma/generator-helper@5.21.0': + '@prisma/generator-helper@5.22.0': dependencies: - '@prisma/debug': 5.21.0 + '@prisma/debug': 5.22.0 '@prisma/get-platform@5.14.0': dependencies: @@ -10679,9 +10679,9 @@ snapshots: dependencies: '@prisma/debug': 5.16.1 - '@prisma/get-platform@5.21.0': + '@prisma/get-platform@5.22.0': dependencies: - '@prisma/debug': 5.21.0 + '@prisma/debug': 5.22.0 '@prisma/internals@5.14.0': dependencies: @@ -10695,15 +10695,15 @@ snapshots: arg: 5.0.2 prompts: 2.4.2 - '@prisma/internals@5.21.0': + '@prisma/internals@5.22.0': dependencies: - '@prisma/debug': 5.21.0 - '@prisma/engines': 5.21.0 - '@prisma/fetch-engine': 5.21.0 - '@prisma/generator-helper': 5.21.0 - '@prisma/get-platform': 5.21.0 - '@prisma/prisma-schema-wasm': 5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95 - '@prisma/schema-files-loader': 5.21.0 + '@prisma/debug': 5.22.0 + '@prisma/engines': 5.22.0 + '@prisma/fetch-engine': 5.22.0 + '@prisma/generator-helper': 5.22.0 + '@prisma/get-platform': 5.22.0 + '@prisma/prisma-schema-wasm': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/schema-files-loader': 5.22.0 arg: 5.0.2 prompts: 2.4.2 @@ -10711,16 +10711,16 @@ snapshots: '@prisma/prisma-schema-wasm@5.14.0-25.e9771e62de70f79a5e1c604a2d7c8e2a0a874b48': {} - '@prisma/prisma-schema-wasm@5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95': {} + '@prisma/prisma-schema-wasm@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} '@prisma/schema-files-loader@5.14.0': dependencies: '@prisma/prisma-schema-wasm': 5.14.0-17.56ca112d5a19c9925b53af75c3c6b7ada97f9f85 fs-extra: 11.1.1 - '@prisma/schema-files-loader@5.21.0': + '@prisma/schema-files-loader@5.22.0': dependencies: - '@prisma/prisma-schema-wasm': 5.21.0-36.08713a93b99d58f31485621c634b04983ae01d95 + '@prisma/prisma-schema-wasm': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 fs-extra: 11.1.1 '@protobufjs/aspromise@1.1.2': {} diff --git a/script/test-scaffold.ts b/script/test-scaffold.ts index a4cf21968..ec1525be0 100644 --- a/script/test-scaffold.ts +++ b/script/test-scaffold.ts @@ -19,6 +19,6 @@ function run(cmd: string) { } run('npm init -y'); -run('npm i --no-audit --no-fund typescript prisma@5.21.x @prisma/client@5.21.x zod decimal.js @types/node'); +run('npm i --no-audit --no-fund typescript prisma@5.22.x @prisma/client@5.22.x zod decimal.js @types/node'); console.log('Test scaffold setup complete.'); diff --git a/tests/integration/test-run/package.json b/tests/integration/test-run/package.json index fa113f1e8..60914bc6f 100644 --- a/tests/integration/test-run/package.json +++ b/tests/integration/test-run/package.json @@ -10,9 +10,9 @@ "author": "", "license": "ISC", "dependencies": { - "@prisma/client": "5.21.x", + "@prisma/client": "5.22.x", "@zenstackhq/runtime": "file:../../../packages/runtime/dist", - "prisma": "5.21.x", + "prisma": "5.22.x", "react": "^18.2.0", "swr": "^1.3.0", "typescript": "^4.9.3", diff --git a/tests/integration/tests/cli/plugins.test.ts b/tests/integration/tests/cli/plugins.test.ts index 8c04eb67e..58237dfc1 100644 --- a/tests/integration/tests/cli/plugins.test.ts +++ b/tests/integration/tests/cli/plugins.test.ts @@ -75,7 +75,7 @@ describe('CLI Plugins Tests', () => { 'swr', '@tanstack/react-query@5.56.x', '@trpc/server', - '@prisma/client@5.21.x', + '@prisma/client@5.22.x', `${path.join(__dirname, '../../../../.build/zenstackhq-language-' + ver + '.tgz')}`, `${path.join(__dirname, '../../../../.build/zenstackhq-sdk-' + ver + '.tgz')}`, `${path.join(__dirname, '../../../../.build/zenstackhq-runtime-' + ver + '.tgz')}`, @@ -85,7 +85,7 @@ describe('CLI Plugins Tests', () => { const devDepPkgs = [ 'typescript', '@types/react', - 'prisma@5.21.x', + 'prisma@5.22.x', `${path.join(__dirname, '../../../../.build/zenstack-' + ver + '.tgz')}`, `${path.join(__dirname, '../../../../.build/zenstackhq-tanstack-query-' + ver + '.tgz')}`, `${path.join(__dirname, '../../../../.build/zenstackhq-swr-' + ver + '.tgz')}`, diff --git a/tests/integration/tests/enhancements/json/crud.test.ts b/tests/integration/tests/enhancements/json/crud.test.ts new file mode 100644 index 000000000..af3705a95 --- /dev/null +++ b/tests/integration/tests/enhancements/json/crud.test.ts @@ -0,0 +1,270 @@ +import { createPostgresDb, dropPostgresDb, loadSchema } from '@zenstackhq/testtools'; + +describe('Json field CRUD', () => { + let dbUrl: string; + let prisma: any; + + beforeEach(async () => { + dbUrl = await createPostgresDb('json-field-typing'); + }); + + afterEach(async () => { + if (prisma) { + await prisma.$disconnect(); + } + await dropPostgresDb(dbUrl); + }); + + it('works with simple cases', async () => { + const params = await loadSchema( + ` + type Address { + city String + } + + type Profile { + age Int + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + posts Post[] + } + + model Post { + id Int @id @default(autoincrement()) + title String + user User @relation(fields: [userId], references: [id]) + userId Int + } + `, + { + provider: 'postgresql', + dbUrl, + enhancements: ['validation'], + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // expecting object + await expect(db.user.create({ data: { profile: 1 } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: [{ age: 18 }] } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { myAge: 18 } } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { address: { city: 'NY' } } } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { age: 18, address: { x: 1 } } } })).toBeRejectedByPolicy(); + + await expect( + db.user.create({ data: { profile: { age: 18 }, posts: { create: { title: 'Post1' } } } }) + ).resolves.toMatchObject({ + profile: { age: 18 }, + }); + await expect( + db.user.create({ + data: { profile: { age: 20, address: { city: 'NY' } }, posts: { create: { title: 'Post1' } } }, + }) + ).resolves.toMatchObject({ + profile: { age: 20, address: { city: 'NY' } }, + }); + }); + + it('works with array', async () => { + const params = await loadSchema( + ` + type Address { + city String + } + + type Profile { + age Int + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profiles Profile[] @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // expecting array + await expect( + db.user.create({ data: { profiles: { age: 18, address: { city: 'NY' } } } }) + ).toBeRejectedByPolicy(); + + await expect( + db.user.create({ data: { profiles: [{ age: 18, address: { city: 'NY' } }] } }) + ).resolves.toMatchObject({ + profiles: expect.arrayContaining([expect.objectContaining({ age: 18, address: { city: 'NY' } })]), + }); + }); + + it('respects validation rules', async () => { + const params = await loadSchema( + ` + type Address { + city String @length(2, 10) + } + + type Profile { + age Int @gte(18) + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + foo Foo? + @@allow('all', true) + } + + model Foo { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int @unique + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // create + await expect(db.user.create({ data: { profile: { age: 10 } } })).toBeRejectedByPolicy(); + await expect(db.user.create({ data: { profile: { age: 18, address: { city: 'N' } } } })).toBeRejectedByPolicy(); + const u1 = await db.user.create({ data: { profile: { age: 18, address: { city: 'NY' } } } }); + expect(u1).toMatchObject({ + profile: { age: 18, address: { city: 'NY' } }, + }); + + // update + await expect(db.user.update({ where: { id: u1.id }, data: { profile: { age: 10 } } })).toBeRejectedByPolicy(); + await expect( + db.user.update({ where: { id: u1.id }, data: { profile: { age: 20, address: { city: 'B' } } } }) + ).toBeRejectedByPolicy(); + await expect( + db.user.update({ where: { id: u1.id }, data: { profile: { age: 20, address: { city: 'BJ' } } } }) + ).resolves.toMatchObject({ + profile: { age: 20, address: { city: 'BJ' } }, + }); + + // nested create + await expect(db.foo.create({ data: { user: { create: { profile: { age: 10 } } } } })).toBeRejectedByPolicy(); + await expect(db.foo.create({ data: { user: { create: { profile: { age: 20 } } } } })).toResolveTruthy(); + + // upsert + await expect( + db.user.upsert({ where: { id: 10 }, create: { id: 10, profile: { age: 10 } }, update: {} }) + ).toBeRejectedByPolicy(); + await expect( + db.user.upsert({ where: { id: 10 }, create: { id: 10, profile: { age: 20 } }, update: {} }) + ).toResolveTruthy(); + await expect( + db.user.upsert({ + where: { id: 10 }, + create: { id: 10, profile: { age: 20 } }, + update: { profile: { age: 10 } }, + }) + ).toBeRejectedByPolicy(); + await expect( + db.user.upsert({ + where: { id: 10 }, + create: { id: 10, profile: { age: 20 } }, + update: { profile: { age: 20 } }, + }) + ).toResolveTruthy(); + }); + + it('respects @default', async () => { + const params = await loadSchema( + ` + type Address { + state String + city String @default('Issaquah') + } + + type Profile { + createdAt DateTime @default(now()) + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + const db = params.enhance(); + + // default value + await expect(db.user.create({ data: { profile: { address: { state: 'WA' } } } })).resolves.toMatchObject({ + profile: { address: { state: 'WA', city: 'Issaquah' }, createdAt: expect.any(Date) }, + }); + + // override default + await expect( + db.user.create({ data: { profile: { address: { state: 'WA', city: 'Seattle' } } } }) + ).resolves.toMatchObject({ + profile: { address: { state: 'WA', city: 'Seattle' } }, + }); + }); + + it('works auth() in @default', async () => { + const params = await loadSchema( + ` + type NestedProfile { + userId Int @default(auth().id) + } + + type Profile { + ownerId Int @default(auth().id) + nested NestedProfile + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + dbUrl, + } + ); + + prisma = params.prisma; + + const db = params.enhance({ id: 1 }); + const u1 = await db.user.create({ data: { profile: { nested: {} } } }); + expect(u1.profile.ownerId).toBe(1); + expect(u1.profile.nested.userId).toBe(1); + + const u2 = await db.user.create({ data: { profile: { ownerId: 2, nested: { userId: 3 } } } }); + expect(u2.profile.ownerId).toBe(2); + expect(u2.profile.nested.userId).toBe(3); + }); +}); diff --git a/tests/integration/tests/enhancements/json/typing.test.ts b/tests/integration/tests/enhancements/json/typing.test.ts new file mode 100644 index 000000000..9681bf015 --- /dev/null +++ b/tests/integration/tests/enhancements/json/typing.test.ts @@ -0,0 +1,233 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('JSON field typing', () => { + it('works with simple field', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + posts Post[] + @@allow('all', true) + } + + model Post { + id Int @id @default(autoincrement()) + title String + user User @relation(fields: [userId], references: [id]) + userId Int + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profile: { age: 18 }, posts: { create: { title: 'Post1' }} } }); + console.log(u.profile.age); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profile.age); + const u2 = await db.user.findMany({include: { posts: true }}); + console.log(u2[0].profile.age); +} + `, + }, + ], + } + ); + }); + + it('works with optional field', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile? @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profile: { age: 18 } } }); + console.log(u.profile?.age); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profile?.age); + const u2 = await db.user.findMany(); + console.log(u2[0].profile?.age); +} + `, + }, + ], + } + ); + }); + + it('works with array field', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profiles Profile[] @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profiles: [{ age: 18 }] } }); + console.log(u.profiles[0].age); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profiles[0].age); + const u2 = await db.user.findMany(); + console.log(u2[0].profiles[0].age); +} + `, + }, + ], + } + ); + }); + + it('works with type nesting', async () => { + await loadSchema( + ` + type Profile { + age Int @gt(0) + address Address? + } + + type Address { + city String + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import { enhance } from '.zenstack/enhance'; +import { PrismaClient } from '@prisma/client'; +const prisma = new PrismaClient(); +const db = enhance(prisma); + +async function main() { + const u = await db.user.create({ data: { profile: { age: 18, address: { city: 'Issaquah' } } } }); + console.log(u.profile.address?.city); + const u1 = await db.user.findUnique({ where: { id: u.id } }); + console.log(u1?.profile.address?.city); + const u2 = await db.user.findMany(); + console.log(u2[0].profile.address?.city); + await db.user.create({ data: { profile: { age: 20 } } }); +} + `, + }, + ], + } + ); + }); + + it('type coverage', async () => { + await loadSchema( + ` + type Profile { + boolean Boolean + bigint BigInt + int Int + float Float + decimal Decimal + string String + bytes Bytes + dateTime DateTime + json Json + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + `, + { + provider: 'postgresql', + pushDb: false, + compile: true, + extraSourceFiles: [ + { + name: 'main.ts', + content: ` +import type { Profile } from '.zenstack/models'; +import { Prisma } from '@prisma/client'; + +async function main() { + const profile: Profile = { + boolean: true, + bigint: BigInt(9007199254740991), + int: 100, + float: 1.23, + decimal: new Prisma.Decimal(1.2345), + string: 'string', + bytes: new Uint8Array([0, 1, 2, 3]), + dateTime: new Date(), + json: { a: 1 }, + } +} + `, + }, + ], + } + ); + }); +}); diff --git a/tests/integration/tests/enhancements/json/validation.test.ts b/tests/integration/tests/enhancements/json/validation.test.ts new file mode 100644 index 000000000..2643056fe --- /dev/null +++ b/tests/integration/tests/enhancements/json/validation.test.ts @@ -0,0 +1,39 @@ +import { loadModelWithError } from '@zenstackhq/testtools'; + +describe('JSON field typing', () => { + it('is only supported by postgres', async () => { + await expect( + loadModelWithError( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + @@allow('all', true) + } + ` + ) + ).resolves.toContain('Custom-typed field is only supported with "postgresql" provider'); + }); + + it('requires field to have @json attribute', async () => { + await expect( + loadModelWithError( + ` + type Profile { + age Int @gt(0) + } + + model User { + id Int @id @default(autoincrement()) + profile Profile + @@allow('all', true) + } + ` + ) + ).resolves.toContain('Custom-typed field must have @json attribute'); + }); +}); diff --git a/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts b/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts index 59a3f68c0..91a385db0 100644 --- a/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts +++ b/tests/integration/tests/enhancements/with-delegate/enhanced-client.test.ts @@ -1407,4 +1407,101 @@ describe('Polymorphism Test', () => { r = await db.post.findFirst({ include: { comments: true } }); expect(r).toMatchObject({ ...post, comments: [comment] }); }); + + it('works with one-to-one self relation', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + successorId Int? @unique + successor User? @relation("BlogOwnerHistory", fields: [successorId], references: [id]) + predecessor User? @relation("BlogOwnerHistory") + type String + @@delegate(type) + } + + model Person extends User { + } + + model Organization extends User { + } + `, + { enhancements: ['delegate'] } + ); + + const db = enhance(); + const u1 = await db.person.create({ data: {} }); + const u2 = await db.organization.create({ + data: { predecessor: { connect: { id: u1.id } } }, + include: { predecessor: true }, + }); + expect(u2).toMatchObject({ id: u2.id, predecessor: { id: u1.id } }); + const foundP1 = await db.person.findUnique({ where: { id: u1.id }, include: { successor: true } }); + expect(foundP1).toMatchObject({ id: u1.id, successor: { id: u2.id } }); + }); + + it('works with one-to-many self relation', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + name String? + parentId Int? + parent User? @relation("ParentChild", fields: [parentId], references: [id]) + children User[] @relation("ParentChild") + type String + @@delegate(type) + } + + model Person extends User { + } + + model Organization extends User { + } + `, + { enhancements: ['delegate'] } + ); + + const db = enhance(); + const u1 = await db.person.create({ data: {} }); + const u2 = await db.organization.create({ + data: { parent: { connect: { id: u1.id } } }, + include: { parent: true }, + }); + expect(u2).toMatchObject({ id: u2.id, parent: { id: u1.id } }); + const foundP1 = await db.person.findUnique({ where: { id: u1.id }, include: { children: true } }); + expect(foundP1).toMatchObject({ id: u1.id, children: [{ id: u2.id }] }); + }); + + it('works with many-to-many self relation', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + name String? + followedBy User[] @relation("UserFollows") + following User[] @relation("UserFollows") + type String + @@delegate(type) + } + + model Person extends User { + } + + model Organization extends User { + } + `, + { enhancements: ['delegate'] } + ); + + const db = enhance(); + const u1 = await db.person.create({ data: {} }); + const u2 = await db.organization.create({ + data: { following: { connect: { id: u1.id } } }, + include: { following: true }, + }); + expect(u2).toMatchObject({ id: u2.id, following: [{ id: u1.id }] }); + const foundP1 = await db.person.findUnique({ where: { id: u1.id }, include: { followedBy: true } }); + expect(foundP1).toMatchObject({ id: u1.id, followedBy: [{ id: u2.id }] }); + }); }); diff --git a/tests/integration/tests/enhancements/with-policy/checker.test.ts b/tests/integration/tests/enhancements/with-policy/checker.test.ts index e4ca61fad..a109c3ef6 100644 --- a/tests/integration/tests/enhancements/with-policy/checker.test.ts +++ b/tests/integration/tests/enhancements/with-policy/checker.test.ts @@ -357,7 +357,7 @@ describe('Permission checker', () => { await expect(db.model.check({ operation: 'update', where: { x: 1, y: 1 } })).toResolveFalsy(); }); - it('field condition unsolvable', async () => { + it('field condition unsatisfiable', async () => { const { enhance } = await load( ` model Model { @@ -649,4 +649,115 @@ describe('Permission checker', () => { await expect(db.model.check({ operation: 'read', where: { value: 1 } })).toResolveTruthy(); await expect(db.model.check({ operation: 'read', where: { value: 2 } })).toResolveTruthy(); }); + + it('supports policy delegation simple', async () => { + const { enhance } = await load( + ` + model User { + id Int @id @default(autoincrement()) + foo Foo[] + } + + model Foo { + id Int @id @default(autoincrement()) + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + model Model? + @@allow('read', auth().id == ownerId) + @@allow('create', auth().id != ownerId) + @@allow('update', auth() == owner) + } + + model Model { + id Int @id @default(autoincrement()) + foo Foo @relation(fields: [fooId], references: [id]) + fooId Int @unique + @@allow('all', check(foo)) + } + `, + { preserveTsFiles: true } + ); + + await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'read' })).toResolveTruthy(); + + await expect(enhance().model.check({ operation: 'create' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'create' })).toResolveTruthy(); + + await expect(enhance().model.check({ operation: 'update' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'update' })).toResolveTruthy(); + + await expect(enhance().model.check({ operation: 'delete' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'delete' })).toResolveFalsy(); + }); + + it('supports policy delegation explicit', async () => { + const { enhance } = await load( + ` + model Foo { + id Int @id @default(autoincrement()) + model Model? + @@allow('all', true) + @@deny('update', true) + } + + model Model { + id Int @id @default(autoincrement()) + foo Foo @relation(fields: [fooId], references: [id]) + fooId Int @unique + @@allow('read', check(foo, 'update')) + } + `, + { preserveTsFiles: true } + ); + + await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy(); + }); + + it('supports policy delegation combined', async () => { + const { enhance } = await load( + ` + model User { + id Int @id @default(autoincrement()) + foo Foo[] + } + + model Foo { + id Int @id @default(autoincrement()) + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + model Model? + @@allow('read', auth().id == ownerId) + @@allow('create', auth().id != ownerId) + @@allow('update', auth() == owner) + } + + model Model { + id Int @id @default(autoincrement()) + foo Foo @relation(fields: [fooId], references: [id]) + fooId Int @unique + value Int + @@allow('all', check(foo) && value > 0) + @@deny('update', check(foo) && value == 1) + } + `, + { preserveTsFiles: true } + ); + + await expect(enhance().model.check({ operation: 'read' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'read' })).toResolveTruthy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'read', where: { value: 1 } })).toResolveTruthy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'read', where: { value: 0 } })).toResolveFalsy(); + + await expect(enhance().model.check({ operation: 'create' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'create' })).toResolveTruthy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'create', where: { value: 1 } })).toResolveTruthy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'create', where: { value: 0 } })).toResolveFalsy(); + + await expect(enhance().model.check({ operation: 'update' })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'update' })).toResolveTruthy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 2 } })).toResolveTruthy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 0 } })).toResolveFalsy(); + await expect(enhance({ id: 1 }).model.check({ operation: 'update', where: { value: 1 } })).toResolveFalsy(); + }); }); diff --git a/tests/integration/tests/frameworks/nextjs/test-project/package.json b/tests/integration/tests/frameworks/nextjs/test-project/package.json index 8ead9a366..95a3bac9e 100644 --- a/tests/integration/tests/frameworks/nextjs/test-project/package.json +++ b/tests/integration/tests/frameworks/nextjs/test-project/package.json @@ -9,7 +9,7 @@ "lint": "next lint" }, "dependencies": { - "@prisma/client": "5.21.x", + "@prisma/client": "5.22.x", "@types/node": "18.11.18", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", @@ -26,6 +26,6 @@ "@zenstackhq/swr": "../../../../../../../packages/plugins/swr/dist" }, "devDependencies": { - "prisma": "5.21.x" + "prisma": "5.22.x" } } diff --git a/tests/integration/tests/frameworks/trpc/test-project/package.json b/tests/integration/tests/frameworks/trpc/test-project/package.json index a23a84e24..676f3f165 100644 --- a/tests/integration/tests/frameworks/trpc/test-project/package.json +++ b/tests/integration/tests/frameworks/trpc/test-project/package.json @@ -9,7 +9,7 @@ "lint": "next lint" }, "dependencies": { - "@prisma/client": "5.21.x", + "@prisma/client": "5.22.x", "@tanstack/react-query": "^4.22.4", "@trpc/client": "^10.34.0", "@trpc/next": "^10.34.0", @@ -31,6 +31,6 @@ "@zenstackhq/trpc": "../../../../../../../packages/plugins/trpc/dist" }, "devDependencies": { - "prisma": "5.21.x" + "prisma": "5.22.x" } } diff --git a/tests/integration/tests/plugins/zod.test.ts b/tests/integration/tests/plugins/zod.test.ts index 5af7f4077..aba94261c 100644 --- a/tests/integration/tests/plugins/zod.test.ts +++ b/tests/integration/tests/plugins/zod.test.ts @@ -1025,4 +1025,60 @@ describe('Zod plugin tests', () => { ) ).rejects.toThrow(/Invalid mode/); }); + + it('supports type def', async () => { + const { zodSchemas } = await loadSchema( + ` + datasource db { + provider = 'postgresql' + url = env('DATABASE_URL') + } + + generator js { + provider = 'prisma-client-js' + } + + plugin zod { + provider = '@core/zod' + } + + type Address { + city String @length(2, 20) + } + + type Profile { + age Int @gte(18) + address Address? + } + + model User { + id Int @id @default(autoincrement()) + profile Profile @json + } + `, + { addPrelude: false, pushDb: false } + ); + + const schemas = zodSchemas.models; + + let parsed = schemas.ProfileSchema.safeParse({ age: 18, address: { city: 'NY' } }); + expect(parsed.success).toBeTruthy(); + expect(parsed.data).toEqual({ age: 18, address: { city: 'NY' } }); + + expect(schemas.ProfileSchema.safeParse({ age: 18 })).toMatchObject({ success: true }); + expect(schemas.ProfileSchema.safeParse({ age: 10 })).toMatchObject({ success: false }); + expect(schemas.ProfileSchema.safeParse({ address: { city: 'NY' } })).toMatchObject({ success: false }); + expect(schemas.ProfileSchema.safeParse({ address: { age: 18, city: 'N' } })).toMatchObject({ success: false }); + + expect(schemas.UserSchema.safeParse({ id: 1, profile: { age: 18 } })).toMatchObject({ success: true }); + expect(schemas.UserSchema.safeParse({ id: 1, profile: { age: 10 } })).toMatchObject({ success: false }); + + const objectSchemas = zodSchemas.objects; + expect(objectSchemas.UserCreateInputObjectSchema.safeParse({ profile: { age: 18 } })).toMatchObject({ + success: true, + }); + expect(objectSchemas.UserCreateInputObjectSchema.safeParse({ profile: { age: 10 } })).toMatchObject({ + success: false, + }); + }); }); diff --git a/tests/regression/tests/issue-1755.test.ts b/tests/regression/tests/issue-1755.test.ts new file mode 100644 index 000000000..9e41f7c6e --- /dev/null +++ b/tests/regression/tests/issue-1755.test.ts @@ -0,0 +1,61 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1755', () => { + it('regression', async () => { + const { enhance } = await loadSchema( + ` + model User { + id Int @id @default(autoincrement()) + contents Content[] + } + + model Content { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + userId Int + contentType String + @@delegate(contentType) + } + + model Post extends Content { + title String + } + + model Video extends Content { + name String + duration Int + } + `, + { enhancements: ['delegate'] } + ); + + const db = enhance(); + const user = await db.user.create({ data: {} }); + const now = Date.now(); + await db.post.create({ + data: { title: 'post1', createdAt: new Date(now - 1000), user: { connect: { id: user.id } } }, + }); + await db.post.create({ + data: { title: 'post2', createdAt: new Date(now), user: { connect: { id: user.id } } }, + }); + + // scalar orderBy + await expect(db.post.findFirst({ orderBy: { createdAt: 'desc' } })).resolves.toMatchObject({ + title: 'post2', + }); + + // array orderBy + await expect(db.post.findFirst({ orderBy: [{ createdAt: 'desc' }] })).resolves.toMatchObject({ + title: 'post2', + }); + + // nested orderBy + await expect( + db.user.findFirst({ include: { contents: { orderBy: [{ createdAt: 'desc' }] } } }) + ).resolves.toMatchObject({ + id: user.id, + contents: [{ title: 'post2' }, { title: 'post1' }], + }); + }); +}); diff --git a/tests/regression/tests/issue-1786.test.ts b/tests/regression/tests/issue-1786.test.ts new file mode 100644 index 000000000..ae37297de --- /dev/null +++ b/tests/regression/tests/issue-1786.test.ts @@ -0,0 +1,48 @@ +import { loadSchema } from '@zenstackhq/testtools'; + +describe('issue 1786', () => { + it('regression', async () => { + await loadSchema( + ` + model User { + id String @id @default(cuid()) + email String @unique @email @length(6, 32) + password String @password @omit + contents Content[] + + // everybody can signup + @@allow('create', true) + + // full access by self + @@allow('all', auth() == this) + } + + abstract model BaseContent { + published Boolean @default(false) + + @@index([published]) + } + + model Content extends BaseContent { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + owner User @relation(fields: [ownerId], references: [id]) + ownerId String + contentType String + + @@delegate(contentType) + } + + model Post extends Content { + title String + } + + model Video extends Content { + name String + duration Int + } + ` + ); + }); +});
Benjamin Zecirovic
Benjamin Zecirovic
Ulric
Ulric
Fabian Jocks
Fabian Jocks