Skip to content

Commit 2fa3aee

Browse files
authored
feat: support abstract model (#308)
1 parent 4e57b96 commit 2fa3aee

File tree

19 files changed

+540
-60
lines changed

19 files changed

+540
-60
lines changed

packages/language/src/ast.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ declare module './generated/ast' {
4343
*/
4444
$resolvedParam?: AttributeParam;
4545
}
46+
47+
interface DataModel {
48+
/**
49+
* Resolved fields, include inherited fields
50+
*/
51+
$resolvedFields: Array<DataModelField>;
52+
}
53+
54+
interface DataModelField {
55+
$isInherited?: boolean;
56+
}
4657
}
4758

4859
declare module 'langium' {

packages/language/src/generated/ast.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ export interface DataModel extends AstNode {
168168
attributes: Array<DataModelAttribute>
169169
comments: Array<string>
170170
fields: Array<DataModelField>
171+
isAbstract: boolean
171172
name: RegularID
173+
superTypes: Array<Reference<DataModel>>
172174
}
173175

174176
export const DataModel = 'DataModel';
@@ -645,6 +647,9 @@ export class ZModelAstReflection extends AbstractAstReflection {
645647
case 'FunctionParamType:reference': {
646648
return TypeDeclaration;
647649
}
650+
case 'DataModel:superTypes': {
651+
return DataModel;
652+
}
648653
case 'InvocationExpr:function': {
649654
return FunctionDecl;
650655
}
@@ -710,7 +715,9 @@ export class ZModelAstReflection extends AbstractAstReflection {
710715
mandatory: [
711716
{ name: 'attributes', type: 'array' },
712717
{ name: 'comments', type: 'array' },
713-
{ name: 'fields', type: 'array' }
718+
{ name: 'fields', type: 'array' },
719+
{ name: 'isAbstract', type: 'boolean' },
720+
{ name: 'superTypes', type: 'array' }
714721
]
715722
};
716723
}

packages/language/src/generated/grammar.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,16 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel
16801680
},
16811681
"cardinality": "*"
16821682
},
1683+
{
1684+
"$type": "Assignment",
1685+
"feature": "isAbstract",
1686+
"operator": "?=",
1687+
"terminal": {
1688+
"$type": "Keyword",
1689+
"value": "abstract"
1690+
},
1691+
"cardinality": "?"
1692+
},
16831693
{
16841694
"$type": "Keyword",
16851695
"value": "model"
@@ -1696,6 +1706,50 @@ export const ZModelGrammar = (): Grammar => loadedZModelGrammar ?? (loadedZModel
16961706
"arguments": []
16971707
}
16981708
},
1709+
{
1710+
"$type": "Group",
1711+
"elements": [
1712+
{
1713+
"$type": "Keyword",
1714+
"value": "extends"
1715+
},
1716+
{
1717+
"$type": "Assignment",
1718+
"feature": "superTypes",
1719+
"operator": "+=",
1720+
"terminal": {
1721+
"$type": "CrossReference",
1722+
"type": {
1723+
"$ref": "#/rules@30"
1724+
},
1725+
"deprecatedSyntax": false
1726+
}
1727+
},
1728+
{
1729+
"$type": "Group",
1730+
"elements": [
1731+
{
1732+
"$type": "Keyword",
1733+
"value": ","
1734+
},
1735+
{
1736+
"$type": "Assignment",
1737+
"feature": "superTypes",
1738+
"operator": "+=",
1739+
"terminal": {
1740+
"$type": "CrossReference",
1741+
"type": {
1742+
"$ref": "#/rules@30"
1743+
},
1744+
"deprecatedSyntax": false
1745+
}
1746+
}
1747+
],
1748+
"cardinality": "*"
1749+
}
1750+
],
1751+
"cardinality": "?"
1752+
},
16991753
{
17001754
"$type": "Keyword",
17011755
"value": "{"

packages/language/src/zmodel.langium

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,9 @@ Argument:
156156
// model
157157
DataModel:
158158
(comments+=TRIPLE_SLASH_COMMENT)*
159-
'model' name=RegularID '{' (
160-
fields+=DataModelField
159+
(isAbstract?='abstract')? 'model' name=RegularID
160+
('extends' superTypes+=[DataModel] (',' superTypes+=[DataModel])*)? '{' (
161+
fields+=DataModelField
161162
| attributes+=DataModelAttribute
162163
)+
163164
'}';

packages/language/syntaxes/zmodel.tmLanguage.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
{
1212
"name": "keyword.control.zmodel",
13-
"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"
13+
"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"
1414
},
1515
{
1616
"name": "string.quoted.double.zmodel",

packages/schema/src/cli/cli-util.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { URI } from 'vscode-uri';
1212
import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from '../language-server/constants';
1313
import { createZModelServices, ZModelServices } from '../language-server/zmodel-module';
1414
import { Context } from '../types';
15-
import { resolveImport, resolveTransitiveImports } from '../utils/ast-utils';
15+
import { mergeBaseModel, resolveImport, resolveTransitiveImports } from '../utils/ast-utils';
1616
import { ensurePackage, installPackage, PackageManagers } from '../utils/pkg-utils';
1717
import { getVersion } from '../utils/version-utils';
1818
import { CliError } from './cli-error';
@@ -125,7 +125,11 @@ export async function loadDocument(fileName: string): Promise<Model> {
125125
}
126126
);
127127

128-
const validationErrors = (document.diagnostics ?? []).filter((e) => e.severity === 1);
128+
const validationErrors = langiumDocuments.all
129+
.flatMap((d) => d.diagnostics ?? [])
130+
.filter((e) => e.severity === 1)
131+
.toArray();
132+
129133
if (validationErrors.length > 0) {
130134
console.error(colors.red('Validation errors:'));
131135
for (const validationError of validationErrors) {
@@ -145,6 +149,9 @@ export async function loadDocument(fileName: string): Promise<Model> {
145149
mergeImportsDeclarations(langiumDocuments, model);
146150

147151
validationAfterMerge(model);
152+
153+
mergeBaseModel(model);
154+
148155
return model;
149156
}
150157

@@ -179,7 +186,9 @@ export function eagerLoadAllImports(
179186
}
180187
}
181188

182-
return Array.from(uris).map((e) => URI.parse(e));
189+
return Array.from(uris)
190+
.filter((x) => uriString != x)
191+
.map((e) => URI.parse(e));
183192
}
184193

185194
export function mergeImportsDeclarations(documents: LangiumDocuments, model: Model) {

packages/schema/src/language-server/validator/datamodel-validator.ts

Lines changed: 87 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ReferenceExpr,
88
} from '@zenstackhq/language/ast';
99
import { analyzePolicies, getLiteral } from '@zenstackhq/sdk';
10-
import { ValidationAcceptor } from 'langium';
10+
import { AstNode, DiagnosticInfo, getDocument, ValidationAcceptor } from 'langium';
1111
import { IssueCodes, SCALAR_TYPES } from '../constants';
1212
import { AstValidator } from '../types';
1313
import { getIdFields, getUniqueFields } from '../utils';
@@ -18,13 +18,14 @@ import { validateAttributeApplication, validateDuplicatedDeclarations } from './
1818
*/
1919
export default class DataModelValidator implements AstValidator<DataModel> {
2020
validate(dm: DataModel, accept: ValidationAcceptor): void {
21-
validateDuplicatedDeclarations(dm.fields, accept);
21+
this.validateBaseAbstractModel(dm, accept);
22+
validateDuplicatedDeclarations(dm.$resolvedFields, accept);
2223
this.validateAttributes(dm, accept);
2324
this.validateFields(dm, accept);
2425
}
2526

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

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

5960
dm.fields.forEach((field) => this.validateField(field, accept));
61+
62+
if (!dm.isAbstract) {
63+
dm.$resolvedFields
64+
.filter((x) => isDataModel(x.type.reference?.ref))
65+
.forEach((y) => {
66+
this.validateRelationField(y, accept);
67+
});
68+
}
6069
}
6170

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

7180
field.attributes.forEach((attr) => validateAttributeApplication(attr, accept));
72-
73-
if (isDataModel(field.type.reference?.ref)) {
74-
this.validateRelationField(field, accept);
75-
}
7681
}
7782

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

207-
let oppositeFields = oppositeModel.fields.filter((f) => f.type.reference?.ref === field.$container);
213+
// Use name because the current document might be updated
214+
let oppositeFields = oppositeModel.$resolvedFields.filter(
215+
(f) => f.type.reference?.ref?.name === field.$container.name
216+
);
208217
oppositeFields = oppositeFields.filter((f) => {
209218
const fieldRel = this.parseRelation(f);
210219
return fieldRel.valid && fieldRel.name === thisRelation.name;
211220
});
212221

213222
if (oppositeFields.length === 0) {
223+
const node = field.$isInherited ? field.$container : field;
224+
const info: DiagnosticInfo<AstNode, string> = { node, code: IssueCodes.MissingOppositeRelation };
225+
226+
let relationFieldDocUri: string;
227+
let relationDataModelName: string;
228+
229+
if (field.$isInherited) {
230+
info.property = 'name';
231+
const container = field.$container as DataModel;
232+
const abstractContainer = container.superTypes.find((x) =>
233+
x.ref?.fields.find((f) => f.name === field.name)
234+
)?.ref as DataModel;
235+
236+
relationFieldDocUri = getDocument(abstractContainer).textDocument.uri;
237+
relationDataModelName = abstractContainer.name;
238+
} else {
239+
relationFieldDocUri = getDocument(field).textDocument.uri;
240+
relationDataModelName = field.$container.name;
241+
}
242+
243+
const data: MissingOppositeRelationData = {
244+
relationFieldName: field.name,
245+
relationDataModelName,
246+
relationFieldDocUri,
247+
dataModelName: field.$container.name,
248+
};
249+
250+
info.data = data;
251+
214252
accept(
215253
'error',
216254
`The relation field "${field.name}" on model "${field.$container.name}" is missing an opposite relation field on model "${oppositeModel.name}"`,
217-
{ node: field, code: IssueCodes.MissingOppositeRelation }
255+
info
218256
);
219257
return;
220258
} else if (oppositeFields.length > 1) {
221-
oppositeFields.forEach((f) => {
222-
if (this.isSelfRelation(f)) {
223-
// self relations are partial
224-
// https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations
225-
} else {
226-
accept(
227-
'error',
228-
`Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${
229-
oppositeModel.name
230-
}" refer to the same relation to model "${field.$container.name}"`,
231-
{ node: f }
232-
);
233-
}
234-
});
259+
oppositeFields
260+
.filter((x) => !x.$isInherited)
261+
.forEach((f) => {
262+
if (this.isSelfRelation(f)) {
263+
// self relations are partial
264+
// https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations
265+
} else {
266+
accept(
267+
'error',
268+
`Fields ${oppositeFields.map((f) => '"' + f.name + '"').join(', ')} on model "${
269+
oppositeModel.name
270+
}" refer to the same relation to model "${field.$container.name}"`,
271+
{ node: f }
272+
);
273+
}
274+
});
235275
return;
236276
}
237277

@@ -317,4 +357,26 @@ export default class DataModelValidator implements AstValidator<DataModel> {
317357
});
318358
}
319359
}
360+
361+
private validateBaseAbstractModel(model: DataModel, accept: ValidationAcceptor) {
362+
model.superTypes.forEach((superType, index) => {
363+
if (!superType.ref?.isAbstract)
364+
accept('error', `Model ${superType.$refText} cannot be extended because it's not abstract`, {
365+
node: model,
366+
property: 'superTypes',
367+
index,
368+
});
369+
});
370+
}
371+
}
372+
373+
export interface MissingOppositeRelationData {
374+
relationDataModelName: string;
375+
relationFieldName: string;
376+
// it might be the abstract model in the imported document
377+
relationFieldDocUri: string;
378+
379+
// the name of DataModel that the relation field belongs to.
380+
// the document is the same with the error node.
381+
dataModelName: string;
320382
}

packages/schema/src/language-server/validator/utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,16 @@ export function validateDuplicatedDeclarations(
3737

3838
for (const [name, decls] of Object.entries<AstNode[]>(groupByName)) {
3939
if (decls.length > 1) {
40+
let errorField = decls[1];
41+
if (decls[0].$type === 'DataModelField') {
42+
const nonInheritedFields = decls.filter((x) => !(x as DataModelField).$isInherited);
43+
if (nonInheritedFields.length > 0) {
44+
errorField = nonInheritedFields.slice(-1)[0];
45+
}
46+
}
47+
4048
accept('error', `Duplicated declaration name "${name}"`, {
41-
node: decls[1],
49+
node: errorField,
4250
});
4351
}
4452
}

0 commit comments

Comments
 (0)