diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 1df0c24d8..511bd9326 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -14,7 +14,7 @@ on: branches: ['dev', 'main', 'canary'] jobs: - build: + build-test: runs-on: ubuntu-latest strategy: @@ -35,11 +35,14 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: | - if [[ $GITHUB_REF == 'refs/heads/canary' ]]; then - DEFAULT_NPM_TAG=canary pnpm run build - else - DEFAULT_NPM_TAG=latest pnpm run build - fi + if [[ $GITHUB_REF == 'refs/heads/canary' ]]; then + DEFAULT_NPM_TAG=canary pnpm run build + else + DEFAULT_NPM_TAG=latest pnpm run build + fi + + - run: pnpm lint + # install again for internal dependencies - run: pnpm install --frozen-lockfile - run: pnpm run test diff --git a/README.md b/README.md index 2104ff0eb..9205a8c44 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ const MyPosts = () => { }; ``` +The following diagram gives a high-level overview of how it works. + +![Architecture](https://zenstack.dev/img/architecture-light.png) + ## Links - [Home](https://zenstack.dev) diff --git a/package.json b/package.json index b8864a499..7460dcb26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-monorepo", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "description": "", "scripts": { "build": "pnpm -r build", diff --git a/packages/language/package.json b/packages/language/package.json index 94dcb52da..f2b0565c0 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/language", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "displayName": "ZenStack modeling language compiler", "description": "ZenStack modeling language compiler", "homepage": "https://zenstack.dev", diff --git a/packages/next/package.json b/packages/next/package.json index 334de8570..d8bb8f774 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/next", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "displayName": "ZenStack Next.js integration", "description": "ZenStack Next.js integration", "homepage": "https://zenstack.dev", diff --git a/packages/plugins/react/package.json b/packages/plugins/react/package.json index bac8594ee..33d19d71d 100644 --- a/packages/plugins/react/package.json +++ b/packages/plugins/react/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/react", "displayName": "ZenStack plugin and runtime for ReactJS", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "description": "ZenStack plugin and runtime for ReactJS", "main": "index.js", "repository": { diff --git a/packages/plugins/trpc/package.json b/packages/plugins/trpc/package.json index 3ef3b3492..27dfb597b 100644 --- a/packages/plugins/trpc/package.json +++ b/packages/plugins/trpc/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/trpc", "displayName": "ZenStack plugin for tRPC", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "description": "ZenStack plugin for tRPC", "main": "index.js", "repository": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b53331462..2f3772b1d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/runtime", "displayName": "ZenStack Runtime Library", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "description": "Runtime of ZenStack for both client-side and server-side environments.", "repository": { "type": "git", diff --git a/packages/schema/package.json b/packages/schema/package.json index 84f8be89c..7bdd76848 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack Language Tools", "description": "A toolkit for building secure CRUD apps with Next.js + Typescript", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "author": { "name": "ZenStack Team" }, diff --git a/packages/schema/src/language-server/constants.ts b/packages/schema/src/language-server/constants.ts index d6e7a27fd..e2bd60339 100644 --- a/packages/schema/src/language-server/constants.ts +++ b/packages/schema/src/language-server/constants.ts @@ -12,3 +12,7 @@ export const SCALAR_TYPES = ['String', 'Int', 'Float', 'Decimal', 'BigInt', 'Boo * Name of standard library module */ export const STD_LIB_MODULE_NAME = 'stdlib.zmodel'; + +export enum IssueCodes { + MissingOppositeRelation = 'miss-opposite-relation', +} diff --git a/packages/schema/src/language-server/validator/datamodel-validator.ts b/packages/schema/src/language-server/validator/datamodel-validator.ts index 7c7742af9..828e40c26 100644 --- a/packages/schema/src/language-server/validator/datamodel-validator.ts +++ b/packages/schema/src/language-server/validator/datamodel-validator.ts @@ -15,7 +15,7 @@ import { import { ValidationAcceptor } from 'langium'; import pluralize from 'pluralize'; import { analyzePolicies } from '../../utils/ast-utils'; -import { SCALAR_TYPES } from '../constants'; +import { IssueCodes, SCALAR_TYPES } from '../constants'; import { AstValidator } from '../types'; import { assignableToAttributeParam, validateDuplicatedDeclarations } from './utils'; @@ -297,7 +297,7 @@ export default class DataModelValidator implements AstValidator { accept( 'error', `The relation field "${field.name}" on model "${field.$container.name}" is missing an opposite relation field on model "${oppositeModel.name}"`, - { node: field } + { node: field, code: IssueCodes.MissingOppositeRelation } ); return; } else if (oppositeFields.length > 1) { diff --git a/packages/schema/src/language-server/validator/utils.ts b/packages/schema/src/language-server/validator/utils.ts index b15de1e91..4dfaff554 100644 --- a/packages/schema/src/language-server/validator/utils.ts +++ b/packages/schema/src/language-server/validator/utils.ts @@ -7,9 +7,11 @@ import { ExpressionType, isArrayExpr, isDataModelField, + isEnum, isLiteralExpr, isReferenceExpr, } from '@zenstackhq/language/ast'; +import { resolved } from '@zenstackhq/sdk'; import { AstNode, ValidationAcceptor } from 'langium'; /** @@ -99,7 +101,19 @@ export function assignableToAttributeParam( const dstIsArray = param.type.array; const dstRef = param.type.reference; - if (dstType) { + if (isEnum(argResolvedType.decl)) { + // enum type + + let attrArgDeclType = dstRef?.ref; + if (dstType === 'ContextType' && isDataModelField(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); + } + return attrArgDeclType === argResolvedType.decl && dstIsArray === argResolvedType.array; + } else if (dstType) { + // scalar type + if (typeof argResolvedType?.decl !== 'string') { // destination type is not a reference, so argument type must be a plain expression return false; @@ -115,6 +129,8 @@ export function assignableToAttributeParam( return isReferenceExpr(arg.value) && isDataModelField(arg.value.target.ref); } } else if (dstType === 'ContextType') { + // attribute parameter type is ContextType, need to infer type from + // the attribute's container if (isDataModelField(attr.$container)) { if (!attr.$container?.type?.type) { return false; @@ -129,6 +145,7 @@ export function assignableToAttributeParam( typeAssignable(dstType, argResolvedType.decl) && (dstType === 'Any' || dstIsArray === argResolvedType.array) ); } else { + // reference type return dstRef?.ref === argResolvedType.decl && dstIsArray === argResolvedType.array; } } diff --git a/packages/schema/src/language-server/zmodel-code-action.ts b/packages/schema/src/language-server/zmodel-code-action.ts new file mode 100644 index 000000000..23a64ca62 --- /dev/null +++ b/packages/schema/src/language-server/zmodel-code-action.ts @@ -0,0 +1,133 @@ +import { DataModel, DataModelField, isDataModel } from '@zenstackhq/language/ast'; +import { + AstReflection, + CodeActionProvider, + findDeclarationNodeAtOffset, + getContainerOfType, + IndexManager, + LangiumDocument, + LangiumServices, + MaybePromise, +} from 'langium'; + +import { CodeAction, CodeActionKind, CodeActionParams, Command, Diagnostic } from 'vscode-languageserver'; +import { IssueCodes } from './constants'; +import { ZModelFormatter } from './zmodel-formatter'; + +export class ZModelCodeActionProvider implements CodeActionProvider { + protected readonly reflection: AstReflection; + protected readonly indexManager: IndexManager; + protected readonly formatter: ZModelFormatter; + + constructor(services: LangiumServices) { + this.reflection = services.shared.AstReflection; + this.indexManager = services.shared.workspace.IndexManager; + this.formatter = services.lsp.Formatter as ZModelFormatter; + } + + getCodeActions( + document: LangiumDocument, + params: CodeActionParams + ): MaybePromise | undefined> { + const result: CodeAction[] = []; + const acceptor = (ca: CodeAction | undefined) => ca && result.push(ca); + for (const diagnostic of params.context.diagnostics) { + this.createCodeActions(diagnostic, document, acceptor); + } + return result; + } + + private createCodeActions( + diagnostic: Diagnostic, + document: LangiumDocument, + accept: (ca: CodeAction | undefined) => void + ) { + switch (diagnostic.code) { + case IssueCodes.MissingOppositeRelation: + accept(this.fixMissingOppositeRelation(diagnostic, document)); + } + + return undefined; + } + + private fixMissingOppositeRelation(diagnostic: Diagnostic, document: LangiumDocument): CodeAction | undefined { + const offset = document.textDocument.offsetAt(diagnostic.range.start); + const rootCst = document.parseResult.value.$cstNode; + + if (rootCst) { + const cstNode = findDeclarationNodeAtOffset(rootCst, offset); + + const astNode = cstNode?.element as DataModelField; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const oppositeModel = astNode.type.reference!.ref! as DataModel; + + const lastField = oppositeModel.fields[oppositeModel.fields.length - 1]; + + const container = getContainerOfType(cstNode?.element, isDataModel) as DataModel; + + const idField = container.fields.find((f) => + f.attributes.find((attr) => attr.decl.ref?.name === '@id') + ) as DataModelField; + + if (container && container.$cstNode && idField) { + // indent + let indent = '\t'; + const formatOptions = this.formatter.getFormatOptions(); + if (formatOptions?.insertSpaces) { + indent = ' '.repeat(formatOptions.tabSize); + } + indent = indent.repeat(this.formatter.getIndent()); + + const typeName = container.name; + const fieldName = this.lowerCaseFirstLetter(typeName); + + // might already exist + let referenceField = ''; + + const idFieldName = idField.name; + const referenceIdFieldName = fieldName + this.upperCaseFirstLetter(idFieldName); + + if (!oppositeModel.fields.find((f) => f.name === referenceIdFieldName)) { + referenceField = '\n' + indent + `${referenceIdFieldName} ${idField.type.type}`; + } + + return { + title: `Add opposite relation fields on ${oppositeModel.name}`, + kind: CodeActionKind.QuickFix, + diagnostics: [diagnostic], + isPreferred: false, + edit: { + changes: { + [document.textDocument.uri]: [ + { + range: { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + start: lastField.$cstNode!.range.end, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + end: lastField.$cstNode!.range.end, + }, + newText: + '\n' + + indent + + `${fieldName} ${typeName} @relation(fields: [${referenceIdFieldName}], references: [${idFieldName}])` + + referenceField, + }, + ], + }, + }, + }; + } + } + + return undefined; + } + + private lowerCaseFirstLetter(str: string) { + return str.charAt(0).toLowerCase() + str.slice(1); + } + + private upperCaseFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); + } +} diff --git a/packages/schema/src/language-server/zmodel-formatter.ts b/packages/schema/src/language-server/zmodel-formatter.ts index bf14e7268..c9300fa7a 100644 --- a/packages/schema/src/language-server/zmodel-formatter.ts +++ b/packages/schema/src/language-server/zmodel-formatter.ts @@ -1,13 +1,31 @@ -import { AbstractFormatter, AstNode, Formatting } from 'langium'; +import { AbstractFormatter, AstNode, Formatting, LangiumDocument } from 'langium'; import * as ast from '@zenstackhq/language/ast'; +import { FormattingOptions, Range, TextEdit } from 'vscode-languageserver'; export class ZModelFormatter extends AbstractFormatter { + private formatOptions?: FormattingOptions; protected format(node: AstNode): void { const formatter = this.getNodeFormatter(node); - if (ast.isAbstractDeclaration(node)) { + if (ast.isDataModelField(node)) { + formatter.property('type').prepend(Formatting.oneSpace()); + if (node.attributes.length > 0) { + formatter.properties('attributes').prepend(Formatting.oneSpace()); + } + } else if (ast.isDataModelFieldAttribute(node)) { + formatter.keyword('(').surround(Formatting.noSpace()); + formatter.keyword(')').prepend(Formatting.noSpace()); + formatter.keyword(',').append(Formatting.oneSpace()); + if (node.args.length > 1) { + formatter.nodes(...node.args.slice(1)).prepend(Formatting.oneSpace()); + } + } else if (ast.isAttributeArg(node)) { + formatter.keyword(':').prepend(Formatting.noSpace()); + formatter.keyword(':').append(Formatting.oneSpace()); + } else if (ast.isAbstractDeclaration(node)) { const bracesOpen = formatter.keyword('{'); const bracesClose = formatter.keyword('}'); + // this line decide the indent count return by this.getIndent() formatter.interior(bracesOpen, bracesClose).prepend(Formatting.indent()); bracesOpen.prepend(Formatting.oneSpace()); bracesClose.prepend(Formatting.newLine()); @@ -17,4 +35,21 @@ export class ZModelFormatter extends AbstractFormatter { nodes.prepend(Formatting.noIndent()); } } + + protected override doDocumentFormat( + document: LangiumDocument, + options: FormattingOptions, + range?: Range | undefined + ): TextEdit[] { + this.formatOptions = options; + return super.doDocumentFormat(document, options, range); + } + + public getFormatOptions(): FormattingOptions | undefined { + return this.formatOptions; + } + + public getIndent() { + return 1; + } } diff --git a/packages/schema/src/language-server/zmodel-module.ts b/packages/schema/src/language-server/zmodel-module.ts index 5a3507789..077675ed5 100644 --- a/packages/schema/src/language-server/zmodel-module.ts +++ b/packages/schema/src/language-server/zmodel-module.ts @@ -20,6 +20,7 @@ import { import { TextDocuments } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { ZModelValidationRegistry, ZModelValidator } from './validator/zmodel-validator'; +import { ZModelCodeActionProvider } from './zmodel-code-action'; import { ZModelFormatter } from './zmodel-formatter'; import { ZModelLinker } from './zmodel-linker'; import { ZModelScopeComputation } from './zmodel-scope'; @@ -56,6 +57,7 @@ export const ZModelModule: Module new ZModelFormatter(), + CodeActionProvider: (services) => new ZModelCodeActionProvider(services), }, }; diff --git a/packages/schema/tests/schema/validation/attribute-validation.test.ts b/packages/schema/tests/schema/validation/attribute-validation.test.ts index e713a43ae..0d49e050f 100644 --- a/packages/schema/tests/schema/validation/attribute-validation.test.ts +++ b/packages/schema/tests/schema/validation/attribute-validation.test.ts @@ -314,4 +314,19 @@ describe('Attribute tests', () => { `) ).toContain('attribute "@length" cannot be used on this type of field'); }); + + it('enum as default', async () => { + await loadModel(` + ${prelude} + + enum E { + E1 + E2 + } + + model M { + e E @default(E1) + } + `); + }); }); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 018906cba..bff8cc0ba 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "description": "ZenStack plugin development SDK", "main": "index.js", "scripts": { diff --git a/tests/integration/test-run/package-lock.json b/tests/integration/test-run/package-lock.json index 43ae64eac..ee995b27a 100644 --- a/tests/integration/test-run/package-lock.json +++ b/tests/integration/test-run/package-lock.json @@ -126,7 +126,7 @@ }, "../../../packages/runtime/dist": { "name": "@zenstackhq/runtime", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "license": "MIT", "dependencies": { "@types/bcryptjs": "^2.4.2", @@ -160,7 +160,7 @@ }, "../../../packages/schema/dist": { "name": "zenstack", - "version": "1.0.0-alpha.31", + "version": "1.0.0-alpha.33", "hasInstallScript": true, "license": "MIT", "dependencies": {