Skip to content

Commit cbf1ce3

Browse files
authored
feat: implement delegate models (create + read) (#110)
* feat: implement delegate models (create + read) * update * update * update
1 parent c80e724 commit cbf1ce3

File tree

25 files changed

+983
-80
lines changed

25 files changed

+983
-80
lines changed

packages/cli/test/ts-schema-gen.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,63 @@ type Address with Base {
266266
},
267267
});
268268
});
269+
270+
it('merges fields and attributes from base models', async () => {
271+
const { schema } = await generateTsSchema(`
272+
model Base {
273+
id String @id @default(uuid())
274+
createdAt DateTime @default(now())
275+
updatedAt DateTime @updatedAt
276+
type String
277+
@@delegate(type)
278+
}
279+
280+
model User extends Base {
281+
email String @unique
282+
}
283+
`);
284+
expect(schema).toMatchObject({
285+
models: {
286+
Base: {
287+
fields: {
288+
id: {
289+
type: 'String',
290+
id: true,
291+
default: expect.objectContaining({ function: 'uuid', kind: 'call' }),
292+
},
293+
createdAt: {
294+
type: 'DateTime',
295+
default: expect.objectContaining({ function: 'now', kind: 'call' }),
296+
},
297+
updatedAt: { type: 'DateTime', updatedAt: true },
298+
type: { type: 'String' },
299+
},
300+
attributes: [
301+
{
302+
name: '@@delegate',
303+
args: [{ name: 'discriminator', value: { kind: 'field', field: 'type' } }],
304+
},
305+
],
306+
isDelegate: true,
307+
},
308+
User: {
309+
baseModel: 'Base',
310+
fields: {
311+
id: { type: 'String' },
312+
createdAt: {
313+
type: 'DateTime',
314+
default: expect.objectContaining({ function: 'now', kind: 'call' }),
315+
originModel: 'Base',
316+
},
317+
updatedAt: { type: 'DateTime', updatedAt: true, originModel: 'Base' },
318+
type: { type: 'String', originModel: 'Base' },
319+
email: { type: 'String' },
320+
},
321+
uniqueFields: expect.objectContaining({
322+
email: { type: 'String' },
323+
}),
324+
},
325+
},
326+
});
327+
});
269328
});

packages/language/src/ast.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { AstNode } from 'langium';
2-
import { AbstractDeclaration, BinaryExpr, DataModel, type ExpressionType } from './generated/ast';
2+
import { AbstractDeclaration, BinaryExpr, DataField, DataModel, type ExpressionType } from './generated/ast';
33

44
export type { AstNode, Reference } from 'langium';
55
export * from './generated/ast';
@@ -46,14 +46,6 @@ declare module './ast' {
4646
$resolvedParam?: AttributeParam;
4747
}
4848

49-
interface DataField {
50-
$inheritedFrom?: DataModel;
51-
}
52-
53-
interface DataModelAttribute {
54-
$inheritedFrom?: DataModel;
55-
}
56-
5749
export interface DataModel {
5850
/**
5951
* All fields including those marked with `@ignore`

packages/language/src/utils.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,6 @@ export function resolved<T extends AstNode>(ref: Reference<T>): T {
161161
return ref.ref;
162162
}
163163

164-
export function getModelFieldsWithBases(model: DataModel, includeDelegate = true) {
165-
return [...model.fields, ...getRecursiveBases(model, includeDelegate).flatMap((base) => base.fields)];
166-
}
167-
168164
export function getRecursiveBases(
169165
decl: DataModel | TypeDef,
170166
includeDelegate = true,
@@ -533,22 +529,51 @@ export function isMemberContainer(node: unknown): node is DataModel | TypeDef {
533529
return isDataModel(node) || isTypeDef(node);
534530
}
535531

536-
export function getAllFields(decl: DataModel | TypeDef, includeIgnored = false): DataField[] {
532+
export function getAllFields(
533+
decl: DataModel | TypeDef,
534+
includeIgnored = false,
535+
seen: Set<DataModel | TypeDef> = new Set(),
536+
): DataField[] {
537+
if (seen.has(decl)) {
538+
return [];
539+
}
540+
seen.add(decl);
541+
537542
const fields: DataField[] = [];
538543
for (const mixin of decl.mixins) {
539544
invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`);
540-
fields.push(...getAllFields(mixin.ref));
545+
fields.push(...getAllFields(mixin.ref, includeIgnored, seen));
546+
}
547+
548+
if (isDataModel(decl) && decl.baseModel) {
549+
invariant(decl.baseModel.ref, `Base model ${decl.baseModel.$refText} is not resolved`);
550+
fields.push(...getAllFields(decl.baseModel.ref, includeIgnored, seen));
541551
}
552+
542553
fields.push(...decl.fields.filter((f) => includeIgnored || !hasAttribute(f, '@ignore')));
543554
return fields;
544555
}
545556

546-
export function getAllAttributes(decl: DataModel | TypeDef): DataModelAttribute[] {
557+
export function getAllAttributes(
558+
decl: DataModel | TypeDef,
559+
seen: Set<DataModel | TypeDef> = new Set(),
560+
): DataModelAttribute[] {
561+
if (seen.has(decl)) {
562+
return [];
563+
}
564+
seen.add(decl);
565+
547566
const attributes: DataModelAttribute[] = [];
548567
for (const mixin of decl.mixins) {
549568
invariant(mixin.ref, `Mixin ${mixin.$refText} is not resolved`);
550-
attributes.push(...getAllAttributes(mixin.ref));
569+
attributes.push(...getAllAttributes(mixin.ref, seen));
570+
}
571+
572+
if (isDataModel(decl) && decl.baseModel) {
573+
invariant(decl.baseModel.ref, `Base model ${decl.baseModel.$refText} is not resolved`);
574+
attributes.push(...getAllAttributes(decl.baseModel.ref, seen));
551575
}
576+
552577
attributes.push(...decl.attributes);
553578
return attributes;
554579
}

packages/language/src/validators/datamodel-validator.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { invariant } from '@zenstackhq/common-helpers';
12
import { AstUtils, type AstNode, type DiagnosticInfo, type ValidationAcceptor } from 'langium';
23
import { IssueCodes, SCALAR_TYPES } from '../constants';
34
import {
@@ -16,8 +17,8 @@ import {
1617
} from '../generated/ast';
1718
import {
1819
getAllAttributes,
20+
getAllFields,
1921
getLiteral,
20-
getModelFieldsWithBases,
2122
getModelIdFields,
2223
getModelUniqueFields,
2324
getUniqueFields,
@@ -32,7 +33,7 @@ import { validateDuplicatedDeclarations, type AstValidator } from './common';
3233
*/
3334
export default class DataModelValidator implements AstValidator<DataModel> {
3435
validate(dm: DataModel, accept: ValidationAcceptor): void {
35-
validateDuplicatedDeclarations(dm, getModelFieldsWithBases(dm), accept);
36+
validateDuplicatedDeclarations(dm, getAllFields(dm), accept);
3637
this.validateAttributes(dm, accept);
3738
this.validateFields(dm, accept);
3839
if (dm.mixins.length > 0) {
@@ -42,7 +43,7 @@ export default class DataModelValidator implements AstValidator<DataModel> {
4243
}
4344

4445
private validateFields(dm: DataModel, accept: ValidationAcceptor) {
45-
const allFields = getModelFieldsWithBases(dm);
46+
const allFields = getAllFields(dm);
4647
const idFields = allFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@id'));
4748
const uniqueFields = allFields.filter((f) => f.attributes.find((attr) => attr.decl.ref?.name === '@unique'));
4849
const modelLevelIds = getModelIdFields(dm);
@@ -266,7 +267,7 @@ export default class DataModelValidator implements AstValidator<DataModel> {
266267
const oppositeModel = field.type.reference!.ref! as DataModel;
267268

268269
// Use name because the current document might be updated
269-
let oppositeFields = getModelFieldsWithBases(oppositeModel, false).filter(
270+
let oppositeFields = getAllFields(oppositeModel, false).filter(
270271
(f) =>
271272
f !== field && // exclude self in case of self relation
272273
f.type.reference?.ref?.name === contextModel.name,
@@ -438,11 +439,38 @@ export default class DataModelValidator implements AstValidator<DataModel> {
438439
if (!model.baseModel) {
439440
return;
440441
}
441-
if (model.baseModel.ref && !isDelegateModel(model.baseModel.ref)) {
442+
443+
invariant(model.baseModel.ref, 'baseModel must be resolved');
444+
445+
// check if the base model is a delegate model
446+
if (!isDelegateModel(model.baseModel.ref)) {
442447
accept('error', `Model ${model.baseModel.$refText} cannot be extended because it's not a delegate model`, {
443448
node: model,
444449
property: 'baseModel',
445450
});
451+
return;
452+
}
453+
454+
// check for cyclic inheritance
455+
const seen: DataModel[] = [];
456+
const todo = [model.baseModel.ref];
457+
while (todo.length > 0) {
458+
const current = todo.shift()!;
459+
if (seen.includes(current)) {
460+
accept(
461+
'error',
462+
`Cyclic inheritance detected: ${seen.map((m) => m.name).join(' -> ')} -> ${current.name}`,
463+
{
464+
node: model,
465+
},
466+
);
467+
return;
468+
}
469+
seen.push(current);
470+
if (current.baseModel) {
471+
invariant(current.baseModel.ref, 'baseModel must be resolved');
472+
todo.push(current.baseModel.ref);
473+
}
446474
}
447475
}
448476

packages/language/src/zmodel-linker.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ import {
2020
AttributeParam,
2121
BinaryExpr,
2222
BooleanLiteral,
23-
DataModel,
2423
DataField,
2524
DataFieldType,
25+
DataModel,
2626
Enum,
2727
EnumField,
2828
type ExpressionType,
@@ -43,19 +43,19 @@ import {
4343
UnaryExpr,
4444
isArrayExpr,
4545
isBooleanLiteral,
46-
isDataModel,
4746
isDataField,
4847
isDataFieldType,
48+
isDataModel,
4949
isEnum,
5050
isNumberLiteral,
5151
isReferenceExpr,
5252
isStringLiteral,
5353
} from './ast';
5454
import {
55+
getAllFields,
5556
getAllLoadedAndReachableDataModelsAndTypeDefs,
5657
getAuthDecl,
5758
getContainingDataModel,
58-
getModelFieldsWithBases,
5959
isAuthInvocation,
6060
isFutureExpr,
6161
isMemberContainer,
@@ -397,8 +397,7 @@ export class ZModelLinker extends DefaultLinker {
397397
const transitiveDataModel = attrAppliedOn.type.reference?.ref as DataModel;
398398
if (transitiveDataModel) {
399399
// resolve references in the context of the transitive data model
400-
const scopeProvider = (name: string) =>
401-
getModelFieldsWithBases(transitiveDataModel).find((f) => f.name === name);
400+
const scopeProvider = (name: string) => getAllFields(transitiveDataModel).find((f) => f.name === name);
402401
if (isArrayExpr(node.value)) {
403402
node.value.items.forEach((item) => {
404403
if (isReferenceExpr(item)) {

packages/language/src/zmodel-scope.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import { match } from 'ts-pattern';
1919
import {
2020
BinaryExpr,
2121
MemberAccessExpr,
22-
isDataModel,
2322
isDataField,
23+
isDataModel,
2424
isEnumField,
2525
isInvocationExpr,
2626
isMemberAccessExpr,
@@ -31,9 +31,9 @@ import {
3131
} from './ast';
3232
import { PLUGIN_MODULE_NAME, STD_LIB_MODULE_NAME } from './constants';
3333
import {
34+
getAllFields,
3435
getAllLoadedAndReachableDataModelsAndTypeDefs,
3536
getAuthDecl,
36-
getModelFieldsWithBases,
3737
getRecursiveBases,
3838
isAuthInvocation,
3939
isCollectionPredicate,
@@ -231,7 +231,7 @@ export class ZModelScopeProvider extends DefaultScopeProvider {
231231

232232
private createScopeForContainer(node: AstNode | undefined, globalScope: Scope, includeTypeDefScope = false) {
233233
if (isDataModel(node)) {
234-
return this.createScopeForNodes(getModelFieldsWithBases(node), globalScope);
234+
return this.createScopeForNodes(getAllFields(node), globalScope);
235235
} else if (includeTypeDefScope && isTypeDef(node)) {
236236
return this.createScopeForNodes(node.fields, globalScope);
237237
} else {

0 commit comments

Comments
 (0)