Skip to content

Commit

Permalink
Extend relationship with attributes between parent and child entity (#52
Browse files Browse the repository at this point in the history
)

* Extend relationship with attributes between parent and child entity

- Add relationship attribute support in grammar
- Provide only correct attributes as suggestion to the user
- Validate to only have correct attributes and no duplicates

* Fix and add test cases

* Apply PR feedback and use fixed property order in grammar

- Slightly improve completion provider
- Fix up test cases with newly specified mandatory properties
  • Loading branch information
martin-fleck-at committed Mar 15, 2024
1 parent 3d9eba8 commit 7514d76
Show file tree
Hide file tree
Showing 30 changed files with 1,472 additions and 1,335 deletions.
Expand Up @@ -2,4 +2,7 @@ relationship:
id: Address_Customer
parent: Customer
child: ExampleCRM.Address
type: "1:1"
type: "1:1"
attributes:
- parent: Customer.Id
child: ExampleCRM.Address.CustomerID
Expand Up @@ -2,4 +2,7 @@ relationship:
id: Order_Customer
parent: Customer
child: Order
type: "1:1"
type: "1:1"
attributes:
- parent: Customer.Id
child: Order.CustomerId
Expand Up @@ -18,6 +18,7 @@ import { CompletionItemKind, InsertTextFormat } from 'vscode-languageserver-prot
import type { Range } from 'vscode-languageserver-types';
import { CrossModelServices } from './cross-model-module.js';
import { isExternalDescriptionForLocalPackage } from './cross-model-scope.js';
import { RelationshipAttribute, isRelationshipAttribute } from './generated/ast.js';
import { fixDocument } from './util/ast-util.js';

/**
Expand All @@ -26,6 +27,10 @@ import { fixDocument } from './util/ast-util.js';
export class CrossModelCompletionProvider extends DefaultCompletionProvider {
protected packageId?: string;

readonly completionOptions = {
triggerCharacters: ['\n', ' ']
};

constructor(
services: CrossModelServices,
protected packageManager = services.shared.workspace.PackageManager
Expand All @@ -52,6 +57,14 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
return context;
}

protected override filterKeyword(context: CompletionContext, keyword: GrammarAST.Keyword): boolean {
return super.filterKeyword(context, keyword) && this.isUndefinedProperty(context.node, keyword.value);
}

protected isUndefinedProperty(obj: any, property: string): boolean {
return obj?.[property] === undefined || (Array.isArray(obj[property]) && obj[property].length === 0);
}

protected completionForAssignment(
context: CompletionContext,
assignment: GrammarAST.Assignment,
Expand Down Expand Up @@ -182,8 +195,25 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
}

protected override filterCrossReference(context: CompletionContext, description: AstNodeDescription): boolean {
// we want to keep fully qualified names in the scope so we can do proper linking
// but want to hide it from the user for local options, i.e., if we are in the same project we can skip the project name
return !isExternalDescriptionForLocalPackage(description, this.packageId) && super.filterCrossReference(context, description);
if (isRelationshipAttribute(context.node)) {
return this.filterRelationshipAttribute(context.node, context, description);
}
if (isExternalDescriptionForLocalPackage(description, this.packageId)) {
// we want to keep fully qualified names in the scope so we can do proper linking
// but want to hide it from the user for local options, i.e., if we are in the same project we can skip the project name
return false;
}
return super.filterCrossReference(context, description);
}

protected filterRelationshipAttribute(node: RelationshipAttribute, context: CompletionContext, desc: AstNodeDescription): boolean {
// only show relevant attributes depending on the parent or child context
if (context.features.find(feature => feature.property === 'child')) {
return desc.name.startsWith(node.$container.child?.$refText + '.');
}
if (context.features.find(feature => feature.property === 'parent')) {
return desc.name.startsWith(node.$container.parent?.$refText + '.');
}
return true;
}
}
Expand Up @@ -5,7 +5,9 @@ import { AstNode, ValidationAcceptor, ValidationChecks } from 'langium';
import type { CrossModelServices } from './cross-model-module.js';
import { ID_PROPERTY, IdentifiableAstNode } from './cross-model-naming.js';
import {
Attribute,
CrossModelAstType,
Relationship,
RelationshipEdge,
SourceObject,
isEntity,
Expand All @@ -25,7 +27,8 @@ export function registerValidationChecks(services: CrossModelServices): void {
const checks: ValidationChecks<CrossModelAstType> = {
AstNode: validator.checkNode,
RelationshipEdge: validator.checkRelationshipEdge,
SourceObject: validator.checkSourceObject
SourceObject: validator.checkSourceObject,
Relationship: validator.checkRelationship
};
registry.register(checks, validator);
}
Expand Down Expand Up @@ -83,6 +86,33 @@ export class CrossModelValidator {
}
}

checkRelationship(relationship: Relationship, accept: ValidationAcceptor): void {
// we check that each attribute actually belongs to their respective entity (parent, child)
// and that each attribute is only used once
const usedParentAttributes: Attribute[] = [];
const usedChildAttributes: Attribute[] = [];
for (const attribute of relationship.attributes) {
if (attribute.parent.ref) {
if (attribute.parent?.ref?.$container !== relationship.parent?.ref) {
accept('error', 'Not a valid parent attribute.', { node: attribute, property: 'parent' });
} else if (usedParentAttributes.includes(attribute.parent.ref)) {
accept('error', 'Each parent attribute can only be referenced once.', { node: attribute, property: 'parent' });
} else {
usedParentAttributes.push(attribute.parent.ref);
}
}
if (attribute.child.ref) {
if (attribute.child?.ref?.$container !== relationship.child?.ref) {
accept('error', 'Not a valid child attribute.', { node: attribute, property: 'child' });
} else if (usedChildAttributes.includes(attribute.child.ref)) {
accept('error', 'Each child attribute can only be referenced once.', { node: attribute, property: 'child' });
} else {
usedChildAttributes.push(attribute.child.ref);
}
}
}
}

checkRelationshipEdge(edge: RelationshipEdge, accept: ValidationAcceptor): void {
if (edge.sourceNode?.ref?.entity?.ref?.$type !== edge.relationship?.ref?.parent?.ref?.$type) {
accept('error', 'Source must match type of parent.', { node: edge, property: 'sourceNode' });
Expand Down
36 changes: 14 additions & 22 deletions extensions/crossmodel-lang/src/language-server/entity.langium
Expand Up @@ -3,37 +3,29 @@ import 'terminals'
// Entity definition
Entity:
'entity' ':'
(
INDENT
(
'id' ':' id=ID |
'name' ':' name=STRING |
'description' ':' description=STRING |
'attributes' ':' EntityAttributes
)*
'id' ':' id=ID
('name' ':' name=STRING)?
('description' ':' description=STRING)?
('attributes' ':'
INDENT
('-' attributes+=EntityAttribute)+
DEDENT
)?
DEDENT
)*
;

interface Attribute {
id: string;
name?: string;
name: string;
datatype: string;
description?: string;
datatype: string;
}

interface EntityAttribute extends Attribute {}

EntityAttributes infers Entity:
INDENT
(attributes+=EntityAttribute)*
DEDENT;

EntityAttribute returns EntityAttribute:
'-' (
'id' ':' id=ID |
'name' ':' name=STRING |
'datatype' ':' datatype=STRING |
'description' ':' description=STRING
)*
;
'id' ':' id=ID
'name' ':' name=STRING
'datatype' ':' datatype=STRING
('description' ':' description=STRING)?;
61 changes: 44 additions & 17 deletions extensions/crossmodel-lang/src/language-server/generated/ast.ts
Expand Up @@ -51,7 +51,7 @@ export interface Attribute extends AstNode {
datatype: string
description?: string
id: string
name?: string
name: string
}

export const Attribute = 'Attribute';
Expand Down Expand Up @@ -104,7 +104,7 @@ export interface Entity extends AstNode {
readonly $type: 'Entity';
attributes: Array<EntityAttribute>
description?: string
id?: string
id: string
name?: string
}

Expand All @@ -118,13 +118,13 @@ export interface EntityNode extends AstNode {
readonly $container: SystemDiagram;
readonly $type: 'EntityNode';
description?: string
entity?: Reference<Entity>
height?: number
id?: string
entity: Reference<Entity>
height: number
id: string
name?: string
width?: number
x?: number
y?: number
width: number
x: number
y: number
}

export const EntityNode = 'EntityNode';
Expand Down Expand Up @@ -200,12 +200,13 @@ export function isReferenceSource(item: unknown): item is ReferenceSource {
export interface Relationship extends AstNode {
readonly $container: CrossModelRoot;
readonly $type: 'Relationship';
child?: Reference<Entity>
attributes: Array<RelationshipAttribute>
child: Reference<Entity>
description?: string
id?: string
id: string
name?: string
parent?: Reference<Entity>
type?: string
parent: Reference<Entity>
type: string
}

export const Relationship = 'Relationship';
Expand All @@ -214,6 +215,19 @@ export function isRelationship(item: unknown): item is Relationship {
return reflection.isInstance(item, Relationship);
}

export interface RelationshipAttribute extends AstNode {
readonly $container: Relationship;
readonly $type: 'RelationshipAttribute';
child: Reference<Attribute>
parent: Reference<Attribute>
}

export const RelationshipAttribute = 'RelationshipAttribute';

export function isRelationshipAttribute(item: unknown): item is RelationshipAttribute {
return reflection.isInstance(item, RelationshipAttribute);
}

export interface RelationshipCondition extends AstNode {
readonly $container: SourceObjectRelations;
readonly $type: 'RelationshipCondition';
Expand All @@ -229,10 +243,10 @@ export function isRelationshipCondition(item: unknown): item is RelationshipCond
export interface RelationshipEdge extends AstNode {
readonly $container: SystemDiagram;
readonly $type: 'RelationshipEdge';
id?: string
relationship?: Reference<Relationship>
sourceNode?: Reference<EntityNode>
targetNode?: Reference<EntityNode>
id: string
relationship: Reference<Relationship>
sourceNode: Reference<EntityNode>
targetNode: Reference<EntityNode>
}

export const RelationshipEdge = 'RelationshipEdge';
Expand Down Expand Up @@ -366,6 +380,7 @@ export type CrossModelAstType = {
NumberLiteral: NumberLiteral
ReferenceSource: ReferenceSource
Relationship: Relationship
RelationshipAttribute: RelationshipAttribute
RelationshipCondition: RelationshipCondition
RelationshipEdge: RelationshipEdge
SourceObject: SourceObject
Expand All @@ -381,7 +396,7 @@ export type CrossModelAstType = {
export class CrossModelAstReflection extends AbstractAstReflection {

getAllTypes(): string[] {
return ['Attribute', 'AttributeMapping', 'AttributeMappingSource', 'AttributeMappingTarget', 'CrossModelRoot', 'Entity', 'EntityAttribute', 'EntityNode', 'EntityNodeAttribute', 'JoinCondition', 'JoinExpression', 'Mapping', 'NumberLiteral', 'ReferenceSource', 'Relationship', 'RelationshipCondition', 'RelationshipEdge', 'SourceObject', 'SourceObjectAttribute', 'SourceObjectCondition', 'SourceObjectRelations', 'StringLiteral', 'SystemDiagram', 'TargetObject', 'TargetObjectAttribute'];
return ['Attribute', 'AttributeMapping', 'AttributeMappingSource', 'AttributeMappingTarget', 'CrossModelRoot', 'Entity', 'EntityAttribute', 'EntityNode', 'EntityNodeAttribute', 'JoinCondition', 'JoinExpression', 'Mapping', 'NumberLiteral', 'ReferenceSource', 'Relationship', 'RelationshipAttribute', 'RelationshipCondition', 'RelationshipEdge', 'SourceObject', 'SourceObjectAttribute', 'SourceObjectCondition', 'SourceObjectRelations', 'StringLiteral', 'SystemDiagram', 'TargetObject', 'TargetObjectAttribute'];
}

protected override computeIsSubtype(subtype: string, supertype: string): boolean {
Expand Down Expand Up @@ -427,6 +442,10 @@ export class CrossModelAstReflection extends AbstractAstReflection {
case 'ReferenceSource:value': {
return SourceObjectAttribute;
}
case 'RelationshipAttribute:child':
case 'RelationshipAttribute:parent': {
return Attribute;
}
case 'RelationshipCondition:relationship':
case 'RelationshipEdge:relationship': {
return Relationship;
Expand Down Expand Up @@ -462,6 +481,14 @@ export class CrossModelAstReflection extends AbstractAstReflection {
]
};
}
case 'Relationship': {
return {
name: 'Relationship',
mandatory: [
{ name: 'attributes', type: 'array' }
]
};
}
case 'SourceObject': {
return {
name: 'SourceObject',
Expand Down

0 comments on commit 7514d76

Please sign in to comment.