Skip to content

feat: support abstract model #308

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Apr 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/language/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ declare module './generated/ast' {
*/
$resolvedParam?: AttributeParam;
}

interface DataModel {
/**
* Resolved fields, include inherited fields
*/
$resolvedFields: Array<DataModelField>;
}

interface DataModelField {
$isInherited?: boolean;
}
}

declare module 'langium' {
Expand Down
9 changes: 8 additions & 1 deletion packages/language/src/generated/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ export interface DataModel extends AstNode {
attributes: Array<DataModelAttribute>
comments: Array<string>
fields: Array<DataModelField>
isAbstract: boolean
name: RegularID
superTypes: Array<Reference<DataModel>>
}

export const DataModel = 'DataModel';
Expand Down Expand Up @@ -645,6 +647,9 @@ export class ZModelAstReflection extends AbstractAstReflection {
case 'FunctionParamType:reference': {
return TypeDeclaration;
}
case 'DataModel:superTypes': {
return DataModel;
}
case 'InvocationExpr:function': {
return FunctionDecl;
}
Expand Down Expand Up @@ -710,7 +715,9 @@ export class ZModelAstReflection extends AbstractAstReflection {
mandatory: [
{ name: 'attributes', type: 'array' },
{ name: 'comments', type: 'array' },
{ name: 'fields', type: 'array' }
{ name: 'fields', type: 'array' },
{ name: 'isAbstract', type: 'boolean' },
{ name: 'superTypes', type: 'array' }
]
};
}
Expand Down
54 changes: 54 additions & 0 deletions packages/language/src/generated/grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,16 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel
},
"cardinality": "*"
},
{
"$type": "Assignment",
"feature": "isAbstract",
"operator": "?=",
"terminal": {
"$type": "Keyword",
"value": "abstract"
},
"cardinality": "?"
},
{
"$type": "Keyword",
"value": "model"
Expand All @@ -1696,6 +1706,50 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel
"arguments": []
}
},
{
"$type": "Group",
"elements": [
{
"$type": "Keyword",
"value": "extends"
},
{
"$type": "Assignment",
"feature": "superTypes",
"operator": "+=",
"terminal": {
"$type": "CrossReference",
"type": {
"$ref": "#/rules@30"
},
"deprecatedSyntax": false
}
},
{
"$type": "Group",
"elements": [
{
"$type": "Keyword",
"value": ","
},
{
"$type": "Assignment",
"feature": "superTypes",
"operator": "+=",
"terminal": {
"$type": "CrossReference",
"type": {
"$ref": "#/rules@30"
},
"deprecatedSyntax": false
}
}
],
"cardinality": "*"
}
],
"cardinality": "?"
},
{
"$type": "Keyword",
"value": "{"
Expand Down
5 changes: 3 additions & 2 deletions packages/language/src/zmodel.langium
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ Argument:
// model
DataModel:
(comments+=TRIPLE_SLASH_COMMENT)*
'model' name=RegularID '{' (
fields+=DataModelField
(isAbstract?='abstract')? 'model' name=RegularID
('extends' superTypes+=[DataModel] (',' superTypes+=[DataModel])*)? '{' (
fields+=DataModelField
| attributes+=DataModelAttribute
)+
'}';
Expand Down
2 changes: 1 addition & 1 deletion packages/language/syntaxes/zmodel.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
{
"name": "keyword.control.zmodel",
"match": "\\b(Any|Asc|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|Desc|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|attribute|datasource|enum|function|generator|import|in|model|plugin|sort)\\b"
"match": "\\b(Any|Asc|BigInt|Boolean|Bytes|ContextType|DateTime|Decimal|Desc|FieldReference|Float|Int|Json|Null|Object|String|TransitiveFieldReference|Unsupported|abstract|attribute|datasource|enum|extends|function|generator|import|in|model|plugin|sort)\\b"
},
{
"name": "string.quoted.double.zmodel",
Expand Down
15 changes: 12 additions & 3 deletions packages/schema/src/cli/cli-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { URI } from 'vscode-uri';
import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from '../language-server/constants';
import { createZModelServices, ZModelServices } from '../language-server/zmodel-module';
import { Context } from '../types';
import { resolveImport, resolveTransitiveImports } from '../utils/ast-utils';
import { mergeBaseModel, resolveImport, resolveTransitiveImports } from '../utils/ast-utils';
import { ensurePackage, installPackage, PackageManagers } from '../utils/pkg-utils';
import { getVersion } from '../utils/version-utils';
import { CliError } from './cli-error';
Expand Down Expand Up @@ -125,7 +125,11 @@ export async function loadDocument(fileName: string): Promise<Model> {
}
);

const validationErrors = (document.diagnostics ?? []).filter((e) => e.severity === 1);
const validationErrors = langiumDocuments.all
.flatMap((d) => d.diagnostics ?? [])
.filter((e) => e.severity === 1)
.toArray();

if (validationErrors.length > 0) {
console.error(colors.red('Validation errors:'));
for (const validationError of validationErrors) {
Expand All @@ -145,6 +149,9 @@ export async function loadDocument(fileName: string): Promise<Model> {
mergeImportsDeclarations(langiumDocuments, model);

validationAfterMerge(model);

mergeBaseModel(model);

return model;
}

Expand Down Expand Up @@ -179,7 +186,9 @@ export function eagerLoadAllImports(
}
}

return Array.from(uris).map((e) => URI.parse(e));
return Array.from(uris)
.filter((x) => uriString != x)
.map((e) => URI.parse(e));
}

export function mergeImportsDeclarations(documents: LangiumDocuments, model: Model) {
Expand Down
112 changes: 87 additions & 25 deletions packages/schema/src/language-server/validator/datamodel-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ReferenceExpr,
} from '@zenstackhq/language/ast';
import { analyzePolicies, getLiteral } from '@zenstackhq/sdk';
import { ValidationAcceptor } from 'langium';
import { AstNode, DiagnosticInfo, getDocument, ValidationAcceptor } from 'langium';
import { IssueCodes, SCALAR_TYPES } from '../constants';
import { AstValidator } from '../types';
import { getIdFields, getUniqueFields } from '../utils';
Expand All @@ -18,13 +18,14 @@ import { validateAttributeApplication, validateDuplicatedDeclarations } from './
*/
export default class DataModelValidator implements AstValidator<DataModel> {
validate(dm: DataModel, accept: ValidationAcceptor): void {
validateDuplicatedDeclarations(dm.fields, accept);
this.validateBaseAbstractModel(dm, accept);
validateDuplicatedDeclarations(dm.$resolvedFields, accept);
this.validateAttributes(dm, accept);
this.validateFields(dm, accept);
}

private validateFields(dm: DataModel, accept: ValidationAcceptor) {
const idFields = dm.fields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id'));
const idFields = dm.$resolvedFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id'));
const modelLevelIds = getIdFields(dm);

if (idFields.length === 0 && modelLevelIds.length === 0) {
Expand Down Expand Up @@ -57,6 +58,14 @@ export default class DataModelValidator implements AstValidator<DataModel> {
}

dm.fields.forEach((field) => this.validateField(field, accept));

if (!dm.isAbstract) {
dm.$resolvedFields
.filter((x) => isDataModel(x.type.reference?.ref))
.forEach((y) => {
this.validateRelationField(y, accept);
});
}
}

private validateField(field: DataModelField, accept: ValidationAcceptor): void {
Expand All @@ -69,10 +78,6 @@ export default class DataModelValidator implements AstValidator<DataModel> {
}

field.attributes.forEach((attr) => validateAttributeApplication(attr, accept));

if (isDataModel(field.type.reference?.ref)) {
this.validateRelationField(field, accept);
}
}

private validateAttributes(dm: DataModel, accept: ValidationAcceptor) {
Expand Down Expand Up @@ -175,8 +180,9 @@ export default class DataModelValidator implements AstValidator<DataModel> {
if (relationName) {
// field's relation points to another type, and that type's opposite relation field
// points back
const oppositeModelFields = field.type.reference?.ref?.fields as DataModelField[];
if (oppositeModelFields) {
const oppositeModel = field.type.reference?.ref as DataModel;
if (oppositeModel) {
const oppositeModelFields = oppositeModel.$resolvedFields as DataModelField[];
for (const oppositeField of oppositeModelFields) {
// find the opposite relation with the matching name
const relAttr = oppositeField.attributes.find((a) => a.decl.ref?.name === '@relation');
Expand Down Expand Up @@ -204,34 +210,68 @@ export default class DataModelValidator implements AstValidator<DataModel> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const oppositeModel = field.type.reference!.ref! as DataModel;

let oppositeFields = oppositeModel.fields.filter((f) => f.type.reference?.ref === field.$container);
// Use name because the current document might be updated
let oppositeFields = oppositeModel.$resolvedFields.filter(
(f) => f.type.reference?.ref?.name === field.$container.name
);
oppositeFields = oppositeFields.filter((f) => {
const fieldRel = this.parseRelation(f);
return fieldRel.valid && fieldRel.name === thisRelation.name;
});

if (oppositeFields.length === 0) {
const node = field.$isInherited ? field.$container : field;
const info: DiagnosticInfo<AstNode, string> = { node, code: IssueCodes.MissingOppositeRelation };

let relationFieldDocUri: string;
let relationDataModelName: string;

if (field.$isInherited) {
info.property = 'name';
const container = field.$container as DataModel;
const abstractContainer = container.superTypes.find((x) =>
x.ref?.fields.find((f) => f.name === field.name)
)?.ref as DataModel;

relationFieldDocUri = getDocument(abstractContainer).textDocument.uri;
relationDataModelName = abstractContainer.name;
} else {
relationFieldDocUri = getDocument(field).textDocument.uri;
relationDataModelName = field.$container.name;
}

const data: MissingOppositeRelationData = {
relationFieldName: field.name,
relationDataModelName,
relationFieldDocUri,
dataModelName: field.$container.name,
};

info.data = data;

accept(
'error',
`The relation field "${field.name}" on model "${field.$container.name}" is missing an opposite relation field on model "${oppositeModel.name}"`,
{ node: field, code: IssueCodes.MissingOppositeRelation }
info
);
return;
} else if (oppositeFields.length > 1) {
oppositeFields.forEach((f) => {
if (this.isSelfRelation(f)) {
// self relations are partial
// https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations
} else {
accept(
'error',
`Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${
oppositeModel.name
}" refer to the same relation to model "${field.$container.name}"`,
{ node: f }
);
}
});
oppositeFields
.filter((x) => !x.$isInherited)
.forEach((f) => {
if (this.isSelfRelation(f)) {
// self relations are partial
// https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations
} else {
accept(
'error',
`Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${
oppositeModel.name
}" refer to the same relation to model "${field.$container.name}"`,
{ node: f }
);
}
});
return;
}

Expand Down Expand Up @@ -317,4 +357,26 @@ export default class DataModelValidator implements AstValidator<DataModel> {
});
}
}

private validateBaseAbstractModel(model: DataModel, accept: ValidationAcceptor) {
model.superTypes.forEach((superType, index) => {
if (!superType.ref?.isAbstract)
accept('error', `Model ${superType.$refText} cannot be extended because it's not abstract`, {
node: model,
property: 'superTypes',
index,
});
});
}
}

export interface MissingOppositeRelationData {
relationDataModelName: string;
relationFieldName: string;
// it might be the abstract model in the imported document
relationFieldDocUri: string;

// the name of DataModel that the relation field belongs to.
// the document is the same with the error node.
dataModelName: string;
}
10 changes: 9 additions & 1 deletion packages/schema/src/language-server/validator/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,16 @@ export function validateDuplicatedDeclarations(

for (const [name, decls] of Object.entries<AstNode[]>(groupByName)) {
if (decls.length > 1) {
let errorField = decls[1];
if (decls[0].$type === 'DataModelField') {
const nonInheritedFields = decls.filter((x) => !(x as DataModelField).$isInherited);
if (nonInheritedFields.length > 0) {
errorField = nonInheritedFields.slice(-1)[0];
}
}

accept('error', `Duplicated declaration name "${name}"`, {
node: decls[1],
node: errorField,
});
}
}
Expand Down
Loading