From 3a0550748a595b67f9ee8245d3c8a9195ec7c021 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:35:34 +0800 Subject: [PATCH 1/3] feat: implementing mixin --- .prettierignore | 2 +- TODO.md | 9 +- packages/cli/src/actions/generate.ts | 4 +- packages/cli/test/ts-schema-gen.test.ts | 85 ++++ packages/language/package.json | 11 + packages/language/res/stdlib.zmodel | 62 +-- packages/language/src/ast.ts | 9 +- packages/language/src/generated/ast.ts | 258 +++++------- packages/language/src/generated/grammar.ts | 384 +++++++----------- packages/language/src/utils.ts | 106 +++-- .../attribute-application-validator.ts | 74 ++-- packages/language/src/validators/common.ts | 6 +- .../src/validators/datamodel-validator.ts | 99 ++--- .../src/validators/expression-validator.ts | 14 +- .../function-invocation-validator.ts | 12 +- packages/language/src/zmodel-linker.ts | 32 +- packages/language/src/zmodel-scope.ts | 40 +- .../language/src/zmodel-workspace-manager.ts | 87 +++- packages/language/src/zmodel.langium | 41 +- packages/language/test/mixin.test.ts | 109 +++++ packages/language/test/utils.ts | 30 ++ packages/language/tsup.config.ts | 1 + packages/language/vitest.config.ts | 4 + packages/runtime/src/client/crud-types.ts | 98 +++-- .../src/client/helpers/schema-db-pusher.ts | 7 +- packages/runtime/src/client/query-builder.ts | 10 +- packages/runtime/test/client-api/find.test.ts | 12 +- .../runtime/test/client-api/mixin.test.ts | 80 ++++ packages/runtime/test/schemas/todo.zmodel | 2 +- packages/runtime/test/test-schema/models.ts | 3 +- packages/runtime/test/test-schema/schema.ts | 49 ++- .../runtime/test/test-schema/schema.zmodel | 22 +- packages/runtime/test/typing/models.ts | 4 +- packages/runtime/test/typing/schema.ts | 20 + .../runtime/test/typing/typing-test.zmodel | 9 + packages/runtime/test/typing/verify-typing.ts | 17 +- packages/runtime/test/utils.ts | 20 +- packages/sdk/src/model-utils.ts | 116 +----- .../sdk/src/prisma/prisma-schema-generator.ts | 50 +-- packages/sdk/src/schema/schema.ts | 81 ++-- packages/sdk/src/ts-schema-generator.ts | 151 +++++-- packages/sdk/src/zmodel-code-generator.ts | 33 +- pnpm-lock.yaml | 3 + samples/blog/zenstack/models.ts | 3 +- samples/blog/zenstack/schema.prisma | 44 -- samples/blog/zenstack/schema.ts | 31 ++ samples/blog/zenstack/schema.zmodel | 19 +- turbo.json | 2 +- 48 files changed, 1341 insertions(+), 1024 deletions(-) create mode 100644 packages/language/test/mixin.test.ts create mode 100644 packages/language/test/utils.ts create mode 100644 packages/language/vitest.config.ts create mode 100644 packages/runtime/test/client-api/mixin.test.ts delete mode 100644 samples/blog/zenstack/schema.prisma diff --git a/.prettierignore b/.prettierignore index 03571ca3..a58f5b41 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ packages/language/src/generated/** -**/schema.ts +**/test/**/schema.ts diff --git a/TODO.md b/TODO.md index 35c34c71..8303aa4b 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,8 @@ - [x] validate - [ ] format - [ ] db seed +- [ ] ZModel + - [ ] View support - [ ] ORM - [x] Create - [x] Input validation @@ -56,13 +58,14 @@ - [x] Aggregate - [x] Group by - [x] Raw queries - - [ ] Transactions + - [x] Transactions - [x] Interactive transaction - [x] Sequential transaction - [ ] Extensions - [x] Query builder API - [x] Computed fields - [x] Prisma client extension + - [ ] Custom procedures - [ ] Misc - [x] JSDoc for CRUD methods - [x] Cache validation schemas @@ -71,7 +74,7 @@ - [x] Many-to-many relation - [ ] Empty AND/OR/NOT behavior - [?] Logging - - [ ] Error system + - [x] Error system - [x] Custom table name - [x] Custom field name - [ ] Strict undefined checks @@ -79,6 +82,8 @@ - [ ] Benchmark - [ ] Plugin - [ ] Post-mutation hooks should be called after transaction is committed +- [x] TypeDef and mixin +- [ ] Strongly typed JSON - [ ] Polymorphism - [ ] Validation - [ ] Access Policy diff --git a/packages/cli/src/actions/generate.ts b/packages/cli/src/actions/generate.ts index 1729bdf3..2a174cad 100644 --- a/packages/cli/src/actions/generate.ts +++ b/packages/cli/src/actions/generate.ts @@ -17,6 +17,8 @@ type Options = { * CLI action for generating code from schema */ export async function run(options: Options) { + const start = Date.now(); + const schemaFile = getSchemaFile(options.schema); const model = await loadSchemaDocument(schemaFile); @@ -40,7 +42,7 @@ export async function run(options: Options) { } if (!options.silent) { - console.log(colors.green('Generation completed successfully.')); + console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.`)); console.log(`You can now create a ZenStack client with it. \`\`\`ts diff --git a/packages/cli/test/ts-schema-gen.test.ts b/packages/cli/test/ts-schema-gen.test.ts index 48692d9a..2ec04048 100644 --- a/packages/cli/test/ts-schema-gen.test.ts +++ b/packages/cli/test/ts-schema-gen.test.ts @@ -181,4 +181,89 @@ model Post { }, }); }); + + it('merges fields and attributes from mixins', async () => { + const { schema } = await generateTsSchema(` +type Timestamped { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +type Named { + name String + @@unique([name]) +} + +model User with Timestamped Named { + id String @id @default(uuid()) + email String @unique +} + `); + expect(schema).toMatchObject({ + models: { + User: { + fields: { + id: { type: 'String' }, + email: { type: 'String' }, + createdAt: { + type: 'DateTime', + default: expect.objectContaining({ function: 'now', kind: 'call' }), + }, + updatedAt: { type: 'DateTime', updatedAt: true }, + name: { type: 'String' }, + }, + uniqueFields: expect.objectContaining({ + name: { type: 'String' }, + }), + }, + }, + }); + }); + + it('generates type definitions', async () => { + const { schema } = await generateTsSchema(` +type Base { + name String + @@meta('foo', 'bar') +} + +type Address with Base { + street String + city String +} + `); + expect(schema).toMatchObject({ + typeDefs: { + Base: { + fields: { + name: { type: 'String' }, + }, + attributes: [ + { + name: '@@meta', + args: [ + { name: 'name', value: { kind: 'literal', value: 'foo' } }, + { name: 'value', value: { kind: 'literal', value: 'bar' } }, + ], + }, + ], + }, + Address: { + fields: { + street: { type: 'String' }, + city: { type: 'String' }, + }, + attributes: [ + { + name: '@@meta', + args: [ + { name: 'name', value: { kind: 'literal', value: 'foo' } }, + { name: 'value', value: { kind: 'literal', value: 'bar' } }, + ], + }, + ], + }, + }, + }); + }); }); diff --git a/packages/language/package.json b/packages/language/package.json index bb7da03f..84248adb 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -37,6 +37,16 @@ "default": "./dist/ast.cjs" } }, + "./utils": { + "import": { + "types": "./dist/utils.d.ts", + "default": "./dist/utils.js" + }, + "require": { + "types": "./dist/utils.d.cts", + "default": "./dist/utils.cjs" + } + }, "./package.json": { "import": "./package.json", "require": "./package.json" @@ -51,6 +61,7 @@ "@types/pluralize": "^0.0.33", "@zenstackhq/eslint-config": "workspace:*", "@zenstackhq/typescript-config": "workspace:*", + "@zenstackhq/common-helpers": "workspace:*", "langium-cli": "catalog:" }, "volta": { diff --git a/packages/language/res/stdlib.zmodel b/packages/language/res/stdlib.zmodel index f7841166..52f300bb 100644 --- a/packages/language/res/stdlib.zmodel +++ b/packages/language/res/stdlib.zmodel @@ -199,11 +199,6 @@ function currentOperation(casing: String?): String { */ 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. */ @@ -237,13 +232,13 @@ attribute @@@once() * @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc. * @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true. */ -attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@supportTypeDef @@@once +attribute @id(map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once /** * Defines a default value for a field. * @param value: An expression (e.g. 5, true, now(), auth()). */ -attribute @default(_ value: ContextType, map: String?) @@@prisma @@@supportTypeDef +attribute @default(_ value: ContextType, map: String?) @@@prisma /** * Defines a unique constraint for this field. @@ -264,7 +259,7 @@ attribute @unique(map: String?, length: Int?, sort: SortOrder?, clustered: Boole * @param sort: Allows you to specify in what order the entries of the ID are stored in the database. The available options are Asc and Desc. * @param clustered: Defines whether the ID is clustered or non-clustered. Defaults to true. */ -attribute @@id(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma +attribute @@id(_ fields: FieldReference[], name: String?, map: String?, length: Int?, sort: SortOrder?, clustered: Boolean?) @@@prisma @@@once /** * Defines a compound unique constraint for the specified fields. @@ -560,82 +555,82 @@ attribute @omit() /** * Validates length of a string field. */ -attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @length(_ min: Int?, _ max: Int?, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value starts with the given text. */ -attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @startsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value ends with the given text. */ -attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @endsWith(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value contains the given text. */ -attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @contains(_ text: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value matches a regex. */ -attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @regex(_ regex: String, _ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value is a valid email address. */ -attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @email(_ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value is a valid ISO datetime. */ -attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @datetime(_ message: String?) @@@targetField([StringField]) @@@validation /** * Validates a string field value is a valid url. */ -attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @url(_ message: String?) @@@targetField([StringField]) @@@validation /** * Trims whitespaces from the start and end of the string. */ -attribute @trim() @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @trim() @@@targetField([StringField]) @@@validation /** * Transform entire string toLowerCase. */ -attribute @lower() @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @lower() @@@targetField([StringField]) @@@validation /** * Transform entire string toUpperCase. */ -attribute @upper() @@@targetField([StringField]) @@@validation @@@supportTypeDef +attribute @upper() @@@targetField([StringField]) @@@validation /** * Validates a number field is greater than the given value. */ -attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @gt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates a number field is greater than or equal to the given value. */ -attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @gte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates a number field is less than the given value. */ -attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @lt(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates a number field is less than or equal to the given value. */ -attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation @@@supportTypeDef +attribute @lte(_ value: Int, _ message: String?) @@@targetField([IntField, FloatField, DecimalField]) @@@validation /** * Validates the entity with a complex condition. */ -attribute @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) @@@validation @@@supportTypeDef +attribute @@validate(_ value: Boolean, _ message: String?, _ path: String[]?) @@@validation /** * Validates length of a string field. @@ -706,7 +701,7 @@ function raw(value: String): Any { /** * Marks a field to be strong-typed JSON. */ -attribute @json() @@@targetField([TypeDefField]) +attribute @json() @@@targetField([TypeDefField]) @@@deprecated('The "@json" attribute is not needed anymore. ZenStack will automatically use JSON to store typed fields.') /** * Marks a field to be computed. @@ -723,4 +718,19 @@ function auth(): Any { * Used to specify the model for resolving `auth()` function call in access policies. A Zmodel * can have at most one model with this attribute. By default, the model named "User" is used. */ -attribute @@auth() @@@supportTypeDef +attribute @@auth() + +/** + * Attaches arbitrary metadata to a model or type def. + */ +attribute @@meta(_ name: String, _ value: Any) + +/** + * Attaches arbitrary metadata to a field. + */ +attribute @meta(_ name: String, _ value: Any) + +/** + * Marks an attribute as deprecated. + */ +attribute @@@deprecated(_ message: String) diff --git a/packages/language/src/ast.ts b/packages/language/src/ast.ts index 4ba343b7..c301eac4 100644 --- a/packages/language/src/ast.ts +++ b/packages/language/src/ast.ts @@ -46,7 +46,7 @@ declare module './ast' { $resolvedParam?: AttributeParam; } - interface DataModelField { + interface DataField { $inheritedFrom?: DataModel; } @@ -55,15 +55,10 @@ declare module './ast' { } export interface DataModel { - /** - * Indicates whether the model is already merged with the base types - */ - $baseMerged?: boolean; - /** * All fields including those marked with `@ignore` */ - $allFields?: DataModelField[]; + $allFields?: DataField[]; } } diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 0555c16d..1cc1205c 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -62,12 +62,12 @@ export type ZModelKeywordNames = | "attribute" | "datasource" | "enum" - | "extends" | "false" | "function" | "generator" | "import" | "in" + | "inherits" | "model" | "mutation" | "null" @@ -77,6 +77,7 @@ export type ZModelKeywordNames = | "true" | "type" | "view" + | "with" | "{" | "||" | "}"; @@ -133,7 +134,7 @@ export function isLiteralExpr(item: unknown): item is LiteralExpr { return reflection.isInstance(item, LiteralExpr); } -export type MemberAccessTarget = DataModelField | TypeDefField; +export type MemberAccessTarget = DataField; export const MemberAccessTarget = 'MemberAccessTarget'; @@ -141,7 +142,7 @@ export function isMemberAccessTarget(item: unknown): item is MemberAccessTarget return reflection.isInstance(item, MemberAccessTarget); } -export type ReferenceTarget = DataModelField | EnumField | FunctionParam | TypeDefField; +export type ReferenceTarget = DataField | EnumField | FunctionParam; export const ReferenceTarget = 'ReferenceTarget'; @@ -169,14 +170,6 @@ export function isTypeDeclaration(item: unknown): item is TypeDeclaration { return reflection.isInstance(item, TypeDeclaration); } -export type TypeDefFieldTypes = Enum | TypeDef; - -export const TypeDefFieldTypes = 'TypeDefFieldTypes'; - -export function isTypeDefFieldTypes(item: unknown): item is TypeDefFieldTypes { - return reflection.isInstance(item, TypeDefFieldTypes); -} - export interface Argument extends langium.AstNode { readonly $container: InvocationExpr; readonly $type: 'Argument'; @@ -217,7 +210,7 @@ export function isAttribute(item: unknown): item is Attribute { } export interface AttributeArg extends langium.AstNode { - readonly $container: DataModelAttribute | DataModelFieldAttribute | InternalAttribute; + readonly $container: DataFieldAttribute | DataModelAttribute | InternalAttribute; readonly $type: 'AttributeArg'; name?: RegularID; value: Expression; @@ -337,79 +330,79 @@ export function isConfigInvocationExpr(item: unknown): item is ConfigInvocationE return reflection.isInstance(item, ConfigInvocationExpr); } -export interface DataModel extends langium.AstNode { - readonly $container: Model; - readonly $type: 'DataModel'; - attributes: Array; +export interface DataField extends langium.AstNode { + readonly $container: DataModel | TypeDef; + readonly $type: 'DataField'; + attributes: Array; comments: Array; - fields: Array; - isAbstract: boolean; - isView: boolean; - name: RegularID; - superTypes: Array>; + name: RegularIDWithTypeNames; + type: DataFieldType; } -export const DataModel = 'DataModel'; +export const DataField = 'DataField'; -export function isDataModel(item: unknown): item is DataModel { - return reflection.isInstance(item, DataModel); +export function isDataField(item: unknown): item is DataField { + return reflection.isInstance(item, DataField); } -export interface DataModelAttribute extends langium.AstNode { - readonly $container: DataModel | Enum | TypeDef; - readonly $type: 'DataModelAttribute'; +export interface DataFieldAttribute extends langium.AstNode { + readonly $container: DataField | EnumField; + readonly $type: 'DataFieldAttribute'; args: Array; decl: langium.Reference; } -export const DataModelAttribute = 'DataModelAttribute'; +export const DataFieldAttribute = 'DataFieldAttribute'; -export function isDataModelAttribute(item: unknown): item is DataModelAttribute { - return reflection.isInstance(item, DataModelAttribute); +export function isDataFieldAttribute(item: unknown): item is DataFieldAttribute { + return reflection.isInstance(item, DataFieldAttribute); } -export interface DataModelField extends langium.AstNode { - readonly $container: DataModel; - readonly $type: 'DataModelField'; - attributes: Array; - comments: Array; - name: RegularIDWithTypeNames; - type: DataModelFieldType; +export interface DataFieldType extends langium.AstNode { + readonly $container: DataField; + readonly $type: 'DataFieldType'; + array: boolean; + optional: boolean; + reference?: langium.Reference; + type?: BuiltinType; + unsupported?: UnsupportedFieldType; } -export const DataModelField = 'DataModelField'; +export const DataFieldType = 'DataFieldType'; -export function isDataModelField(item: unknown): item is DataModelField { - return reflection.isInstance(item, DataModelField); +export function isDataFieldType(item: unknown): item is DataFieldType { + return reflection.isInstance(item, DataFieldType); } -export interface DataModelFieldAttribute extends langium.AstNode { - readonly $container: DataModelField | EnumField | TypeDefField; - readonly $type: 'DataModelFieldAttribute'; - args: Array; - decl: langium.Reference; +export interface DataModel extends langium.AstNode { + readonly $container: Model; + readonly $type: 'DataModel'; + attributes: Array; + baseModel?: langium.Reference; + comments: Array; + fields: Array; + isView: boolean; + mixins: Array>; + name: RegularID; } -export const DataModelFieldAttribute = 'DataModelFieldAttribute'; +export const DataModel = 'DataModel'; -export function isDataModelFieldAttribute(item: unknown): item is DataModelFieldAttribute { - return reflection.isInstance(item, DataModelFieldAttribute); +export function isDataModel(item: unknown): item is DataModel { + return reflection.isInstance(item, DataModel); } -export interface DataModelFieldType extends langium.AstNode { - readonly $container: DataModelField; - readonly $type: 'DataModelFieldType'; - array: boolean; - optional: boolean; - reference?: langium.Reference; - type?: BuiltinType; - unsupported?: UnsupportedFieldType; +export interface DataModelAttribute extends langium.AstNode { + readonly $container: DataModel | Enum | TypeDef; + readonly $type: 'DataModelAttribute'; + args: Array; + decl: langium.Reference; } -export const DataModelFieldType = 'DataModelFieldType'; +export const DataModelAttribute = 'DataModelAttribute'; -export function isDataModelFieldType(item: unknown): item is DataModelFieldType { - return reflection.isInstance(item, DataModelFieldType); +export function isDataModelAttribute(item: unknown): item is DataModelAttribute { + return reflection.isInstance(item, DataModelAttribute); } export interface DataSource extends langium.AstNode { @@ -443,7 +436,7 @@ export function isEnum(item: unknown): item is Enum { export interface EnumField extends langium.AstNode { readonly $container: Enum; readonly $type: 'EnumField'; - attributes: Array; + attributes: Array; comments: Array; name: RegularIDWithTypeNames; } @@ -734,7 +727,8 @@ export interface TypeDef extends langium.AstNode { readonly $type: 'TypeDef'; attributes: Array; comments: Array; - fields: Array; + fields: Array; + mixins: Array>; name: RegularID; } @@ -744,36 +738,6 @@ export function isTypeDef(item: unknown): item is TypeDef { return reflection.isInstance(item, TypeDef); } -export interface TypeDefField extends langium.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 langium.AstNode { - readonly $container: TypeDefField; - readonly $type: 'TypeDefFieldType'; - array: boolean; - optional: boolean; - reference?: langium.Reference; - type?: BuiltinType; -} - -export const TypeDefFieldType = 'TypeDefFieldType'; - -export function isTypeDefFieldType(item: unknown): item is TypeDefFieldType { - return reflection.isInstance(item, TypeDefFieldType); -} - export interface UnaryExpr extends langium.AstNode { readonly $container: Argument | ArrayExpr | AttributeArg | BinaryExpr | FieldInitializer | FunctionDecl | MemberAccessExpr | ReferenceArg | UnaryExpr; readonly $type: 'UnaryExpr'; @@ -788,7 +752,7 @@ export function isUnaryExpr(item: unknown): item is UnaryExpr { } export interface UnsupportedFieldType extends langium.AstNode { - readonly $container: DataModelFieldType; + readonly $container: DataFieldType; readonly $type: 'UnsupportedFieldType'; value: LiteralExpr; } @@ -814,11 +778,11 @@ export type ZModelAstType = { ConfigField: ConfigField ConfigInvocationArg: ConfigInvocationArg ConfigInvocationExpr: ConfigInvocationExpr + DataField: DataField + DataFieldAttribute: DataFieldAttribute + DataFieldType: DataFieldType DataModel: DataModel DataModelAttribute: DataModelAttribute - DataModelField: DataModelField - DataModelFieldAttribute: DataModelFieldAttribute - DataModelFieldType: DataModelFieldType DataSource: DataSource Enum: Enum EnumField: EnumField @@ -849,9 +813,6 @@ export type ZModelAstType = { ThisExpr: ThisExpr TypeDeclaration: TypeDeclaration TypeDef: TypeDef - TypeDefField: TypeDefField - TypeDefFieldType: TypeDefFieldType - TypeDefFieldTypes: TypeDefFieldTypes UnaryExpr: UnaryExpr UnsupportedFieldType: UnsupportedFieldType } @@ -859,7 +820,7 @@ export type ZModelAstType = { export class ZModelAstReflection extends langium.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, MemberAccessTarget, Model, ModelImport, NullExpr, NumberLiteral, ObjectExpr, Plugin, PluginField, Procedure, ProcedureParam, ReferenceArg, ReferenceExpr, ReferenceTarget, StringLiteral, ThisExpr, TypeDeclaration, TypeDef, TypeDefField, TypeDefFieldType, TypeDefFieldTypes, UnaryExpr, UnsupportedFieldType]; + return [AbstractDeclaration, Argument, ArrayExpr, Attribute, AttributeArg, AttributeParam, AttributeParamType, BinaryExpr, BooleanLiteral, ConfigArrayExpr, ConfigExpr, ConfigField, ConfigInvocationArg, ConfigInvocationExpr, DataField, DataFieldAttribute, DataFieldType, DataModel, DataModelAttribute, DataSource, Enum, EnumField, Expression, FieldInitializer, FunctionDecl, FunctionParam, FunctionParamType, GeneratorDecl, InternalAttribute, InvocationExpr, LiteralExpr, MemberAccessExpr, MemberAccessTarget, Model, ModelImport, NullExpr, NumberLiteral, ObjectExpr, Plugin, PluginField, Procedure, ProcedureParam, ReferenceArg, ReferenceExpr, ReferenceTarget, StringLiteral, ThisExpr, TypeDeclaration, TypeDef, UnaryExpr, UnsupportedFieldType]; } protected override computeIsSubtype(subtype: string, supertype: string): boolean { @@ -890,16 +851,13 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { case ConfigArrayExpr: { return this.isSubtype(ConfigExpr, supertype); } - case DataModel: { - return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); - } - case DataModelField: - case TypeDefField: { + case DataField: { return this.isSubtype(MemberAccessTarget, supertype) || this.isSubtype(ReferenceTarget, supertype); } + case DataModel: case Enum: case TypeDef: { - return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype) || this.isSubtype(TypeDefFieldTypes, supertype); + return this.isSubtype(AbstractDeclaration, supertype) || this.isSubtype(TypeDeclaration, supertype); } case EnumField: case FunctionParam: { @@ -919,18 +877,22 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { const referenceId = `${refInfo.container.$type}:${refInfo.property}`; switch (referenceId) { case 'AttributeParamType:reference': - case 'DataModelFieldType:reference': + case 'DataFieldType:reference': case 'FunctionParamType:reference': { return TypeDeclaration; } - case 'DataModel:superTypes': { - return DataModel; - } + case 'DataFieldAttribute:decl': case 'DataModelAttribute:decl': - case 'DataModelFieldAttribute:decl': case 'InternalAttribute:decl': { return Attribute; } + case 'DataModel:baseModel': { + return DataModel; + } + case 'DataModel:mixins': + case 'TypeDef:mixins': { + return TypeDef; + } case 'InvocationExpr:function': { return FunctionDecl; } @@ -940,9 +902,6 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { case 'ReferenceExpr:target': { return ReferenceTarget; } - case 'TypeDefFieldType:reference': { - return TypeDefFieldTypes; - } default: { throw new Error(`${referenceId} is not a valid reference id.`); } @@ -1063,58 +1022,58 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { ] }; } - case DataModel: { + case DataField: { return { - name: DataModel, + name: DataField, properties: [ { name: 'attributes', defaultValue: [] }, { name: 'comments', defaultValue: [] }, - { name: 'fields', defaultValue: [] }, - { name: 'isAbstract', defaultValue: false }, - { name: 'isView', defaultValue: false }, { name: 'name' }, - { name: 'superTypes', defaultValue: [] } + { name: 'type' } ] }; } - case DataModelAttribute: { + case DataFieldAttribute: { return { - name: DataModelAttribute, + name: DataFieldAttribute, properties: [ { name: 'args', defaultValue: [] }, { name: 'decl' } ] }; } - case DataModelField: { + case DataFieldType: { return { - name: DataModelField, + name: DataFieldType, properties: [ - { name: 'attributes', defaultValue: [] }, - { name: 'comments', defaultValue: [] }, - { name: 'name' }, - { name: 'type' } + { name: 'array', defaultValue: false }, + { name: 'optional', defaultValue: false }, + { name: 'reference' }, + { name: 'type' }, + { name: 'unsupported' } ] }; } - case DataModelFieldAttribute: { + case DataModel: { return { - name: DataModelFieldAttribute, + name: DataModel, properties: [ - { name: 'args', defaultValue: [] }, - { name: 'decl' } + { name: 'attributes', defaultValue: [] }, + { name: 'baseModel' }, + { name: 'comments', defaultValue: [] }, + { name: 'fields', defaultValue: [] }, + { name: 'isView', defaultValue: false }, + { name: 'mixins', defaultValue: [] }, + { name: 'name' } ] }; } - case DataModelFieldType: { + case DataModelAttribute: { return { - name: DataModelFieldType, + name: DataModelAttribute, properties: [ - { name: 'array', defaultValue: false }, - { name: 'optional', defaultValue: false }, - { name: 'reference' }, - { name: 'type' }, - { name: 'unsupported' } + { name: 'args', defaultValue: [] }, + { name: 'decl' } ] }; } @@ -1347,32 +1306,11 @@ export class ZModelAstReflection extends langium.AbstractAstReflection { { name: 'attributes', defaultValue: [] }, { name: 'comments', defaultValue: [] }, { name: 'fields', defaultValue: [] }, + { name: 'mixins', defaultValue: [] }, { name: 'name' } ] }; } - case TypeDefField: { - return { - name: TypeDefField, - properties: [ - { name: 'attributes', defaultValue: [] }, - { name: 'comments', defaultValue: [] }, - { name: 'name' }, - { name: 'type' } - ] - }; - } - case TypeDefFieldType: { - return { - name: TypeDefFieldType, - properties: [ - { name: 'array', defaultValue: false }, - { name: 'optional', defaultValue: false }, - { name: 'reference' }, - { name: 'type' } - ] - }; - } case UnaryExpr: { return { name: UnaryExpr, diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index c2a61e5a..56f9d901 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -126,7 +126,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "RuleCall", "rule": { - "$ref": "#/rules@40" + "$ref": "#/rules@42" }, "arguments": [] }, @@ -1920,16 +1920,6 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "Group", "elements": [ - { - "$type": "Assignment", - "feature": "isAbstract", - "operator": "?=", - "terminal": { - "$type": "Keyword", - "value": "abstract" - }, - "cardinality": "?" - }, { "$type": "Keyword", "value": "model" @@ -1947,45 +1937,59 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel } }, { - "$type": "Group", + "$type": "Alternatives", "elements": [ { - "$type": "Keyword", - "value": "extends" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [] }, { - "$type": "Assignment", - "feature": "superTypes", - "operator": "+=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@37" + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@39" + }, + "arguments": [] + }, + { + "$type": "Group", + "elements": [ + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@39" + }, + "arguments": [] }, - "deprecatedSyntax": false - } + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [] + } + ] }, { "$type": "Group", "elements": [ { - "$type": "Keyword", - "value": "," + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [] }, { - "$type": "Assignment", - "feature": "superTypes", - "operator": "+=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/rules@37" - }, - "deprecatedSyntax": false - } + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@39" + }, + "arguments": [] } - ], - "cardinality": "*" + ] } ], "cardinality": "?" @@ -2034,7 +2038,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@38" + "$ref": "#/rules@40" }, "arguments": [] } @@ -2069,7 +2073,92 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "DataModelField", + "fragment": true, + "name": "WithClause", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "with" + }, + { + "$type": "Assignment", + "feature": "mixins", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@42" + }, + "deprecatedSyntax": false + } + }, + { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": ",", + "cardinality": "?" + }, + { + "$type": "Assignment", + "feature": "mixins", + "operator": "+=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@42" + }, + "deprecatedSyntax": false + } + } + ], + "cardinality": "*" + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "fragment": true, + "name": "ExtendsClause", + "definition": { + "$type": "Group", + "elements": [ + { + "$type": "Keyword", + "value": "inherits" + }, + { + "$type": "Assignment", + "feature": "baseModel", + "operator": "=", + "terminal": { + "$type": "CrossReference", + "type": { + "$ref": "#/rules@37" + }, + "deprecatedSyntax": false + } + } + ] + }, + "definesHiddenTokens": false, + "entry": false, + "hiddenTokens": [], + "parameters": [], + "wildcard": false + }, + { + "$type": "ParserRule", + "name": "DataField", "definition": { "$type": "Group", "elements": [ @@ -2105,7 +2194,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@39" + "$ref": "#/rules@41" }, "arguments": [] } @@ -2134,7 +2223,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "DataModelFieldType", + "name": "DataFieldType", "definition": { "$type": "Group", "elements": [ @@ -2172,7 +2261,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/types@3" + "$ref": "#/types@2" }, "terminal": { "$type": "RuleCall", @@ -2259,6 +2348,14 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "arguments": [] } }, + { + "$type": "RuleCall", + "rule": { + "$ref": "#/rules@38" + }, + "arguments": [], + "cardinality": "?" + }, { "$type": "Keyword", "value": "{" @@ -2273,7 +2370,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "RuleCall", "rule": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" }, "arguments": [] } @@ -2306,151 +2403,6 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "parameters": [], "wildcard": false }, - { - "$type": "ParserRule", - "name": "TypeDefField", - "definition": { - "$type": "Group", - "elements": [ - { - "$type": "Assignment", - "feature": "comments", - "operator": "+=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@71" - }, - "arguments": [] - }, - "cardinality": "*" - }, - { - "$type": "Assignment", - "feature": "name", - "operator": "=", - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@52" - }, - "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@56" - }, - "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@62" - }, - "arguments": [] - } - }, - { - "$type": "Assignment", - "feature": "reference", - "operator": "=", - "terminal": { - "$type": "CrossReference", - "type": { - "$ref": "#/types@2" - }, - "terminal": { - "$type": "RuleCall", - "rule": { - "$ref": "#/rules@51" - }, - "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": "UnsupportedFieldType", @@ -2851,7 +2803,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/types@3" + "$ref": "#/types@2" }, "terminal": { "$type": "RuleCall", @@ -3465,7 +3417,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "terminal": { "$type": "CrossReference", "type": { - "$ref": "#/types@3" + "$ref": "#/types@2" }, "terminal": { "$type": "RuleCall", @@ -3519,7 +3471,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel }, { "$type": "ParserRule", - "name": "DataModelFieldAttribute", + "name": "DataFieldAttribute", "definition": { "$type": "Group", "elements": [ @@ -4036,13 +3988,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@38" - } - }, - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@41" + "$ref": "#/rules@40" } }, { @@ -4058,42 +4004,10 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "$type": "Type", "name": "MemberAccessTarget", "type": { - "$type": "UnionType", - "types": [ - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@38" - } - }, - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@41" - } - } - ] - } - }, - { - "$type": "Type", - "name": "TypeDefFieldTypes", - "type": { - "$type": "UnionType", - "types": [ - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@40" - } - }, - { - "$type": "SimpleType", - "typeRef": { - "$ref": "#/rules@44" - } - } - ] + "$type": "SimpleType", + "typeRef": { + "$ref": "#/rules@40" + } } }, { @@ -4111,7 +4025,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel { "$type": "SimpleType", "typeRef": { - "$ref": "#/rules@40" + "$ref": "#/rules@42" } }, { diff --git a/packages/language/src/utils.ts b/packages/language/src/utils.ts index 224c89c3..f661bbc5 100644 --- a/packages/language/src/utils.ts +++ b/packages/language/src/utils.ts @@ -1,4 +1,6 @@ +import { invariant } from '@zenstackhq/common-helpers'; import { AstUtils, URI, type AstNode, type LangiumDocuments, type Reference } from 'langium'; +import fs from 'node:fs'; import path from 'path'; import { STD_LIB_MODULE_NAME, type ExpressionContext } from './constants'; import { @@ -7,8 +9,8 @@ import { isArrayExpr, isBinaryExpr, isConfigArrayExpr, + isDataField, isDataModel, - isDataModelField, isEnumField, isExpression, isInvocationExpr, @@ -19,32 +21,28 @@ import { isReferenceExpr, isStringLiteral, isTypeDef, - isTypeDefField, Model, ModelImport, ReferenceExpr, type Attribute, type AttributeParam, type BuiltinType, + type DataField, + type DataFieldAttribute, type DataModel, type DataModelAttribute, - type DataModelField, - type DataModelFieldAttribute, type Enum, type EnumField, type Expression, type ExpressionType, type FunctionDecl, type TypeDef, - type TypeDefField, } from './generated/ast'; -import fs from 'node:fs'; export type AttributeTarget = | DataModel | TypeDef - | DataModelField - | TypeDefField + | DataField | Enum | EnumField | FunctionDecl @@ -56,9 +54,7 @@ export function hasAttribute(decl: AttributeTarget, name: string) { } export function getAttribute(decl: AttributeTarget, name: string) { - return (decl.attributes as (DataModelAttribute | DataModelFieldAttribute)[]).find( - (attr) => attr.decl.$refText === name, - ); + return (decl.attributes as (DataModelAttribute | DataFieldAttribute)[]).find((attr) => attr.decl.$refText === name); } export function isFromStdlib(node: AstNode) { @@ -139,14 +135,14 @@ export function isEnumFieldReference(node: AstNode): node is ReferenceExpr { return isReferenceExpr(node) && isEnumField(node.target.ref); } -export function isDataModelFieldReference(node: AstNode): node is ReferenceExpr { - return isReferenceExpr(node) && isDataModelField(node.target.ref); +export function isDataFieldReference(node: AstNode): node is ReferenceExpr { + return isReferenceExpr(node) && isDataField(node.target.ref); } /** * Returns if the given field is a relation field. */ -export function isRelationshipField(field: DataModelField) { +export function isRelationshipField(field: DataField) { return isDataModel(field.type.reference?.ref); } @@ -165,42 +161,22 @@ export function resolved(ref: Reference): T { return ref.ref; } -/** - * Walk up the inheritance chain to find the path from the start model to the target model - */ -export function findUpInheritance(start: DataModel, target: DataModel): DataModel[] | undefined { - for (const base of start.superTypes) { - if (base.ref === target) { - return [base.ref]; - } - const path = findUpInheritance(base.ref as DataModel, target); - if (path) { - return [base.ref as DataModel, ...path]; - } - } - return undefined; -} - export function getModelFieldsWithBases(model: DataModel, includeDelegate = true) { - if (model.$baseMerged) { - return model.fields; - } else { - return [...model.fields, ...getRecursiveBases(model, includeDelegate).flatMap((base) => base.fields)]; - } + return [...model.fields, ...getRecursiveBases(model, includeDelegate).flatMap((base) => base.fields)]; } export function getRecursiveBases( - dataModel: DataModel, + decl: DataModel | TypeDef, includeDelegate = true, - seen = new Set(), -): DataModel[] { - const result: DataModel[] = []; - if (seen.has(dataModel)) { + seen = new Set(), +): (TypeDef | DataModel)[] { + const result: (TypeDef | DataModel)[] = []; + if (seen.has(decl)) { return result; } - seen.add(dataModel); - dataModel.superTypes.forEach((superType) => { - const baseDecl = superType.ref; + seen.add(decl); + decl.mixins.forEach((mixin) => { + const baseDecl = mixin.ref; if (baseDecl) { if (!includeDelegate && isDelegateModel(baseDecl)) { return; @@ -216,10 +192,11 @@ export function getRecursiveBases( * Gets `@@id` fields declared at the data model level (including search in base models) */ export function getModelIdFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; + const modelsToCheck = [model, ...getRecursiveBases(model)]; for (const modelToCheck of modelsToCheck) { - const idAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@id'); + const allAttributes = getAllAttributes(modelToCheck); + const idAttr = allAttributes.find((attr) => attr.decl.$refText === '@@id'); if (!idAttr) { continue; } @@ -230,7 +207,7 @@ export function getModelIdFields(model: DataModel) { return fieldsArg.value.items .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => resolved(item.target) as DataModelField); + .map((item) => resolved(item.target) as DataField); } return []; @@ -240,10 +217,11 @@ export function getModelIdFields(model: DataModel) { * Gets `@@unique` fields declared at the data model level (including search in base models) */ export function getModelUniqueFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; + const modelsToCheck = [model, ...getRecursiveBases(model)]; for (const modelToCheck of modelsToCheck) { - const uniqueAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@unique'); + const allAttributes = getAllAttributes(modelToCheck); + const uniqueAttr = allAttributes.find((attr) => attr.decl.$refText === '@@unique'); if (!uniqueAttr) { continue; } @@ -254,7 +232,7 @@ export function getModelUniqueFields(model: DataModel) { return fieldsArg.value.items .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => resolved(item.target) as DataModelField); + .map((item) => resolved(item.target) as DataField); } return []; @@ -277,7 +255,7 @@ export function getUniqueFields(model: DataModel) { return fieldsArg.value.items .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => resolved(item.target) as DataModelField); + .map((item) => resolved(item.target) as DataField); }); } @@ -346,7 +324,7 @@ function getArray(expr: Expression | ConfigExpr | undefined) { } export function getAttributeArgLiteral( - attr: DataModelAttribute | DataModelFieldAttribute, + attr: DataModelAttribute | DataFieldAttribute, name: string, ): T | undefined { for (const arg of attr.args) { @@ -373,10 +351,10 @@ export function getFunctionExpressionContext(funcDecl: FunctionDecl) { return funcAllowedContext; } -export function getFieldReference(expr: Expression): DataModelField | TypeDefField | undefined { - if (isReferenceExpr(expr) && (isDataModelField(expr.target.ref) || isTypeDefField(expr.target.ref))) { +export function getFieldReference(expr: Expression): DataField | undefined { + if (isReferenceExpr(expr) && isDataField(expr.target.ref)) { return expr.target.ref; - } else if (isMemberAccessExpr(expr) && (isDataModelField(expr.member.ref) || isTypeDefField(expr.member.ref))) { + } else if (isMemberAccessExpr(expr) && isDataField(expr.member.ref)) { return expr.member.ref; } else { return undefined; @@ -554,3 +532,23 @@ export function getContainingDataModel(node: Expression): DataModel | undefined export function isMemberContainer(node: unknown): node is DataModel | TypeDef { return isDataModel(node) || isTypeDef(node); } + +export function getAllFields(decl: DataModel | TypeDef, includeIgnored = false): DataField[] { + const fields: DataField[] = []; + for (const mixin of decl.mixins) { + invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`); + fields.push(...getAllFields(mixin.ref)); + } + fields.push(...decl.fields.filter((f) => includeIgnored || !hasAttribute(f, '@ignore'))); + return fields; +} + +export function getAllAttributes(decl: DataModel | TypeDef): DataModelAttribute[] { + const attributes: DataModelAttribute[] = []; + for (const mixin of decl.mixins) { + invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`); + attributes.push(...getAllAttributes(mixin.ref)); + } + attributes.push(...decl.attributes); + return attributes; +} diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index df6f5334..c04666df 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -6,23 +6,23 @@ import { AttributeArg, AttributeParam, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, + DataField, + DataFieldAttribute, InternalAttribute, ReferenceExpr, isArrayExpr, isAttribute, isDataModel, - isDataModelField, + isDataField, isEnum, isReferenceExpr, isTypeDef, - isTypeDefField, } from '../generated/ast'; import { + getAllAttributes, getStringLiteral, hasAttribute, - isDataModelFieldReference, + isDataFieldReference, isDelegateModel, isFutureExpr, isRelationshipField, @@ -31,6 +31,7 @@ import { typeAssignable, } from '../utils'; import type { AstValidator } from './common'; +import type { DataModel } from '../ast'; // a registry of function handlers marked with @check const attributeCheckers = new Map(); @@ -45,13 +46,13 @@ function check(name: string) { }; } -type AttributeApplication = DataModelAttribute | DataModelFieldAttribute | InternalAttribute; +type AttributeApplication = DataModelAttribute | DataFieldAttribute | InternalAttribute; /** * Validates function declarations. */ export default class AttributeApplicationValidator implements AstValidator { - validate(attr: AttributeApplication, accept: ValidationAcceptor) { + validate(attr: AttributeApplication, accept: ValidationAcceptor, contextDataModel?: DataModel) { const decl = attr.decl.ref; if (!decl) { return; @@ -63,19 +64,12 @@ export default class AttributeApplicationValidator implements AstValidator(); @@ -133,13 +127,27 @@ export default class AttributeApplicationValidator implements AstValidator a.decl.ref?.name === '@@@deprecated'); + if (deprecateAttr) { + const message = + getStringLiteral(deprecateAttr.args[0]?.value) ?? `Attribute "${attr.decl.ref?.name}" is deprecated`; + accept('warning', message, { node: attr }); + } + } + + private checkDuplicatedAttributes( + attr: AttributeApplication, + accept: ValidationAcceptor, + contextDataModel?: DataModel, + ) { const attrDecl = attr.decl.ref; if (!attrDecl?.attributes.some((a) => a.decl.ref?.name === '@@@once')) { return; } - const duplicates = attr.$container.attributes.filter((a) => a.decl.ref === attrDecl && a !== attr); + const allAttributes = contextDataModel ? getAllAttributes(contextDataModel) : attr.$container.attributes; + const duplicates = allAttributes.filter((a) => a.decl.ref === attrDecl && a !== attr); if (duplicates.length > 0) { accept('error', `Attribute "${attrDecl.name}" can only be applied once`, { node: attr }); } @@ -182,7 +190,7 @@ export default class AttributeApplicationValidator implements AstValidator isDataModelFieldReference(node) && isDataModel(node.$resolvedType?.decl), + (node) => isDataFieldReference(node) && isDataModel(node.$resolvedType?.decl), ) ) { accept('error', `\`@@validate\` condition cannot use relation fields`, { node: condition }); @@ -233,7 +241,7 @@ export default class AttributeApplicationValidator implements AstValidator { - if (isDataModelFieldReference(node) && hasAttribute(node.target.ref as DataModelField, '@encrypted')) { + if (isDataFieldReference(node) && hasAttribute(node.target.ref as DataField, '@encrypted')) { accept('error', `Encrypted fields cannot be used in policy rules`, { node }); } }); @@ -292,7 +300,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstType === 'ContextType') { // ContextType is inferred from the attribute's container's type - if (isDataModelField(attr.$container)) { + if (isDataField(attr.$container)) { dstIsArray = attr.$container.type.array; } } @@ -320,10 +328,10 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstIsArray) { return ( isArrayExpr(arg.value) && - !arg.value.items.find((item) => !isReferenceExpr(item) || !isDataModelField(item.target.ref)) + !arg.value.items.find((item) => !isReferenceExpr(item) || !isDataField(item.target.ref)) ); } else { - return isReferenceExpr(arg.value) && isDataModelField(arg.value.target.ref); + return isReferenceExpr(arg.value) && isDataField(arg.value.target.ref); } } @@ -331,7 +339,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at // enum type let attrArgDeclType = dstRef?.ref; - if (dstType === 'ContextType' && isDataModelField(attr.$container) && attr.$container?.type?.reference) { + if (dstType === 'ContextType' && isDataField(attr.$container) && attr.$container?.type?.reference) { // attribute parameter type is ContextType, need to infer type from // the attribute's container attrArgDeclType = resolved(attr.$container.type.reference); @@ -349,7 +357,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at if (dstType === 'ContextType') { // attribute parameter type is ContextType, need to infer type from // the attribute's container - if (isDataModelField(attr.$container)) { + if (isDataField(attr.$container)) { if (!attr.$container?.type?.type) { return false; } @@ -367,7 +375,7 @@ function assignableToAttributeParam(arg: AttributeArg, param: AttributeParam, at } } -function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) { +function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataField) { const targetField = attrDecl.attributes.find((attr) => attr.decl.ref?.name === '@@@targetField'); if (!targetField?.args[0]) { // no field type constraint @@ -425,6 +433,10 @@ function isValidAttributeTarget(attrDecl: Attribute, targetDecl: DataModelField) return allowed; } -export function validateAttributeApplication(attr: AttributeApplication, accept: ValidationAcceptor) { - new AttributeApplicationValidator().validate(attr, accept); +export function validateAttributeApplication( + attr: AttributeApplication, + accept: ValidationAcceptor, + contextDataModel?: DataModel, +) { + new AttributeApplicationValidator().validate(attr, accept, contextDataModel); } diff --git a/packages/language/src/validators/common.ts b/packages/language/src/validators/common.ts index 76bd41cc..257efc79 100644 --- a/packages/language/src/validators/common.ts +++ b/packages/language/src/validators/common.ts @@ -1,5 +1,5 @@ import type { AstNode, MaybePromise, ValidationAcceptor } from 'langium'; -import { isDataModelField } from '../generated/ast'; +import { isDataField } from '../generated/ast'; /** * AST validator contract @@ -28,8 +28,8 @@ export function validateDuplicatedDeclarations( for (const [name, decls] of Object.entries(groupByName)) { if (decls.length > 1) { let errorField = decls[1]!; - if (isDataModelField(decls[0])) { - const nonInheritedFields = decls.filter((x) => !(isDataModelField(x) && x.$container !== container)); + if (isDataField(decls[0])) { + const nonInheritedFields = decls.filter((x) => !(isDataField(x) && x.$container !== container)); if (nonInheritedFields.length > 0) { errorField = nonInheritedFields.slice(-1)[0]!; } diff --git a/packages/language/src/validators/datamodel-validator.ts b/packages/language/src/validators/datamodel-validator.ts index f163d2a3..5d7f9919 100644 --- a/packages/language/src/validators/datamodel-validator.ts +++ b/packages/language/src/validators/datamodel-validator.ts @@ -2,10 +2,11 @@ import { AstUtils, type AstNode, type DiagnosticInfo, type ValidationAcceptor } import { IssueCodes, SCALAR_TYPES } from '../constants'; import { ArrayExpr, + DataField, DataModel, - DataModelField, Model, ReferenceExpr, + TypeDef, isDataModel, isDataSource, isEnum, @@ -14,7 +15,7 @@ import { isTypeDef, } from '../generated/ast'; import { - findUpInheritance, + getAllAttributes, getLiteral, getModelFieldsWithBases, getModelIdFields, @@ -31,15 +32,13 @@ import { validateDuplicatedDeclarations, type AstValidator } from './common'; */ export default class DataModelValidator implements AstValidator { validate(dm: DataModel, accept: ValidationAcceptor): void { - this.validateBaseAbstractModel(dm, accept); - this.validateBaseDelegateModel(dm, accept); validateDuplicatedDeclarations(dm, getModelFieldsWithBases(dm), accept); this.validateAttributes(dm, accept); this.validateFields(dm, accept); - - if (dm.superTypes.length > 0) { - this.validateInheritance(dm, accept); + if (dm.mixins.length > 0) { + this.validateMixins(dm, accept); } + this.validateInherits(dm, accept); } private validateFields(dm: DataModel, accept: ValidationAcceptor) { @@ -50,7 +49,6 @@ export default class DataModelValidator implements AstValidator { const modelUniqueFields = getModelUniqueFields(dm); if ( - !dm.isAbstract && idFields.length === 0 && modelLevelIds.length === 0 && uniqueFields.length === 0 && @@ -89,17 +87,14 @@ export default class DataModelValidator implements AstValidator { } dm.fields.forEach((field) => this.validateField(field, accept)); - - if (!dm.isAbstract) { - allFields - .filter((x) => isDataModel(x.type.reference?.ref)) - .forEach((y) => { - this.validateRelationField(dm, y, accept); - }); - } + allFields + .filter((x) => isDataModel(x.type.reference?.ref)) + .forEach((y) => { + this.validateRelationField(dm, y, accept); + }); } - private validateField(field: DataModelField, accept: ValidationAcceptor): void { + private validateField(field: DataField, accept: ValidationAcceptor): void { if (field.type.array && field.type.optional) { accept('error', 'Optional lists are not supported. Use either `Type[]` or `Type?`', { node: field.type }); } @@ -137,10 +132,10 @@ export default class DataModelValidator implements AstValidator { } private validateAttributes(dm: DataModel, accept: ValidationAcceptor) { - dm.attributes.forEach((attr) => validateAttributeApplication(attr, accept)); + getAllAttributes(dm).forEach((attr) => validateAttributeApplication(attr, accept, dm)); } - private parseRelation(field: DataModelField, accept?: ValidationAcceptor) { + private parseRelation(field: DataField, accept?: ValidationAcceptor) { const relAttr = field.attributes.find((attr) => attr.decl.ref?.name === '@relation'); let name: string | undefined; @@ -244,17 +239,17 @@ export default class DataModelValidator implements AstValidator { return { attr: relAttr, name, fields, references, valid }; } - private isSelfRelation(field: DataModelField) { + private isSelfRelation(field: DataField) { return field.type.reference?.ref === field.$container; } - private validateRelationField(contextModel: DataModel, field: DataModelField, accept: ValidationAcceptor) { + private validateRelationField(contextModel: DataModel, field: DataField, accept: ValidationAcceptor) { const thisRelation = this.parseRelation(field, accept); if (!thisRelation.valid) { return; } - if (this.isFieldInheritedFromDelegateModel(field, contextModel)) { + if (this.isFieldInheritedFromDelegateModel(field)) { // relation fields inherited from delegate model don't need opposite relation return; } @@ -331,7 +326,7 @@ export default class DataModelValidator implements AstValidator { const oppositeField = oppositeFields[0]!; const oppositeRelation = this.parseRelation(oppositeField); - let relationOwner: DataModelField; + let relationOwner: DataField; if (field.type.array && oppositeField.type.array) { // if both the field is array, then it's an implicit many-to-many relation, @@ -411,7 +406,7 @@ export default class DataModelValidator implements AstValidator { } thisRelation.fields?.forEach((ref) => { - const refField = ref.target.ref as DataModelField; + const refField = ref.target.ref as DataField; if (refField) { if ( refField.attributes.find( @@ -435,59 +430,35 @@ export default class DataModelValidator implements AstValidator { } // checks if the given field is inherited directly or indirectly from a delegate model - private isFieldInheritedFromDelegateModel(field: DataModelField, contextModel: DataModel) { - const basePath = findUpInheritance(contextModel, field.$container as DataModel); - if (basePath && basePath.some(isDelegateModel)) { - return true; - } else { - return false; - } + private isFieldInheritedFromDelegateModel(field: DataField) { + return isDelegateModel(field.$container); } - private validateBaseAbstractModel(model: DataModel, accept: ValidationAcceptor) { - model.superTypes.forEach((superType, index) => { - if ( - !superType.ref?.isAbstract && - !superType.ref?.attributes.some((attr) => attr.decl.ref?.name === '@@delegate') - ) - accept( - 'error', - `Model ${superType.$refText} cannot be extended because it's neither abstract nor marked as "@@delegate"`, - { - node: model, - property: 'superTypes', - index, - }, - ); - }); - } - - private validateBaseDelegateModel(model: DataModel, accept: ValidationAcceptor) { - if (model.superTypes.filter((base) => base.ref && isDelegateModel(base.ref)).length > 1) { - accept('error', 'Extending from multiple delegate models is not supported', { + private validateInherits(model: DataModel, accept: ValidationAcceptor) { + if (!model.baseModel) { + return; + } + if (model.baseModel.ref && !isDelegateModel(model.baseModel.ref)) { + accept('error', `Model ${model.baseModel.$refText} cannot be extended because it's not a delegate model`, { node: model, - property: 'superTypes', + property: 'baseModel', }); } } - private validateInheritance(dm: DataModel, accept: ValidationAcceptor) { - const seen = [dm]; - const todo: DataModel[] = dm.superTypes.map((superType) => superType.ref!); + private validateMixins(dm: DataModel, accept: ValidationAcceptor) { + const seen: TypeDef[] = []; + const todo: TypeDef[] = dm.mixins.map((mixin) => mixin.ref!); while (todo.length > 0) { const current = todo.shift()!; if (seen.includes(current)) { - accept( - 'error', - `Circular inheritance detected: ${seen.map((m) => m.name).join(' -> ')} -> ${current.name}`, - { - node: dm, - }, - ); + accept('error', `Cyclic mixin detected: ${seen.map((m) => m.name).join(' -> ')} -> ${current.name}`, { + node: dm, + }); return; } seen.push(current); - todo.push(...current.superTypes.map((superType) => superType.ref!)); + todo.push(...current.mixins.map((mixin) => mixin.ref!)); } } } diff --git a/packages/language/src/validators/expression-validator.ts b/packages/language/src/validators/expression-validator.ts index 362aed2d..28c15fc6 100644 --- a/packages/language/src/validators/expression-validator.ts +++ b/packages/language/src/validators/expression-validator.ts @@ -18,7 +18,7 @@ import { findUpAst, isAuthInvocation, isAuthOrAuthMemberAccess, - isDataModelFieldReference, + isDataFieldReference, isEnumFieldReference, typeAssignable, } from '../utils'; @@ -149,8 +149,8 @@ export default class ExpressionValidator implements AstValidator { // in validation context, all fields are optional, so we should allow // comparing any field against null if ( - (isDataModelFieldReference(expr.left) && isNullExpr(expr.right)) || - (isDataModelFieldReference(expr.right) && isNullExpr(expr.left)) + (isDataFieldReference(expr.left) && isNullExpr(expr.right)) || + (isDataFieldReference(expr.right) && isNullExpr(expr.left)) ) { return; } @@ -204,13 +204,13 @@ export default class ExpressionValidator implements AstValidator { // - foo == bar // - foo == this if ( - isDataModelFieldReference(expr.left) && - (isThisExpr(expr.right) || isDataModelFieldReference(expr.right)) + isDataFieldReference(expr.left) && + (isThisExpr(expr.right) || isDataFieldReference(expr.right)) ) { accept('error', 'comparison between model-typed fields are not supported', { node: expr }); } else if ( - isDataModelFieldReference(expr.right) && - (isThisExpr(expr.left) || isDataModelFieldReference(expr.left)) + isDataFieldReference(expr.right) && + (isThisExpr(expr.left) || isDataFieldReference(expr.left)) ) { accept('error', 'comparison between model-typed fields are not supported', { node: expr }); } diff --git a/packages/language/src/validators/function-invocation-validator.ts b/packages/language/src/validators/function-invocation-validator.ts index ae59f5fd..b640ad1b 100644 --- a/packages/language/src/validators/function-invocation-validator.ts +++ b/packages/language/src/validators/function-invocation-validator.ts @@ -3,22 +3,22 @@ import { match, P } from 'ts-pattern'; import { ExpressionContext } from '../constants'; import { Argument, + DataFieldAttribute, DataModel, DataModelAttribute, - DataModelFieldAttribute, Expression, FunctionDecl, FunctionParam, InvocationExpr, + isDataFieldAttribute, isDataModel, isDataModelAttribute, - isDataModelFieldAttribute, } from '../generated/ast'; import { getFunctionExpressionContext, getLiteral, isCheckInvocation, - isDataModelFieldReference, + isDataFieldReference, isFromStdlib, typeAssignable, } from '../utils'; @@ -56,9 +56,9 @@ export default class FunctionInvocationValidator implements AstValidator, - extraScopes: ScopeProvider[], - ) { + private resolveDataField(node: DataField, document: LangiumDocument, extraScopes: ScopeProvider[]) { // Field declaration may contain enum references, and enum fields are pushed to the global // scope, so if there're enums with fields with the same name, an arbitrary one will be // used as resolution target. The correct behavior is to resolve to the enum that's used @@ -518,9 +512,9 @@ export class ZModelLinker extends DefaultLinker { //#region Utils - private resolveToDeclaredType(node: AstNode, type: FunctionParamType | DataModelFieldType | TypeDefFieldType) { + private resolveToDeclaredType(node: AstNode, type: FunctionParamType | DataFieldType) { let nullable = false; - if (isDataModelFieldType(type) || isTypeDefField(type)) { + if (isDataFieldType(type)) { nullable = type.optional; // referencing a field of 'Unsupported' type diff --git a/packages/language/src/zmodel-scope.ts b/packages/language/src/zmodel-scope.ts index c5c2968f..e95ac0b7 100644 --- a/packages/language/src/zmodel-scope.ts +++ b/packages/language/src/zmodel-scope.ts @@ -20,7 +20,7 @@ import { BinaryExpr, MemberAccessExpr, isDataModel, - isDataModelField, + isDataField, isEnumField, isInvocationExpr, isMemberAccessExpr, @@ -28,13 +28,13 @@ import { isReferenceExpr, isThisExpr, isTypeDef, - isTypeDefField, } from './ast'; import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants'; import { getAllLoadedAndReachableDataModelsAndTypeDefs, getAuthDecl, getModelFieldsWithBases, + getRecursiveBases, isAuthInvocation, isCollectionPredicate, isFutureInvocation, @@ -75,22 +75,15 @@ export class ZModelScopeComputation extends DefaultScopeComputation { override processNode(node: AstNode, document: LangiumDocument, scopes: PrecomputedScopes) { super.processNode(node, document, scopes); - // TODO: merge base - // if (isDataModel(node) && !node.$baseMerged) { - // // add base fields to the scope recursively - // const bases = getRecursiveBases(node); - // for (const base of bases) { - // for (const field of base.fields) { - // scopes.add( - // node, - // this.descriptions.createDescription( - // field, - // this.nameProvider.getName(field) - // ) - // ); - // } - // } - // } + if (isDataModel(node)) { + // add base fields to the scope recursively + const bases = getRecursiveBases(node); + for (const base of bases) { + for (const field of base.fields) { + scopes.add(node, this.descriptions.createDescription(field, this.nameProvider.getName(field))); + } + } + } } } @@ -152,7 +145,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isReferenceExpr, (operand) => { // operand is a reference, it can only be a model/type-def field const ref = operand.target.ref; - if (isDataModelField(ref) || isTypeDefField(ref)) { + if (isDataField(ref)) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; @@ -160,10 +153,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isMemberAccessExpr, (operand) => { // operand is a member access, it must be resolved to a non-array model/typedef type const ref = operand.member.ref; - if (isDataModelField(ref) && !ref.type.array) { - return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); - } - if (isTypeDefField(ref) && !ref.type.array) { + if (isDataField(ref) && !ref.type.array) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; @@ -203,7 +193,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isReferenceExpr, (expr) => { // collection is a reference - model or typedef field const ref = expr.target.ref; - if (isDataModelField(ref) || isTypeDefField(ref)) { + if (isDataField(ref)) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; @@ -211,7 +201,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider { .when(isMemberAccessExpr, (expr) => { // collection is a member access, it can only be resolved to a model or typedef field const ref = expr.member.ref; - if (isDataModelField(ref) || isTypeDefField(ref)) { + if (isDataField(ref)) { return this.createScopeForContainer(ref.type.reference?.ref, globalScope, allowTypeDefScope); } return EMPTY_SCOPE; diff --git a/packages/language/src/zmodel-workspace-manager.ts b/packages/language/src/zmodel-workspace-manager.ts index f21db797..7b21b56b 100644 --- a/packages/language/src/zmodel-workspace-manager.ts +++ b/packages/language/src/zmodel-workspace-manager.ts @@ -1,6 +1,7 @@ import { DefaultWorkspaceManager, URI, + UriUtils, type AstNode, type LangiumDocument, type LangiumDocumentFactory, @@ -10,7 +11,9 @@ import type { LangiumSharedServices } from 'langium/lsp'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { STD_LIB_MODULE_NAME } from './constants'; +import { isPlugin, type Model } from './ast'; +import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants'; +import { getLiteral } from './utils'; export class ZModelWorkspaceManager extends DefaultWorkspaceManager { private documentFactory: LangiumDocumentFactory; @@ -68,5 +71,87 @@ export class ZModelWorkspaceManager extends DefaultWorkspaceManager { const stdlib = await this.documentFactory.fromUri(URI.file(stdLibPath)); collector(stdlib); + + const documents = this.langiumDocuments.all; + const pluginModels = new Set(); + + // find plugin models + documents.forEach((doc) => { + const parsed = doc.parseResult.value as Model; + parsed.declarations.forEach((decl) => { + if (isPlugin(decl)) { + const providerField = decl.fields.find((f) => f.name === 'provider'); + if (providerField) { + const provider = getLiteral(providerField.value); + if (provider) { + pluginModels.add(provider); + } + } + } + }); + }); + + if (pluginModels.size > 0) { + console.log(`Used plugin modules: ${Array.from(pluginModels)}`); + + // the loaded plugin models would be removed from the set + const pendingPluginModules = new Set(pluginModels); + + await Promise.all( + folders + .map((wf) => [wf, this.getRootFolder(wf)] as [WorkspaceFolder, URI]) + .map(async (entry) => this.loadPluginModels(...entry, pendingPluginModules, collector)), + ); + } + } + + protected async loadPluginModels( + workspaceFolder: WorkspaceFolder, + folderPath: URI, + pendingPluginModels: Set, + collector: (document: LangiumDocument) => void, + ): Promise { + const content = (await this.fileSystemProvider.readDirectory(folderPath)).sort((a, b) => { + // make sure the node_modules folder is always the first one to be checked + // so we can exit early if the plugin is found + if (a.isDirectory && b.isDirectory) { + const aName = UriUtils.basename(a.uri); + if (aName === 'node_modules') { + return -1; + } else { + return 1; + } + } else { + return 0; + } + }); + + for (const entry of content) { + if (entry.isDirectory) { + const name = UriUtils.basename(entry.uri); + if (name === 'node_modules') { + for (const plugin of Array.from(pendingPluginModels)) { + const path = UriUtils.joinPath(entry.uri, plugin, PLUGIN_MODULE_NAME); + try { + await this.fileSystemProvider.readFile(path); + const document = await this.langiumDocuments.getOrCreateDocument(path); + collector(document); + console.log(`Adding plugin document from ${path.path}`); + + pendingPluginModels.delete(plugin); + // early exit if all plugins are loaded + if (pendingPluginModels.size === 0) { + return; + } + } catch { + // no-op. The module might be found in another node_modules folder + // will show the warning message eventually if not found + } + } + } else { + await this.loadPluginModels(workspaceFolder, entry.uri, pendingPluginModels, collector); + } + } + } } } diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 5f2bca5e..2bb5f3dd 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -66,7 +66,8 @@ ConfigArrayExpr: ConfigExpr: LiteralExpr | InvocationExpr | ConfigArrayExpr; -type ReferenceTarget = FunctionParam | DataModelField | TypeDefField | EnumField; +type ReferenceTarget = FunctionParam | DataField | EnumField; + ThisExpr: value='this'; @@ -94,7 +95,7 @@ FieldInitializer: InvocationExpr: function=[FunctionDecl] '(' ArgumentList? ')'; -type MemberAccessTarget = DataModelField | TypeDefField; +type MemberAccessTarget = DataField; MemberAccessExpr infers Expression: PrimaryExpr ( @@ -164,41 +165,37 @@ Argument: DataModel: (comments+=TRIPLE_SLASH_COMMENT)* ( - ((isAbstract?='abstract')? 'model' name=RegularID - ('extends' superTypes+=[DataModel] (',' superTypes+=[DataModel])*)?) | + ('model' name=RegularID (WithClause | ExtendsClause | (ExtendsClause WithClause) | (WithClause ExtendsClause))?) | ((isView?='view') name=RegularID) ) '{' ( - fields+=DataModelField + fields+=DataField | attributes+=DataModelAttribute )* '}'; -DataModelField: +fragment WithClause: + 'with' mixins+=[TypeDef] (','? mixins+=[TypeDef])*; + +fragment ExtendsClause: + 'inherits' baseModel=[DataModel]; + +DataField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames type=DataModelFieldType (attributes+=DataModelFieldAttribute)*; + name=RegularIDWithTypeNames type=DataFieldType (attributes+=DataFieldAttribute)*; -DataModelFieldType: +DataFieldType: (type=BuiltinType | unsupported=UnsupportedFieldType | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; -// TODO: unify TypeDef and abstract DataModel TypeDef: (comments+=TRIPLE_SLASH_COMMENT)* - 'type' name=RegularID '{' ( - fields+=TypeDefField | + 'type' name=RegularID WithClause? + '{' ( + fields+=DataField | attributes+=DataModelAttribute )* '}'; -type TypeDefFieldTypes = TypeDef | Enum; - -TypeDefField: - (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames type=TypeDefFieldType (attributes+=DataModelFieldAttribute)*; - -TypeDefFieldType: - (type=BuiltinType | reference=[TypeDefFieldTypes:RegularID]) (array?='[' ']')? (optional?='?')?; - UnsupportedFieldType: 'Unsupported' '(' (value=LiteralExpr) ')'; @@ -213,7 +210,7 @@ Enum: EnumField: (comments+=TRIPLE_SLASH_COMMENT)* - name=RegularIDWithTypeNames (attributes+=DataModelFieldAttribute)*; + name=RegularIDWithTypeNames (attributes+=DataFieldAttribute)*; // function FunctionDecl: @@ -252,7 +249,7 @@ AttributeParamType: (type=(ExpressionType | 'FieldReference' | 'TransitiveFieldReference' | 'ContextType') | reference=[TypeDeclaration:RegularID]) (array?='[' ']')? (optional?='?')?; type TypeDeclaration = DataModel | TypeDef | Enum; -DataModelFieldAttribute: +DataFieldAttribute: decl=[Attribute:FIELD_ATTRIBUTE_NAME] ('(' AttributeArgList? ')')?; // TODO: need rename since it's for both DataModel and TypeDef diff --git a/packages/language/test/mixin.test.ts b/packages/language/test/mixin.test.ts new file mode 100644 index 00000000..3832148d --- /dev/null +++ b/packages/language/test/mixin.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { loadSchema, loadSchemaWithError } from './utils'; +import { DataModel, TypeDef } from '../src/ast'; + +describe('Mixin Tests', () => { + it('supports model mixing types to Model', async () => { + const model = await loadSchema(` + type A { + x String + } + + type B { + y String + } + + model M with A B { + id String @id + } + `); + const m = model.declarations.find((d) => d.name === 'M') as DataModel; + expect(m.mixins.length).toBe(2); + expect(m.mixins[0].ref?.name).toBe('A'); + expect(m.mixins[1].ref?.name).toBe('B'); + }); + + it('supports model mixing types to type', async () => { + const model = await loadSchema(` + type A { + x String + } + + type B { + y String + } + + type C with A B { + z String + } + + model M with C { + id String @id + } + `); + const c = model.declarations.find((d) => d.name === 'C') as TypeDef; + expect(c?.mixins.length).toBe(2); + expect(c?.mixins[0].ref?.name).toBe('A'); + expect(c?.mixins[1].ref?.name).toBe('B'); + const m = model.declarations.find((d) => d.name === 'M') as DataModel; + expect(m.mixins[0].ref?.name).toBe('C'); + }); + + it('can detect cyclic mixins', async () => { + await loadSchemaWithError( + ` + type A with B { + x String + } + + type B with A { + y String + } + + model M with A { + id String @id + } + `, + 'cyclic', + ); + }); + + it('can detect duplicated fields from mixins', async () => { + await loadSchemaWithError( + ` + type A { + x String + } + + type B { + x String + } + + model M with A B { + id String @id + } + `, + 'duplicated', + ); + }); + + it('can detect duplicated attributes from mixins', async () => { + await loadSchemaWithError( + ` + type A { + x String + @@id([x]) + } + + type B { + y String + @@id([y]) + } + + model M with A B { + } + `, + 'can only be applied once', + ); + }); +}); diff --git a/packages/language/test/utils.ts b/packages/language/test/utils.ts new file mode 100644 index 00000000..fe558f41 --- /dev/null +++ b/packages/language/test/utils.ts @@ -0,0 +1,30 @@ +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { loadDocument } from '../src'; +import { expect } from 'vitest'; +import { invariant } from '@zenstackhq/common-helpers'; + +export async function loadSchema(schema: string) { + // create a temp file + const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`); + fs.writeFileSync(tempFile, schema); + const r = await loadDocument(tempFile); + expect(r.success).toBe(true); + invariant(r.success); + return r.model; +} + +export async function loadSchemaWithError(schema: string, error: string | RegExp) { + // create a temp file + const tempFile = path.join(os.tmpdir(), `zenstack-schema-${crypto.randomUUID()}.zmodel`); + fs.writeFileSync(tempFile, schema); + const r = await loadDocument(tempFile); + expect(r.success).toBe(false); + invariant(!r.success); + if (typeof error === 'string') { + expect(r.errors.some((e) => e.toString().toLowerCase().includes(error.toLowerCase()))).toBe(true); + } else { + expect(r.errors.some((e) => error.test(e))).toBe(true); + } +} diff --git a/packages/language/tsup.config.ts b/packages/language/tsup.config.ts index a6af92ce..0d5d2b6c 100644 --- a/packages/language/tsup.config.ts +++ b/packages/language/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', ast: 'src/ast.ts', + utils: 'src/utils.ts', }, outDir: 'dist', splitting: false, diff --git a/packages/language/vitest.config.ts b/packages/language/vitest.config.ts new file mode 100644 index 00000000..04655403 --- /dev/null +++ b/packages/language/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import base from '../../vitest.base.config'; + +export default mergeConfig(base, defineConfig({})); diff --git a/packages/runtime/src/client/crud-types.ts b/packages/runtime/src/client/crud-types.ts index e97d2e29..c14cf401 100644 --- a/packages/runtime/src/client/crud-types.ts +++ b/packages/runtime/src/client/crud-types.ts @@ -11,11 +11,14 @@ import type { ForeignKeyFields, GetEnum, GetEnums, - GetField, - GetFields, - GetFieldType, GetModel, + GetModelField, + GetModelFields, + GetModelFieldType, GetModels, + GetTypeDefField, + GetTypeDefFields, + GetTypeDefs, NonRelationFields, RelationFields, RelationFieldType, @@ -51,7 +54,7 @@ type DefaultModelResult< ? Omit[Key] extends true ? never : Key - : Key]: MapFieldType; + : Key]: MapModelFieldType; }, Optional, Array @@ -71,7 +74,7 @@ type ModelSelectResult : Key]: Key extends '_count' ? SelectCountResult : Key extends NonRelationFields - ? MapFieldType + ? MapModelFieldType : Key extends RelationFields ? Select[Key] extends FindArgs< Schema, @@ -161,11 +164,15 @@ export type ModelResult< export type SimplifiedModelResult< Schema extends SchemaDef, Model extends GetModels, - Args extends SelectIncludeOmit, + Args extends SelectIncludeOmit = {}, Optional = false, Array = false, > = Simplify>; +export type TypeDefResult> = { + [Key in GetTypeDefFields]: MapTypeDefFieldType; +}; + export type BatchResult = { count: number }; //#endregion @@ -177,7 +184,7 @@ export type WhereInput< Model extends GetModels, ScalarOnly extends boolean = false, > = { - [Key in GetFields as ScalarOnly extends true + [Key in GetModelFields as ScalarOnly extends true ? Key extends RelationFields ? never : Key @@ -185,12 +192,12 @@ export type WhereInput< ? // relation RelationFilter : // enum - GetFieldType extends GetEnums - ? EnumFilter, FieldIsOptional> + GetModelFieldType extends GetEnums + ? EnumFilter, FieldIsOptional> : FieldIsArray extends true - ? ArrayFilter> + ? ArrayFilter> : // primitive - PrimitiveFilter, FieldIsOptional>; + PrimitiveFilter, FieldIsOptional>; } & { $expr?: (eb: ExpressionBuilder, Model>) => OperandExpression; } & { @@ -443,11 +450,24 @@ type RelationFilter< //#region Field utils -type MapFieldType< +type MapModelFieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = MapFieldDefType>; + Field extends GetModelFields, +> = MapFieldDefType>; + +type MapTypeDefFieldType< + Schema extends SchemaDef, + TypeDef extends GetTypeDefs, + Field extends GetTypeDefFields, +> = + GetTypeDefField['type'] extends GetTypeDefs + ? WrapType< + TypeDefResult['type']>, + GetTypeDefField['optional'], + GetTypeDefField['array'] + > + : MapFieldDefType>; type MapFieldDefType> = WrapType< T['type'] extends GetEnums ? keyof GetEnum : MapBaseType, @@ -456,32 +476,32 @@ type MapFieldDefType; type OptionalFieldsForCreate> = keyof { - [Key in GetFields as FieldIsOptional extends true + [Key in GetModelFields as FieldIsOptional extends true ? Key : FieldHasDefault extends true ? Key - : GetField['updatedAt'] extends true + : GetModelField['updatedAt'] extends true ? Key : FieldIsRelationArray extends true ? Key - : never]: GetField; + : never]: GetModelField; }; type GetRelation< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['relation']; + Field extends GetModelFields, +> = GetModelField['relation']; type OppositeRelation< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, FT = FieldType, > = FT extends GetModels ? GetRelation extends RelationInfo - ? GetRelation['opposite'] extends GetFields + ? GetRelation['opposite'] extends GetModelFields ? Schema['models'][FT]['fields'][GetRelation['opposite']]['relation'] : never : never @@ -490,20 +510,20 @@ type OppositeRelation< type OppositeRelationFields< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, Opposite = OppositeRelation, > = Opposite extends RelationInfo ? (Opposite['fields'] extends string[] ? Opposite['fields'] : []) : []; type OppositeRelationAndFK< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, FT = FieldType, - Relation = GetField['relation'], + Relation = GetModelField['relation'], Opposite = Relation extends RelationInfo ? Relation['opposite'] : never, > = FT extends GetModels - ? Opposite extends GetFields + ? Opposite extends GetModelFields ? Opposite | OppositeRelationFields[number] : never : never; @@ -579,10 +599,10 @@ type ScalarCreatePayload< Model extends GetModels, Field extends ScalarFields, > = - | MapFieldType + | MapModelFieldType | (FieldIsArray extends true ? { - set?: MapFieldType[]; + set?: MapModelFieldType[]; } : never); @@ -590,7 +610,7 @@ type CreateFKPayload> Schema, Model, { - [Key in ForeignKeyFields]: MapFieldType; + [Key in ForeignKeyFields]: MapModelFieldType; } >; @@ -729,7 +749,7 @@ type ScalarUpdatePayload< Model extends GetModels, Field extends NonRelationFields, > = - | MapFieldType + | MapModelFieldType | (Field extends NumericFields ? { set?: NullableIf>; @@ -741,8 +761,8 @@ type ScalarUpdatePayload< : never) | (FieldIsArray extends true ? { - set?: MapFieldType[]; - push?: OrArray, true>; + set?: MapModelFieldType[]; + push?: OrArray, true>; } : never); @@ -871,11 +891,15 @@ export type AggregateArgs> = keyof { - [Key in GetFields as GetFieldType extends 'Int' | 'Float' | 'BigInt' | 'Decimal' + [Key in GetModelFields as GetModelFieldType extends + | 'Int' + | 'Float' + | 'BigInt' + | 'Decimal' ? FieldIsArray extends true ? never : Key - : never]: GetField; + : never]: GetModelField; }; type SumAvgInput> = { @@ -883,7 +907,7 @@ type SumAvgInput> = { }; type MinMaxInput> = { - [Key in GetFields as FieldIsArray extends true + [Key in GetModelFields as FieldIsArray extends true ? never : FieldIsRelation extends true ? never @@ -957,7 +981,7 @@ export type GroupByResult< { [Key in NonRelationFields as Key extends ValueOfPotentialTuple ? Key - : never]: MapFieldType; + : never]: MapModelFieldType; } & (Args extends { _count: infer Count } ? { _count: AggCommonOutput; @@ -1110,7 +1134,9 @@ type NestedDeleteManyInput< // #region Utilities type NonOwnedRelationFields> = keyof { - [Key in RelationFields as GetField['relation'] extends { references: unknown[] } + [Key in RelationFields as GetModelField['relation'] extends { + references: unknown[]; + } ? never : Key]: Key; }; diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index 9bb5ba19..0f42d757 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -49,7 +49,7 @@ export class SchemaDbPusher { } table = this.addPrimaryKeyConstraint(table, model, modelDef); - table = this.addUniqueConstraint(table, modelDef); + table = this.addUniqueConstraint(table, model, modelDef); return table; } @@ -77,7 +77,7 @@ export class SchemaDbPusher { return table; } - private addUniqueConstraint(table: CreateTableBuilder, modelDef: ModelDef) { + private addUniqueConstraint(table: CreateTableBuilder, model: string, modelDef: ModelDef) { for (const [key, value] of Object.entries(modelDef.uniqueFields)) { invariant(typeof value === 'object', 'expecting an object'); if ('type' in value) { @@ -86,9 +86,10 @@ export class SchemaDbPusher { if (fieldDef.unique) { continue; } + table = table.addUniqueConstraint(`unique_${model}_${key}`, [key]); } else { // multi-field constraint - table = table.addUniqueConstraint(`unique_${key}`, Object.keys(value)); + table = table.addUniqueConstraint(`unique_${model}_${key}`, Object.keys(value)); } } return table; diff --git a/packages/runtime/src/client/query-builder.ts b/packages/runtime/src/client/query-builder.ts index f049727f..64b76ea0 100644 --- a/packages/runtime/src/client/query-builder.ts +++ b/packages/runtime/src/client/query-builder.ts @@ -4,8 +4,8 @@ import type { FieldHasDefault, FieldIsOptional, ForeignKeyFields, - GetFields, - GetFieldType, + GetModelFields, + GetModelFieldType, GetModels, ScalarFields, SchemaDef, @@ -44,13 +44,13 @@ type WrapNull = Null extends true ? T | null : T; type MapType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = WrapNull>, FieldIsOptional>; + Field extends GetModelFields, +> = WrapNull>, FieldIsOptional>; type toKyselyFieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, > = FieldHasDefault extends true ? Generated> diff --git a/packages/runtime/test/client-api/find.test.ts b/packages/runtime/test/client-api/find.test.ts index 8bc60821..d239c76d 100644 --- a/packages/runtime/test/client-api/find.test.ts +++ b/packages/runtime/test/client-api/find.test.ts @@ -51,20 +51,12 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', // take await expect(client.user.findMany({ take: 1 })).resolves.toHaveLength(1); - // default sorted by id - await expect(client.user.findFirst({ take: 1 })).resolves.toMatchObject({ - email: 'u1@test.com', - }); await expect(client.user.findMany({ take: 2 })).resolves.toHaveLength(2); await expect(client.user.findMany({ take: 4 })).resolves.toHaveLength(3); // skip await expect(client.user.findMany({ skip: 1 })).resolves.toHaveLength(2); await expect(client.user.findMany({ skip: 2 })).resolves.toHaveLength(1); - // default sorted by id - await expect(client.user.findFirst({ skip: 1, take: 1 })).resolves.toMatchObject({ - email: 'u02@test.com', - }); // explicit sort await expect( client.user.findFirst({ @@ -81,10 +73,10 @@ describe.each(createClientSpecs(PG_DB_NAME))('Client find tests for $provider', // negative take, default sort is negated await expect(client.user.findMany({ take: -2 })).toResolveWithLength(2); - await expect(client.user.findMany({ take: -2 })).resolves.toEqual( + await expect(client.user.findMany({ take: -2, orderBy: { id: 'asc' } })).resolves.toEqual( expect.arrayContaining([expect.objectContaining({ id: '3' }), expect.objectContaining({ id: '2' })]), ); - await expect(client.user.findMany({ skip: 1, take: -1 })).resolves.toEqual([ + await expect(client.user.findMany({ skip: 1, take: -1, orderBy: { id: 'asc' } })).resolves.toEqual([ expect.objectContaining({ id: '2' }), ]); diff --git a/packages/runtime/test/client-api/mixin.test.ts b/packages/runtime/test/client-api/mixin.test.ts new file mode 100644 index 00000000..8e888aac --- /dev/null +++ b/packages/runtime/test/client-api/mixin.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { createTestClient } from '../utils'; + +describe('Client API Mixins', () => { + const schema = ` +type TimeStamped { + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +type Named { + name String + @@unique([name]) +} + +type CommonFields with TimeStamped Named { + id String @id @default(cuid()) +} + +model Foo with TimeStamped { + id String @id @default(cuid()) + title String +} + +model Bar with CommonFields { + description String +} + `; + + it('includes fields and attributes from mixins', async () => { + const client = await createTestClient(schema, { + usePrismaPush: true, + }); + + await expect( + client.foo.create({ + data: { + title: 'Foo', + }, + }), + ).resolves.toMatchObject({ + id: expect.any(String), + title: 'Foo', + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + await expect( + client.bar.create({ + data: { + description: 'Bar', + }, + }), + ).rejects.toThrow('Invalid input'); + + await expect( + client.bar.create({ + data: { + name: 'Bar', + description: 'Bar', + }, + }), + ).resolves.toMatchObject({ + id: expect.any(String), + name: 'Bar', + description: 'Bar', + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }); + + await expect( + client.bar.create({ + data: { + name: 'Bar', + description: 'Bar', + }, + }), + ).rejects.toThrow('constraint failed'); + }); +}); diff --git a/packages/runtime/test/schemas/todo.zmodel b/packages/runtime/test/schemas/todo.zmodel index f462d064..0adb21f6 100644 --- a/packages/runtime/test/schemas/todo.zmodel +++ b/packages/runtime/test/schemas/todo.zmodel @@ -73,7 +73,7 @@ model User { password String? @password @omit emailVerified DateTime? name String? - bio String @ignore + bio String? @ignore ownedSpaces Space[] spaces SpaceUser[] image String? @url diff --git a/packages/runtime/test/test-schema/models.ts b/packages/runtime/test/test-schema/models.ts index f9dbfc98..48b8dea5 100644 --- a/packages/runtime/test/test-schema/models.ts +++ b/packages/runtime/test/test-schema/models.ts @@ -6,10 +6,11 @@ /* eslint-disable */ import { schema as $schema, type SchemaType as $Schema } from "./schema"; -import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +import { type ModelResult as $ModelResult, type TypeDefResult as $TypeDefResult } from "@zenstackhq/runtime"; export type User = $ModelResult<$Schema, "User">; export type Post = $ModelResult<$Schema, "Post">; export type Comment = $ModelResult<$Schema, "Comment">; export type Profile = $ModelResult<$Schema, "Profile">; +export type CommonFields = $TypeDefResult<$Schema, "CommonFields">; export const Role = $schema.enums.Role; export type Role = (typeof Role)[keyof typeof Role]; diff --git a/packages/runtime/test/test-schema/schema.ts b/packages/runtime/test/test-schema/schema.ts index 610cb4b0..db61c902 100644 --- a/packages/runtime/test/test-schema/schema.ts +++ b/packages/runtime/test/test-schema/schema.ts @@ -19,15 +19,6 @@ export const schema = { attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, - email: { - type: "String", - unique: true, - attributes: [{ name: "@unique" }] - }, - name: { - type: "String", - optional: true - }, createdAt: { type: "DateTime", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], @@ -38,6 +29,15 @@ export const schema = { updatedAt: true, attributes: [{ name: "@updatedAt" }] }, + email: { + type: "String", + unique: true, + attributes: [{ name: "@unique" }] + }, + name: { + type: "String", + optional: true + }, role: { type: "Role", attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.literal("USER") }] }], @@ -169,6 +169,16 @@ export const schema = { attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, bio: { type: "String" }, @@ -199,6 +209,27 @@ export const schema = { } } }, + typeDefs: { + CommonFields: { + fields: { + id: { + type: "String", + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + } + } + } + }, enums: { Role: { ADMIN: "ADMIN", diff --git a/packages/runtime/test/test-schema/schema.zmodel b/packages/runtime/test/test-schema/schema.zmodel index fb041b5a..e65f74e6 100644 --- a/packages/runtime/test/test-schema/schema.zmodel +++ b/packages/runtime/test/test-schema/schema.zmodel @@ -12,12 +12,15 @@ enum Role { USER } -model User { +type CommonFields { id String @id @default(cuid()) - email String @unique - name String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +model User with CommonFields { + email String @unique + name String? role Role @default(USER) posts Post[] profile Profile? @@ -27,10 +30,7 @@ model User { @@allow('read', auth() != null) } -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Post with CommonFields { title String content String? published Boolean @default(false) @@ -44,17 +44,13 @@ model Post { @@allow('read', published) } -model Comment { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Comment with CommonFields { content String post Post? @relation(fields: [postId], references: [id], onUpdate: Cascade, onDelete: Cascade) postId String? } -model Profile { - id String @id @default(cuid()) +model Profile with CommonFields { bio String age Int? user User? @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) diff --git a/packages/runtime/test/typing/models.ts b/packages/runtime/test/typing/models.ts index e418ffad..a30c2d6e 100644 --- a/packages/runtime/test/typing/models.ts +++ b/packages/runtime/test/typing/models.ts @@ -6,12 +6,14 @@ /* eslint-disable */ import { schema as $schema, type SchemaType as $Schema } from "./schema"; -import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +import { type ModelResult as $ModelResult, type TypeDefResult as $TypeDefResult } from "@zenstackhq/runtime"; export type User = $ModelResult<$Schema, "User">; export type Post = $ModelResult<$Schema, "Post">; export type Profile = $ModelResult<$Schema, "Profile">; export type Tag = $ModelResult<$Schema, "Tag">; export type Region = $ModelResult<$Schema, "Region">; export type Meta = $ModelResult<$Schema, "Meta">; +export type Identity = $TypeDefResult<$Schema, "Identity">; +export type IdentityProvider = $TypeDefResult<$Schema, "IdentityProvider">; export const Role = $schema.enums.Role; export type Role = (typeof Role)[keyof typeof Role]; diff --git a/packages/runtime/test/typing/schema.ts b/packages/runtime/test/typing/schema.ts index fb0db9e1..d529dbf3 100644 --- a/packages/runtime/test/typing/schema.ts +++ b/packages/runtime/test/typing/schema.ts @@ -246,6 +246,26 @@ export const schema = { } } }, + typeDefs: { + Identity: { + fields: { + providers: { + type: "IdentityProvider", + array: true + } + } + }, + IdentityProvider: { + fields: { + id: { + type: "String" + }, + name: { + type: "String" + } + } + } + }, enums: { Role: { ADMIN: "ADMIN", diff --git a/packages/runtime/test/typing/typing-test.zmodel b/packages/runtime/test/typing/typing-test.zmodel index 84e9d9b2..2aa9aa67 100644 --- a/packages/runtime/test/typing/typing-test.zmodel +++ b/packages/runtime/test/typing/typing-test.zmodel @@ -8,6 +8,15 @@ enum Role { USER } +type Identity { + providers IdentityProvider[] +} + +type IdentityProvider { + id String + name String +} + model User { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) diff --git a/packages/runtime/test/typing/verify-typing.ts b/packages/runtime/test/typing/verify-typing.ts index 86389616..e9410031 100644 --- a/packages/runtime/test/typing/verify-typing.ts +++ b/packages/runtime/test/typing/verify-typing.ts @@ -1,6 +1,6 @@ import SQLite from 'better-sqlite3'; import { ZenStackClient } from '../../dist'; -import { Role } from './models'; +import { Role, type Identity, type IdentityProvider } from './models'; import { schema } from './schema'; const client = new ZenStackClient(schema, { @@ -28,6 +28,7 @@ async function main() { await groupBy(); await queryBuilder(); enums(); + typeDefs(); } async function find() { @@ -612,4 +613,18 @@ function enums() { console.log(b); } +function typeDefs() { + const identityProvider: IdentityProvider = { + id: '123', + name: 'GitHub', + }; + console.log(identityProvider.id); + console.log(identityProvider.name); + + const identity: Identity = { + providers: [identityProvider], + }; + console.log(identity.providers[0]?.name); +} + main(); diff --git a/packages/runtime/test/utils.ts b/packages/runtime/test/utils.ts index 88aa389c..82b05e82 100644 --- a/packages/runtime/test/utils.ts +++ b/packages/runtime/test/utils.ts @@ -79,8 +79,14 @@ export async function createTestClient( let workDir: string | undefined; let _schema: Schema; + let dbName = options?.dbName; + const provider = options?.provider ?? 'sqlite'; + if (provider === 'sqlite' && options?.usePrismaPush && !dbName) { + dbName = 'file:./test.db'; + } + if (typeof schema === 'string') { - const generated = await generateTsSchema(schema, options?.provider, options?.dbName, options?.extraSourceFiles); + const generated = await generateTsSchema(schema, provider, dbName, options?.extraSourceFiles); workDir = generated.workDir; _schema = generated.schema as Schema; } else { @@ -110,21 +116,21 @@ export async function createTestClient( stdio: 'inherit', }); } else { - if (options?.provider === 'postgresql') { - invariant(options?.dbName, 'dbName is required'); + if (provider === 'postgresql') { + invariant(dbName, 'dbName is required'); const pgClient = new PGClient(TEST_PG_CONFIG); await pgClient.connect(); - await pgClient.query(`DROP DATABASE IF EXISTS "${options!.dbName}"`); - await pgClient.query(`CREATE DATABASE "${options!.dbName}"`); + await pgClient.query(`DROP DATABASE IF EXISTS "${dbName}"`); + await pgClient.query(`CREATE DATABASE "${dbName}"`); await pgClient.end(); } } - if (options?.provider === 'postgresql') { + if (provider === 'postgresql') { _options.dialectConfig = { pool: new Pool({ ...TEST_PG_CONFIG, - database: options!.dbName, + database: dbName, }), } as unknown as ClientOptions['dialectConfig']; } else { diff --git a/packages/sdk/src/model-utils.ts b/packages/sdk/src/model-utils.ts index f20de625..14532938 100644 --- a/packages/sdk/src/model-utils.ts +++ b/packages/sdk/src/model-utils.ts @@ -1,27 +1,25 @@ import { - isArrayExpr, isDataModel, isLiteralExpr, isModel, - isReferenceExpr, Model, - ReferenceExpr, type AstNode, type Attribute, type AttributeParam, + type DataField, + type DataFieldAttribute, type DataModel, type DataModelAttribute, - type DataModelField, - type DataModelFieldAttribute, type Enum, type EnumField, type FunctionDecl, type Reference, type TypeDef, - type TypeDefField, } from '@zenstackhq/language/ast'; -export function isIdField(field: DataModelField) { +import { getAllFields, getModelIdFields, getModelUniqueFields, type AttributeTarget } from '@zenstackhq/language/utils'; + +export function isIdField(field: DataField, contextModel: DataModel) { // field-level @id attribute if (hasAttribute(field, '@id')) { return true; @@ -30,27 +28,26 @@ export function isIdField(field: DataModelField) { // NOTE: we have to use name to match fields because the fields // may be inherited from an abstract base and have cloned identities - const model = field.$container as DataModel; - // model-level @@id attribute with a list of fields - const modelLevelIds = getModelIdFields(model); + const modelLevelIds = getModelIdFields(contextModel); if (modelLevelIds.map((f) => f.name).includes(field.name)) { return true; } - if (model.fields.some((f) => hasAttribute(f, '@id')) || modelLevelIds.length > 0) { + const allFields = getAllFields(contextModel); + if (allFields.some((f) => hasAttribute(f, '@id')) || modelLevelIds.length > 0) { // the model already has id field, don't check @unique and @@unique return false; } // then, the first field with @unique can be used as id - const firstUniqueField = model.fields.find((f) => hasAttribute(f, '@unique')); + const firstUniqueField = allFields.find((f) => hasAttribute(f, '@unique')); if (firstUniqueField) { return firstUniqueField.name === field.name; } // last, the first model level @@unique can be used as id - const modelLevelUnique = getModelUniqueFields(model); + const modelLevelUnique = getModelUniqueFields(contextModel); if (modelLevelUnique.map((f) => f.name).includes(field.name)) { return true; } @@ -59,106 +56,21 @@ export function isIdField(field: DataModelField) { } export function hasAttribute( - decl: DataModel | TypeDef | DataModelField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, + decl: DataModel | TypeDef | DataField | Enum | EnumField | FunctionDecl | Attribute | AttributeParam, name: string, ) { return !!getAttribute(decl, name); } -export function getAttribute( - decl: - | DataModel - | TypeDef - | DataModelField - | TypeDefField - | Enum - | EnumField - | FunctionDecl - | Attribute - | AttributeParam, - name: string, -) { - return (decl.attributes as (DataModelAttribute | DataModelFieldAttribute)[]).find( - (attr) => attr.decl.$refText === name, - ); -} - -/** - * Gets `@@id` fields declared at the data model level (including search in base models) - */ -export function getModelIdFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; - - for (const modelToCheck of modelsToCheck) { - const idAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@id'); - if (!idAttr) { - continue; - } - const fieldsArg = idAttr.args.find((a) => a.$resolvedParam?.name === 'fields'); - if (!fieldsArg || !isArrayExpr(fieldsArg.value)) { - continue; - } - - return fieldsArg.value.items - .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => item.target.ref as DataModelField); - } - - return []; -} - -/** - * Gets `@@unique` fields declared at the data model level (including search in base models) - */ -export function getModelUniqueFields(model: DataModel) { - const modelsToCheck = model.$baseMerged ? [model] : [model, ...getRecursiveBases(model)]; - - for (const modelToCheck of modelsToCheck) { - const uniqueAttr = modelToCheck.attributes.find((attr) => attr.decl.$refText === '@@unique'); - if (!uniqueAttr) { - continue; - } - const fieldsArg = uniqueAttr.args.find((a) => a.$resolvedParam?.name === 'fields'); - if (!fieldsArg || !isArrayExpr(fieldsArg.value)) { - continue; - } - - return fieldsArg.value.items - .filter((item): item is ReferenceExpr => isReferenceExpr(item)) - .map((item) => item.target.ref as DataModelField); - } - - return []; -} - -export function getRecursiveBases( - dataModel: DataModel, - includeDelegate = true, - seen = new Set(), -): DataModel[] { - const result: DataModel[] = []; - if (seen.has(dataModel)) { - return result; - } - seen.add(dataModel); - dataModel.superTypes.forEach((superType) => { - const baseDecl = superType.ref; - if (baseDecl) { - if (!includeDelegate && isDelegateModel(baseDecl)) { - return; - } - result.push(baseDecl); - result.push(...getRecursiveBases(baseDecl, includeDelegate, seen)); - } - }); - return result; +export function getAttribute(decl: AttributeTarget, name: string) { + return (decl.attributes as (DataModelAttribute | DataFieldAttribute)[]).find((attr) => attr.decl.$refText === name); } export function isDelegateModel(node: AstNode) { return isDataModel(node) && hasAttribute(node, '@@delegate'); } -export function isUniqueField(field: DataModelField) { +export function isUniqueField(field: DataField) { if (hasAttribute(field, '@unique')) { return true; } diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index b4edb776..aa4c9172 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -4,11 +4,11 @@ import { ConfigArrayExpr, ConfigExpr, ConfigInvocationArg, + DataField, + DataFieldAttribute, + DataFieldType, DataModel, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, - DataModelFieldType, DataSource, Enum, EnumField, @@ -30,12 +30,11 @@ import { type AstNode, } from '@zenstackhq/language/ast'; import { AstUtils } from 'langium'; -import { match, P } from 'ts-pattern'; +import { match } from 'ts-pattern'; +import { getAllAttributes, getAllFields } from '@zenstackhq/language/utils'; import { ModelUtils, ZModelCodeGenerator } from '..'; import { - AttributeArgValue, - ModelField, ModelFieldType, AttributeArg as PrismaAttributeArg, AttributeArgValue as PrismaAttributeArgValue, @@ -151,15 +150,17 @@ export class PrismaSchemaGenerator { private generateModel(prisma: PrismaModel, decl: DataModel) { const model = decl.isView ? prisma.addView(decl.name) : prisma.addModel(decl.name); - for (const field of decl.fields) { + const allFields = getAllFields(decl, true); + for (const field of allFields) { if (ModelUtils.hasAttribute(field, '@computed')) { continue; // skip computed fields } // TODO: exclude fields inherited from delegate - this.generateModelField(model, field); + this.generateModelField(model, field, decl); } - for (const attr of decl.attributes.filter((attr) => this.isPrismaAttribute(attr))) { + const allAttributes = getAllAttributes(decl); + for (const attr of allAttributes.filter((attr) => this.isPrismaAttribute(attr))) { this.generateContainerAttribute(model, attr); } @@ -183,14 +184,14 @@ export class PrismaSchemaGenerator { // this.ensureRelationsInheritedFromDelegate(model, decl); } - private isPrismaAttribute(attr: DataModelAttribute | DataModelFieldAttribute) { + private isPrismaAttribute(attr: DataModelAttribute | DataFieldAttribute) { if (!attr.decl.ref) { return false; } return attr.decl.ref.attributes.some((a) => a.decl.ref?.name === '@@@prisma'); } - private getUnsupportedFieldType(fieldType: DataModelFieldType) { + private getUnsupportedFieldType(fieldType: DataFieldType) { if (fieldType.unsupported) { const value = this.getStringLiteral(fieldType.unsupported.value); if (value) { @@ -207,7 +208,7 @@ export class PrismaSchemaGenerator { return isStringLiteral(node) ? node.value : undefined; } - private generateModelField(model: PrismaDataModel, field: DataModelField, addToFront = false) { + private generateModelField(model: PrismaDataModel, field: DataField, contextModel: DataModel, addToFront = false) { let fieldType: string | undefined; if (field.type.type) { @@ -245,7 +246,7 @@ export class PrismaSchemaGenerator { (attr) => // when building physical schema, exclude `@default` for id fields inherited from delegate base !( - ModelUtils.isIdField(field) && + ModelUtils.isIdField(field, contextModel) && this.isInheritedFromDelegate(field) && attr.decl.$refText === '@default' ), @@ -257,7 +258,7 @@ export class PrismaSchemaGenerator { return result; } - private isDefaultWithPluginInvocation(attr: DataModelFieldAttribute) { + private isDefaultWithPluginInvocation(attr: DataFieldAttribute) { if (attr.decl.ref?.name !== '@default') { return false; } @@ -275,28 +276,11 @@ export class PrismaSchemaGenerator { return !!model && !!model.$document && model.$document.uri.path.endsWith('plugin.zmodel'); } - private setDummyDefault(result: ModelField, field: DataModelField) { - const dummyDefaultValue = match(field.type.type) - .with('String', () => new AttributeArgValue('String', '')) - .with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => new AttributeArgValue('Number', '0')) - .with('Boolean', () => new AttributeArgValue('Boolean', 'false')) - .with('DateTime', () => new AttributeArgValue('FunctionCall', new PrismaFunctionCall('now'))) - .with('Json', () => new AttributeArgValue('String', '{}')) - .with('Bytes', () => new AttributeArgValue('String', '')) - .otherwise(() => { - throw new Error(`Unsupported field type with default value: ${field.type.type}`); - }); - - result.attributes.push( - new PrismaFieldAttribute('@default', [new PrismaAttributeArg(undefined, dummyDefaultValue)]), - ); - } - - private isInheritedFromDelegate(field: DataModelField) { + private isInheritedFromDelegate(field: DataField) { return field.$inheritedFrom && ModelUtils.isDelegateModel(field.$inheritedFrom); } - private makeFieldAttribute(attr: DataModelFieldAttribute) { + private makeFieldAttribute(attr: DataFieldAttribute) { const attrName = attr.decl.ref!.name; return new PrismaFieldAttribute( attrName, diff --git a/packages/sdk/src/schema/schema.ts b/packages/sdk/src/schema/schema.ts index 91d333b5..7ef99516 100644 --- a/packages/sdk/src/schema/schema.ts +++ b/packages/sdk/src/schema/schema.ts @@ -11,6 +11,7 @@ export type SchemaDef = { provider: DataSourceProvider; models: Record; enums?: Record; + typeDefs?: Record; plugins: Record; procedures?: Record; authType?: GetModels; @@ -89,6 +90,11 @@ export type MappedBuiltinType = string | boolean | number | bigint | Decimal | D export type EnumDef = Record; +export type TypeDefDef = { + fields: Record; + attributes?: AttributeApplication[]; +}; + //#region Extraction export type GetModels = Extract; @@ -99,31 +105,47 @@ export type GetEnums = keyof Schema['enums']; export type GetEnum> = Schema['enums'][Enum]; -export type GetFields> = Extract< +export type GetTypeDefs = Extract; + +export type GetTypeDef> = + Schema['typeDefs'] extends Record ? Schema['typeDefs'][TypeDef] : never; + +export type GetModelFields> = Extract< keyof GetModel['fields'], string >; -export type GetField< +export type GetModelField< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = Schema['models'][Model]['fields'][Field]; + Field extends GetModelFields, +> = GetModel['fields'][Field]; -export type GetFieldType< +export type GetModelFieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, > = Schema['models'][Model]['fields'][Field]['type']; +export type GetTypeDefFields> = Extract< + keyof GetTypeDef['fields'], + string +>; + +export type GetTypeDefField< + Schema extends SchemaDef, + TypeDef extends GetTypeDefs, + Field extends GetTypeDefFields, +> = GetTypeDef['fields'][Field]; + export type ScalarFields< Schema extends SchemaDef, Model extends GetModels, IncludeComputed extends boolean = true, > = keyof { - [Key in GetFields as GetField['relation'] extends object + [Key in GetModelFields as GetModelField['relation'] extends object ? never - : GetField['foreignKeyFor'] extends string[] + : GetModelField['foreignKeyFor'] extends string[] ? never : IncludeComputed extends true ? Key @@ -133,69 +155,76 @@ export type ScalarFields< }; export type ForeignKeyFields> = keyof { - [Key in GetFields as GetField['foreignKeyFor'] extends string[] + [Key in GetModelFields as GetModelField['foreignKeyFor'] extends string[] ? Key : never]: Key; }; export type NonRelationFields> = keyof { - [Key in GetFields as GetField['relation'] extends object ? never : Key]: Key; + [Key in GetModelFields as GetModelField['relation'] extends object + ? never + : Key]: Key; }; export type RelationFields> = keyof { - [Key in GetFields as GetField['relation'] extends object ? Key : never]: Key; + [Key in GetModelFields as GetModelField['relation'] extends object + ? Key + : never]: Key; }; export type FieldType< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['type']; + Field extends GetModelFields, +> = GetModelField['type']; export type RelationFieldType< Schema extends SchemaDef, Model extends GetModels, Field extends RelationFields, -> = GetField['type'] extends GetModels ? GetField['type'] : never; +> = + GetModelField['type'] extends GetModels + ? GetModelField['type'] + : never; export type FieldIsOptional< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['optional'] extends true ? true : false; + Field extends GetModelFields, +> = GetModelField['optional'] extends true ? true : false; export type FieldIsRelation< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['relation'] extends object ? true : false; + Field extends GetModelFields, +> = GetModelField['relation'] extends object ? true : false; export type FieldIsArray< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['array'] extends true ? true : false; + Field extends GetModelFields, +> = GetModelField['array'] extends true ? true : false; export type FieldIsComputed< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['computed'] extends true ? true : false; + Field extends GetModelFields, +> = GetModelField['computed'] extends true ? true : false; export type FieldHasDefault< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, -> = GetField['default'] extends object | number | string | boolean + Field extends GetModelFields, +> = GetModelField['default'] extends object | number | string | boolean ? true - : GetField['updatedAt'] extends true + : GetModelField['updatedAt'] extends true ? true : false; export type FieldIsRelationArray< Schema extends SchemaDef, Model extends GetModels, - Field extends GetFields, + Field extends GetModelFields, > = FieldIsRelation extends true ? FieldIsArray : false; //#endregion diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index d8c90934..63f6f243 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -4,18 +4,18 @@ import { ArrayExpr, AttributeArg, BinaryExpr, + DataField, + DataFieldAttribute, + DataFieldType, DataModel, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, - DataModelFieldType, Enum, Expression, InvocationExpr, isArrayExpr, isBinaryExpr, + isDataField, isDataModel, - isDataModelField, isDataSource, isEnum, isEnumField, @@ -26,14 +26,17 @@ import { isProcedure, isReferenceExpr, isThisExpr, + isTypeDef, isUnaryExpr, LiteralExpr, MemberAccessExpr, Procedure, ReferenceExpr, + TypeDef, UnaryExpr, type Model, } from '@zenstackhq/language/ast'; +import { getAllAttributes, getAllFields } from '@zenstackhq/language/utils'; import fs from 'node:fs'; import path from 'node:path'; import { match } from 'ts-pattern'; @@ -56,7 +59,7 @@ export class TsSchemaGenerator { this.generateSchema(model, outputDir); // the model types - this.generateModels(model, outputDir); + this.generateModelsAndTypeDefs(model, outputDir); // the input types this.generateInputTypes(model, outputDir); @@ -141,6 +144,11 @@ export class TsSchemaGenerator { // models ts.factory.createPropertyAssignment('models', this.createModelsObject(model)), + + // typeDefs + ...(model.declarations.some(isTypeDef) + ? [ts.factory.createPropertyAssignment('typeDefs', this.createTypeDefsObject(model))] + : []), ]; // enums @@ -194,28 +202,38 @@ export class TsSchemaGenerator { ); } + private createTypeDefsObject(model: Model): ts.Expression { + return ts.factory.createObjectLiteralExpression( + model.declarations + .filter((d): d is TypeDef => isTypeDef(d)) + .map((td) => ts.factory.createPropertyAssignment(td.name, this.createTypeDefObject(td))), + true, + ); + } + private createDataModelObject(dm: DataModel) { + const allFields = getAllFields(dm); + const allAttributes = getAllAttributes(dm); + const fields: ts.PropertyAssignment[] = [ // fields ts.factory.createPropertyAssignment( 'fields', ts.factory.createObjectLiteralExpression( - dm.fields - .filter((field) => !hasAttribute(field, '@ignore')) - .map((field) => - ts.factory.createPropertyAssignment(field.name, this.createDataModelFieldObject(field)), - ), + allFields.map((field) => + ts.factory.createPropertyAssignment(field.name, this.createDataFieldObject(field, dm)), + ), true, ), ), // attributes - ...(dm.attributes.length > 0 + ...(allAttributes.length > 0 ? [ ts.factory.createPropertyAssignment( 'attributes', ts.factory.createArrayLiteralExpression( - dm.attributes.map((attr) => this.createAttributeObject(attr)), + allAttributes.map((attr) => this.createAttributeObject(attr)), true, ), ), @@ -245,7 +263,40 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(fields, true); } - private createComputedFieldsObject(fields: DataModelField[]) { + private createTypeDefObject(td: TypeDef): ts.Expression { + const allFields = getAllFields(td); + const allAttributes = getAllAttributes(td); + + const fields: ts.PropertyAssignment[] = [ + // fields + ts.factory.createPropertyAssignment( + 'fields', + ts.factory.createObjectLiteralExpression( + allFields.map((field) => + ts.factory.createPropertyAssignment(field.name, this.createDataFieldObject(field, undefined)), + ), + true, + ), + ), + + // attributes + ...(allAttributes.length > 0 + ? [ + ts.factory.createPropertyAssignment( + 'attributes', + ts.factory.createArrayLiteralExpression( + allAttributes.map((attr) => this.createAttributeObject(attr)), + true, + ), + ), + ] + : []), + ]; + + return ts.factory.createObjectLiteralExpression(fields, true); + } + + private createComputedFieldsObject(fields: DataField[]) { return ts.factory.createObjectLiteralExpression( fields.map((field) => ts.factory.createMethodDeclaration( @@ -274,7 +325,7 @@ export class TsSchemaGenerator { ); } - private mapFieldTypeToTSType(type: DataModelFieldType) { + private mapFieldTypeToTSType(type: DataFieldType) { let result = match(type.type) .with('String', () => 'string') .with('Boolean', () => 'boolean') @@ -292,10 +343,10 @@ export class TsSchemaGenerator { return result; } - private createDataModelFieldObject(field: DataModelField) { + private createDataFieldObject(field: DataField, contextModel: DataModel | undefined) { const objectFields = [ts.factory.createPropertyAssignment('type', this.generateFieldTypeLiteral(field))]; - if (isIdField(field)) { + if (contextModel && ModelUtils.isIdField(field, contextModel)) { objectFields.push(ts.factory.createPropertyAssignment('id', ts.factory.createTrue())); } @@ -445,7 +496,7 @@ export class TsSchemaGenerator { } private getFieldMappedDefault( - field: DataModelField, + field: DataField, ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | undefined { const defaultAttr = getAttribute(field, '@default'); if (!defaultAttr) { @@ -458,7 +509,7 @@ export class TsSchemaGenerator { private getMappedValue( expr: Expression, - fieldType: DataModelFieldType, + fieldType: DataFieldType, ): string | number | boolean | unknown[] | { call: string; args: any[] } | { authMember: string[] } | undefined { if (isLiteralExpr(expr)) { const lit = (expr as LiteralExpr).value; @@ -507,7 +558,7 @@ export class TsSchemaGenerator { ); } - private createRelationObject(field: DataModelField) { + private createRelationObject(field: DataField) { const relationFields: ts.PropertyAssignment[] = []; const oppositeRelation = this.getOppositeRelationField(field); @@ -558,7 +609,7 @@ export class TsSchemaGenerator { return isArrayExpr(expr) && expr.items.map((item) => (item as ReferenceExpr).target.$refText); } - private getForeignKeyFor(field: DataModelField) { + private getForeignKeyFor(field: DataField) { const result: string[] = []; for (const f of field.$container.fields) { const relation = getAttribute(f, '@relation'); @@ -577,7 +628,7 @@ export class TsSchemaGenerator { return result; } - private getOppositeRelationField(field: DataModelField) { + private getOppositeRelationField(field: DataField) { if (!field.type.reference?.ref || !isDataModel(field.type.reference?.ref)) { return undefined; } @@ -605,7 +656,7 @@ export class TsSchemaGenerator { return undefined; } - private getRelationName(field: DataModelField) { + private getRelationName(field: DataField) { const relation = getAttribute(field, '@relation'); if (relation) { const nameArg = relation.args.find((arg) => arg.$resolvedParam.name === 'name'); @@ -618,14 +669,17 @@ export class TsSchemaGenerator { } private getIdFields(dm: DataModel) { - return dm.fields.filter(isIdField).map((f) => f.name); + return getAllFields(dm) + .filter((f) => ModelUtils.isIdField(f, dm)) + .map((f) => f.name); } private createUniqueFieldsObject(dm: DataModel) { const properties: ts.PropertyAssignment[] = []; // field-level id and unique - for (const field of dm.fields) { + const allFields = getAllFields(dm); + for (const field of allFields) { if (hasAttribute(field, '@id') || hasAttribute(field, '@unique')) { properties.push( ts.factory.createPropertyAssignment( @@ -639,11 +693,12 @@ export class TsSchemaGenerator { } // model-level id and unique + const allAttributes = getAllAttributes(dm); // it's possible to have the same set of fields in both `@@id` and `@@unique` // so we need to deduplicate them const seenKeys = new Set(); - for (const attr of dm.attributes) { + for (const attr of allAttributes) { if (attr.decl.$refText === '@@id' || attr.decl.$refText === '@@unique') { const fieldNames = this.getReferenceNames(attr.args[0]!.value); if (!fieldNames) { @@ -652,7 +707,7 @@ export class TsSchemaGenerator { if (fieldNames.length === 1) { // single-field unique - const fieldDef = dm.fields.find((f) => f.name === fieldNames[0])!; + const fieldDef = allFields.find((f) => f.name === fieldNames[0])!; properties.push( ts.factory.createPropertyAssignment( fieldNames[0]!, @@ -673,7 +728,7 @@ export class TsSchemaGenerator { fieldNames.join('_'), ts.factory.createObjectLiteralExpression( fieldNames.map((field) => { - const fieldDef = dm.fields.find((f) => f.name === field)!; + const fieldDef = allFields.find((f) => f.name === field)!; return ts.factory.createPropertyAssignment( field, ts.factory.createObjectLiteralExpression([ @@ -694,7 +749,7 @@ export class TsSchemaGenerator { return ts.factory.createObjectLiteralExpression(properties, true); } - private generateFieldTypeLiteral(field: DataModelField): ts.Expression { + private generateFieldTypeLiteral(field: DataField): ts.Expression { invariant( field.type.type || field.type.reference || field.type.unsupported, 'Field type must be a primitive, reference, or Unsupported', @@ -831,7 +886,7 @@ export class TsSchemaGenerator { ts.addSyntheticLeadingComment(statements[0]!, ts.SyntaxKind.SingleLineCommentTrivia, banner); } - private createAttributeObject(attr: DataModelAttribute | DataModelFieldAttribute): ts.Expression { + private createAttributeObject(attr: DataModelAttribute | DataFieldAttribute): ts.Expression { return ts.factory.createObjectLiteralExpression([ ts.factory.createPropertyAssignment('name', ts.factory.createStringLiteral(attr.decl.$refText)), ...(attr.args.length > 0 @@ -922,7 +977,7 @@ export class TsSchemaGenerator { } private createRefExpression(expr: ReferenceExpr): any { - if (isDataModelField(expr.target.ref)) { + if (isDataField(expr.target.ref)) { return ts.factory.createCallExpression(ts.factory.createIdentifier('ExpressionUtils.field'), undefined, [ this.createLiteralNode(expr.target.$refText), ]); @@ -964,7 +1019,7 @@ export class TsSchemaGenerator { }); } - private generateModels(model: Model, outputDir: string) { + private generateModelsAndTypeDefs(model: Model, outputDir: string) { const statements: ts.Statement[] = []; // generate: import { schema as $schema, type SchemaType as $Schema } from './schema'; @@ -983,15 +1038,24 @@ export class TsSchemaGenerator { undefined, ts.factory.createIdentifier(`ModelResult as $ModelResult`), ), + ...(model.declarations.some(isTypeDef) + ? [ + ts.factory.createImportSpecifier( + true, + undefined, + ts.factory.createIdentifier(`TypeDefResult as $TypeDefResult`), + ), + ] + : []), ]), ), ts.factory.createStringLiteral('@zenstackhq/runtime'), ), ); + // generate: export type Model = $ModelResult; const dataModels = model.declarations.filter(isDataModel); for (const dm of dataModels) { - // generate: export type Model = $ModelResult; let modelType = ts.factory.createTypeAliasDeclaration( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], dm.name, @@ -1007,10 +1071,27 @@ export class TsSchemaGenerator { statements.push(modelType); } - // generate enums + // generate: export type TypeDef = $TypeDefResult; + const typeDefs = model.declarations.filter(isTypeDef); + for (const td of typeDefs) { + let typeDef = ts.factory.createTypeAliasDeclaration( + [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], + td.name, + undefined, + ts.factory.createTypeReferenceNode('$TypeDefResult', [ + ts.factory.createTypeReferenceNode('$Schema'), + ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(td.name)), + ]), + ); + if (td.comments.length > 0) { + typeDef = this.generateDocs(typeDef, td); + } + statements.push(typeDef); + } + + // generate: export const Enum = $schema.enums.Enum; const enums = model.declarations.filter(isEnum); for (const e of enums) { - // generate: export const Enum = $schema.enums.Enum; let enumDecl = ts.factory.createVariableStatement( [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], ts.factory.createVariableDeclarationList( @@ -1097,7 +1178,7 @@ export class TsSchemaGenerator { private generateDocs( tsDecl: T, - decl: DataModel | Enum, + decl: DataModel | TypeDef | Enum, ): T { return ts.addSyntheticLeadingComment( tsDecl, diff --git a/packages/sdk/src/zmodel-code-generator.ts b/packages/sdk/src/zmodel-code-generator.ts index ee115bee..2e010884 100644 --- a/packages/sdk/src/zmodel-code-generator.ts +++ b/packages/sdk/src/zmodel-code-generator.ts @@ -11,11 +11,11 @@ import { ConfigArrayExpr, ConfigField, ConfigInvocationExpr, + DataField, + DataFieldAttribute, + DataFieldType, DataModel, DataModelAttribute, - DataModelField, - DataModelFieldAttribute, - DataModelFieldType, DataSource, Enum, EnumField, @@ -38,8 +38,6 @@ import { StringLiteral, ThisExpr, TypeDef, - TypeDefField, - TypeDefFieldType, UnaryExpr, type AstNode, } from '@zenstackhq/language/ast'; @@ -162,8 +160,8 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')} @gen(DataModel) private _generateDataModel(ast: DataModel) { - return `${ast.isAbstract ? 'abstract ' : ''}${ast.isView ? 'view' : 'model'} ${ast.name}${ - ast.superTypes.length > 0 ? ' extends ' + ast.superTypes.map((x) => x.ref?.name).join(', ') : '' + return `${ast.isView ? 'view' : 'model'} ${ast.name}${ + ast.mixins.length > 0 ? ' mixes ' + ast.mixins.map((x) => x.ref?.name).join(', ') : '' } { ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ ast.attributes.length > 0 @@ -173,17 +171,17 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ }`; } - @gen(DataModelField) - private _generateDataModelField(ast: DataModelField) { + @gen(DataField) + private _generateDataField(ast: DataField) { return `${ast.name} ${this.fieldType(ast.type)}${ ast.attributes.length > 0 ? ' ' + ast.attributes.map((x) => this.generate(x)).join(' ') : '' }`; } - private fieldType(type: DataModelFieldType | TypeDefFieldType) { + private fieldType(type: DataFieldType) { const baseType = type.type ? type.type - : type.$type == 'DataModelFieldType' && type.unsupported + : type.$type == 'DataFieldType' && type.unsupported ? 'Unsupported(' + this.generate(type.unsupported.value) + ')' : type.reference?.$refText; return `${baseType}${type.array ? '[]' : ''}${type.optional ? '?' : ''}`; @@ -194,12 +192,12 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ return this.attribute(ast); } - @gen(DataModelFieldAttribute) - private _generateDataModelFieldAttribute(ast: DataModelFieldAttribute) { + @gen(DataFieldAttribute) + private _generateDataFieldAttribute(ast: DataFieldAttribute) { return this.attribute(ast); } - private attribute(ast: DataModelAttribute | DataModelFieldAttribute) { + private attribute(ast: DataModelAttribute | DataFieldAttribute) { const args = ast.args.length ? `(${ast.args.map((x) => this.generate(x)).join(', ')})` : ''; return `${resolved(ast.decl).name}${args}`; } @@ -338,13 +336,6 @@ ${ast.fields.map((x) => this.indent + this.generate(x)).join('\n')}${ }`; } - @gen(TypeDefField) - private _generateTypeDefField(ast: TypeDefField) { - return `${ast.name} ${this.fieldType(ast.type)}${ - ast.attributes.length > 0 ? ' ' + ast.attributes.map((x) => this.generate(x)).join(' ') : '' - }`; - } - private argument(ast: Argument) { return this.generate(ast.value); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f3319d2..835af938 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: '@types/pluralize': specifier: ^0.0.33 version: 0.0.33 + '@zenstackhq/common-helpers': + specifier: workspace:* + version: link:../common-helpers '@zenstackhq/eslint-config': specifier: workspace:* version: link:../eslint-config diff --git a/samples/blog/zenstack/models.ts b/samples/blog/zenstack/models.ts index a55d9275..86b941a1 100644 --- a/samples/blog/zenstack/models.ts +++ b/samples/blog/zenstack/models.ts @@ -6,7 +6,7 @@ /* eslint-disable */ import { schema as $schema, type SchemaType as $Schema } from "./schema"; -import { type ModelResult as $ModelResult } from "@zenstackhq/runtime"; +import { type ModelResult as $ModelResult, type TypeDefResult as $TypeDefResult } from "@zenstackhq/runtime"; /** * User model * @@ -21,6 +21,7 @@ export type Profile = $ModelResult<$Schema, "Profile">; * Post model */ export type Post = $ModelResult<$Schema, "Post">; +export type CommonFields = $TypeDefResult<$Schema, "CommonFields">; /** * User roles */ diff --git a/samples/blog/zenstack/schema.prisma b/samples/blog/zenstack/schema.prisma deleted file mode 100644 index 0dc12287..00000000 --- a/samples/blog/zenstack/schema.prisma +++ /dev/null @@ -1,44 +0,0 @@ -////////////////////////////////////////////////////////////////////////////////////////////// -// DO NOT MODIFY THIS FILE // -// This file is automatically generated by ZenStack CLI and should not be manually updated. // -////////////////////////////////////////////////////////////////////////////////////////////// - -datasource db { - provider = "sqlite" - url = "file:./dev.db" -} - -enum Role { - ADMIN - USER -} - -model User { - id String @id() @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt() - email String @unique() - name String? - role Role @default(USER) - posts Post[] - profile Profile? -} - -model Profile { - id String @id() @default(cuid()) - bio String? - age Int? - user User? @relation(fields: [userId], references: [id]) - userId String? @unique() -} - -model Post { - id String @id() @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt() - title String - content String - published Boolean @default(false) - author User @relation(fields: [authorId], references: [id]) - authorId String -} \ No newline at end of file diff --git a/samples/blog/zenstack/schema.ts b/samples/blog/zenstack/schema.ts index 18211550..c1ad9a73 100644 --- a/samples/blog/zenstack/schema.ts +++ b/samples/blog/zenstack/schema.ts @@ -78,6 +78,16 @@ export const schema = { attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], default: ExpressionUtils.call("cuid") }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + }, bio: { type: "String", optional: true @@ -155,6 +165,27 @@ export const schema = { } } }, + typeDefs: { + CommonFields: { + fields: { + id: { + type: "String", + attributes: [{ name: "@id" }, { name: "@default", args: [{ name: "value", value: ExpressionUtils.call("cuid") }] }], + default: ExpressionUtils.call("cuid") + }, + createdAt: { + type: "DateTime", + attributes: [{ name: "@default", args: [{ name: "value", value: ExpressionUtils.call("now") }] }], + default: ExpressionUtils.call("now") + }, + updatedAt: { + type: "DateTime", + updatedAt: true, + attributes: [{ name: "@updatedAt" }] + } + } + } + }, enums: { Role: { ADMIN: "ADMIN", diff --git a/samples/blog/zenstack/schema.zmodel b/samples/blog/zenstack/schema.zmodel index bc4d3ed3..aeccb56f 100644 --- a/samples/blog/zenstack/schema.zmodel +++ b/samples/blog/zenstack/schema.zmodel @@ -9,13 +9,16 @@ enum Role { USER } -/// User model -/// -/// Represents a user of the blog. -model User { +type CommonFields { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt +} + +/// User model +/// +/// Represents a user of the blog. +model User with CommonFields { email String @unique name String? postCount Int @computed @@ -25,8 +28,7 @@ model User { } /// Profile model -model Profile { - id String @id @default(cuid()) +model Profile with CommonFields { bio String? age Int? user User? @relation(fields: [userId], references: [id]) @@ -34,10 +36,7 @@ model Profile { } /// Post model -model Post { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt +model Post with CommonFields { title String content String published Boolean @default(false) diff --git a/turbo.json b/turbo.json index 31aad504..203466fc 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,7 @@ "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**"], + "inputs": ["src/**", "zenstack/*.zmodel"], "outputs": ["dist/**"] }, "lint": { From 605af83dd86c570905d39a943cb793b65a61e395 Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:39:01 +0800 Subject: [PATCH 2/3] format --- .prettierignore | 5 +++++ CLAUDE.md | 15 +++++++++++++-- packages/cli/src/actions/validate.ts | 2 +- packages/sdk/src/ts-schema-generator.ts | 2 +- tests/e2e/prisma-consistency/attributes.test.ts | 10 ++++++++-- tests/e2e/prisma-consistency/basic-models.test.ts | 10 ++++++++-- tests/e2e/prisma-consistency/compound-ids.test.ts | 10 ++++++++-- tests/e2e/prisma-consistency/datasource.test.ts | 10 ++++++++-- tests/e2e/prisma-consistency/enums.test.ts | 10 ++++++++-- tests/e2e/prisma-consistency/field-types.test.ts | 11 +++++++++-- .../relation-validation.test.ts | 10 ++++++++-- .../relations-many-to-many.test.ts | 10 ++++++++-- .../relations-one-to-many.test.ts | 10 ++++++++-- .../relations-one-to-one.test.ts | 10 ++++++++-- .../e2e/prisma-consistency/relations-self.test.ts | 10 ++++++++-- tests/e2e/prisma-consistency/test-utils.ts | 2 +- .../prisma-consistency/unique-constraints.test.ts | 10 ++++++++-- 17 files changed, 118 insertions(+), 29 deletions(-) diff --git a/.prettierignore b/.prettierignore index a58f5b41..2f2b01e6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,7 @@ packages/language/src/generated/** **/test/**/schema.ts +**/test/**/models.ts +**/test/**/input.ts +samples/**/schema.ts +samples/**/models.ts +samples/**/input.ts diff --git a/CLAUDE.md b/CLAUDE.md index d9dbd43b..432af85c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,22 +5,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands ### Build System + - `pnpm build` - Build all packages using Turbo - `pnpm watch` - Watch mode for all packages - `pnpm lint` - Run ESLint across all packages - `pnpm test` - Run tests for all packages ### Package Management + - Uses `pnpm` with workspaces - Package manager is pinned to `pnpm@10.12.1` - Packages are located in `packages/`, `samples/`, and `tests/` ### Testing + - Runtime package tests: `pnpm test` (includes vitest, typing generation, and typecheck) -- CLI tests: `pnpm test` +- CLI tests: `pnpm test` - E2E tests are in `tests/e2e/` directory ### ZenStack CLI Commands + - `npx zenstack init` - Initialize ZenStack in a project - `npx zenstack generate` - Compile ZModel schema to TypeScript - `npx zenstack db push` - Sync schema to database (uses Prisma) @@ -30,36 +34,42 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Architecture Overview ### Core Components + - **@zenstackhq/runtime** - Main database client and ORM engine built on Kysely - **@zenstackhq/cli** - Command line interface and project management - **@zenstackhq/language** - ZModel language specification and parser (uses Langium) - **@zenstackhq/sdk** - Code generation utilities and schema processing ### Key Architecture Patterns + - **Monorepo Structure**: Uses pnpm workspaces with Turbo for build orchestration - **Language-First Design**: ZModel DSL compiles to TypeScript, not runtime code generation - **Kysely-Based ORM**: V3 uses Kysely as query builder instead of Prisma runtime dependency - **Plugin Architecture**: Runtime plugins for query interception and entity mutation hooks ### ZModel to TypeScript Flow + 1. ZModel schema (`schema.zmodel`) defines database structure and policies 2. `zenstack generate` compiles ZModel to TypeScript schema (`schema.ts`) 3. Schema is used to instantiate `ZenStackClient` with type-safe CRUD operations 4. Client provides both high-level ORM API and low-level Kysely query builder ### Package Dependencies + - **Runtime**: Depends on Kysely, Zod, and various utility libraries - **CLI**: Depends on language package, Commander.js, and Prisma (for migrations) - **Language**: Uses Langium for grammar parsing and AST generation - **Database Support**: SQLite (better-sqlite3) and PostgreSQL (pg) only ### Testing Strategy + - Runtime package has comprehensive client API tests and policy tests - CLI has action-specific tests for commands - E2E tests validate real-world schema compatibility (cal.com, formbricks, trigger.dev) - Type coverage tests ensure TypeScript inference works correctly ## Key Differences from Prisma + - No runtime dependency on @prisma/client - Pure TypeScript implementation without Rust/WASM - Built-in access control and validation (coming soon) @@ -67,7 +77,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Schema-first approach with ZModel DSL extension of Prisma schema language ## Development Notes + - Always run `zenstack generate` after modifying ZModel schemas - Database migrations still use Prisma CLI under the hood - Plugin system allows interception at ORM, Kysely, and entity mutation levels -- Computed fields are evaluated at database level for performance \ No newline at end of file +- Computed fields are evaluated at database level for performance diff --git a/packages/cli/src/actions/validate.ts b/packages/cli/src/actions/validate.ts index c04f9b11..8ae9932c 100644 --- a/packages/cli/src/actions/validate.ts +++ b/packages/cli/src/actions/validate.ts @@ -19,4 +19,4 @@ export async function run(options: Options) { // Re-throw to maintain CLI exit code behavior throw error; } -} \ No newline at end of file +} diff --git a/packages/sdk/src/ts-schema-generator.ts b/packages/sdk/src/ts-schema-generator.ts index 63f6f243..91e04112 100644 --- a/packages/sdk/src/ts-schema-generator.ts +++ b/packages/sdk/src/ts-schema-generator.ts @@ -42,7 +42,7 @@ import path from 'node:path'; import { match } from 'ts-pattern'; import * as ts from 'typescript'; import { ModelUtils } from '.'; -import { getAttribute, getAuthDecl, hasAttribute, isIdField, isUniqueField } from './model-utils'; +import { getAttribute, getAuthDecl, hasAttribute, isUniqueField } from './model-utils'; export class TsSchemaGenerator { public async generate(schemaFile: string, pluginModelFiles: string[], outputDir: string) { diff --git a/tests/e2e/prisma-consistency/attributes.test.ts b/tests/e2e/prisma-consistency/attributes.test.ts index be0eeb40..0e1b8044 100644 --- a/tests/e2e/prisma-consistency/attributes.test.ts +++ b/tests/e2e/prisma-consistency/attributes.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Attributes Validation', () => { let tester: ZenStackValidationTester; @@ -57,4 +63,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/basic-models.test.ts b/tests/e2e/prisma-consistency/basic-models.test.ts index 067ab9c9..bb075dbe 100644 --- a/tests/e2e/prisma-consistency/basic-models.test.ts +++ b/tests/e2e/prisma-consistency/basic-models.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Basic Models Validation', () => { let tester: ZenStackValidationTester; @@ -96,4 +102,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/compound-ids.test.ts b/tests/e2e/prisma-consistency/compound-ids.test.ts index 90b42f9b..017d1f53 100644 --- a/tests/e2e/prisma-consistency/compound-ids.test.ts +++ b/tests/e2e/prisma-consistency/compound-ids.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Compound IDs Validation', () => { let tester: ZenStackValidationTester; @@ -44,4 +50,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/datasource.test.ts b/tests/e2e/prisma-consistency/datasource.test.ts index 7746dbf8..2c02c0de 100644 --- a/tests/e2e/prisma-consistency/datasource.test.ts +++ b/tests/e2e/prisma-consistency/datasource.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Datasource Validation', () => { let tester: ZenStackValidationTester; @@ -61,4 +67,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/enums.test.ts b/tests/e2e/prisma-consistency/enums.test.ts index 9a0719c3..c1e6cbc2 100644 --- a/tests/e2e/prisma-consistency/enums.test.ts +++ b/tests/e2e/prisma-consistency/enums.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Enums Validation', () => { let tester: ZenStackValidationTester; @@ -50,4 +56,4 @@ model User { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/field-types.test.ts b/tests/e2e/prisma-consistency/field-types.test.ts index ac9a0081..d6a51931 100644 --- a/tests/e2e/prisma-consistency/field-types.test.ts +++ b/tests/e2e/prisma-consistency/field-types.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema, sqliteSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, + sqliteSchema, +} from './test-utils'; describe('Field Types Validation', () => { let tester: ZenStackValidationTester; @@ -52,4 +59,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relation-validation.test.ts b/tests/e2e/prisma-consistency/relation-validation.test.ts index acf35ee3..7ad878be 100644 --- a/tests/e2e/prisma-consistency/relation-validation.test.ts +++ b/tests/e2e/prisma-consistency/relation-validation.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Relation Validation Rules', () => { let tester: ZenStackValidationTester; @@ -160,4 +166,4 @@ model Post { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-many-to-many.test.ts b/tests/e2e/prisma-consistency/relations-many-to-many.test.ts index 7a89de96..9322948b 100644 --- a/tests/e2e/prisma-consistency/relations-many-to-many.test.ts +++ b/tests/e2e/prisma-consistency/relations-many-to-many.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Many-to-Many Relations Validation', () => { let tester: ZenStackValidationTester; @@ -82,4 +88,4 @@ model Post { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts index dc4048a8..506fc2d4 100644 --- a/tests/e2e/prisma-consistency/relations-one-to-many.test.ts +++ b/tests/e2e/prisma-consistency/relations-one-to-many.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('One-to-Many Relations Validation', () => { let tester: ZenStackValidationTester; @@ -75,4 +81,4 @@ model Post { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-one-to-one.test.ts b/tests/e2e/prisma-consistency/relations-one-to-one.test.ts index b73726dd..10459241 100644 --- a/tests/e2e/prisma-consistency/relations-one-to-one.test.ts +++ b/tests/e2e/prisma-consistency/relations-one-to-one.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('One-to-One Relations Validation', () => { let tester: ZenStackValidationTester; @@ -96,4 +102,4 @@ model Profile { expectValidationFailure(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/relations-self.test.ts b/tests/e2e/prisma-consistency/relations-self.test.ts index 49077c6a..6a283685 100644 --- a/tests/e2e/prisma-consistency/relations-self.test.ts +++ b/tests/e2e/prisma-consistency/relations-self.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Self Relations Validation', () => { let tester: ZenStackValidationTester; @@ -60,4 +66,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/prisma-consistency/test-utils.ts b/tests/e2e/prisma-consistency/test-utils.ts index 727426a5..ba6ba3a2 100644 --- a/tests/e2e/prisma-consistency/test-utils.ts +++ b/tests/e2e/prisma-consistency/test-utils.ts @@ -115,4 +115,4 @@ datasource db { provider = "sqlite" url = "file:./dev.db" } -`; \ No newline at end of file +`; diff --git a/tests/e2e/prisma-consistency/unique-constraints.test.ts b/tests/e2e/prisma-consistency/unique-constraints.test.ts index c39d456e..dbb0cbf4 100644 --- a/tests/e2e/prisma-consistency/unique-constraints.test.ts +++ b/tests/e2e/prisma-consistency/unique-constraints.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { ZenStackValidationTester, createTestDir, expectValidationSuccess, expectValidationFailure, baseSchema } from './test-utils'; +import { + ZenStackValidationTester, + createTestDir, + expectValidationSuccess, + expectValidationFailure, + baseSchema, +} from './test-utils'; describe('Unique Constraints Validation', () => { let tester: ZenStackValidationTester; @@ -60,4 +66,4 @@ model User { expectValidationSuccess(result); }); -}); \ No newline at end of file +}); From 08d8e47c72b0cd472953c1b0c5ad526422a9f6bf Mon Sep 17 00:00:00 2001 From: ymc9 <104139426+ymc9@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:41:10 +0800 Subject: [PATCH 3/3] update --- packages/language/src/generated/ast.ts | 2 +- packages/language/src/generated/grammar.ts | 2 +- packages/language/src/zmodel.langium | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/language/src/generated/ast.ts b/packages/language/src/generated/ast.ts index 1cc1205c..e759aa1f 100644 --- a/packages/language/src/generated/ast.ts +++ b/packages/language/src/generated/ast.ts @@ -62,12 +62,12 @@ export type ZModelKeywordNames = | "attribute" | "datasource" | "enum" + | "extends" | "false" | "function" | "generator" | "import" | "in" - | "inherits" | "model" | "mutation" | "null" diff --git a/packages/language/src/generated/grammar.ts b/packages/language/src/generated/grammar.ts index 56f9d901..02260ccd 100644 --- a/packages/language/src/generated/grammar.ts +++ b/packages/language/src/generated/grammar.ts @@ -2134,7 +2134,7 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel "elements": [ { "$type": "Keyword", - "value": "inherits" + "value": "extends" }, { "$type": "Assignment", diff --git a/packages/language/src/zmodel.langium b/packages/language/src/zmodel.langium index 2bb5f3dd..8d279787 100644 --- a/packages/language/src/zmodel.langium +++ b/packages/language/src/zmodel.langium @@ -178,7 +178,7 @@ fragment WithClause: 'with' mixins+=[TypeDef] (','? mixins+=[TypeDef])*; fragment ExtendsClause: - 'inherits' baseModel=[DataModel]; + 'extends' baseModel=[DataModel]; DataField: (comments+=TRIPLE_SLASH_COMMENT)*