Skip to content

Commit

Permalink
Add support for textual expressions in attribute mappings
Browse files Browse the repository at this point in the history
- Allow specifying expressions in attribute mappings as string
- Validate reference expressions '{{expression}}'
- Provide suggestions for reference expressions
- Allow attribute mappings to have 0 to n sources (previously 1)

Minor:
- Guard return value in selection data service for undefined references
  • Loading branch information
martin-fleck-at committed May 22, 2024
1 parent f0b6dc8 commit 4174776
Show file tree
Hide file tree
Showing 20 changed files with 310 additions and 115 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@ mapping:
entity: ExampleDWH.CompleteCustomer
mappings:
- attribute: Country
source: CustomerSourceObject.Country
sources:
- CustomerSourceObject.Country
- attribute: FixedNumber
source: 1
sources:
- 1
- attribute: FixedString
source: "Hoppa"
sources:
- "Hoppa"
- attribute: Name
source: CustomerSourceObject.FirstName
sources:
- CustomerSourceObject.FirstName
- CustomerSourceObject.LastName
expression: "CONCAT_WS({{CustomerSourceObject.FirstName}}, {{CustomerSourceObject.LastName}}, ' ')"
- attribute: Age
source: CalcAgeSourceObject.Age
- attribute: Age
source: CalcAgeSourceObject.Age
sources:
- CustomerSourceObject.BirthDate
expression: "DATEDIFF(YEAR, NOW(), {{CustomerSourceObject.BirthDate}})"
- attribute: Today
expression: "GETDATE()"
18 changes: 12 additions & 6 deletions examples/mapping-example/ExampleDWH/DWH.mapping.cm
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@ mapping:
entity: CompleteCustomer
mappings:
- attribute: Name
source: Customer.FirstName
sources:
- Customer.FirstName
- attribute: Name
source: Customer.LastName
sources:
- Customer.LastName
- attribute: Country
source: Country.Name
sources:
- Country.Name
- attribute: Age
source: CalcAgeSourceObject.Age
sources:
- CalcAgeSourceObject.Age
- attribute: FixedNumber
source: 1337
sources:
- 1337
- attribute: FixedString
source: "Fixed String"
sources:
- "Fixed String"
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ entity:
datatype: "Integer"
- id: FixedString
name: "FixedString"
datatype: "Varchar"
datatype: "Varchar"
- id: Today
name: "Today"
datatype: "Date"
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/********************************************************************************
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import { GModelIndex } from '@eclipse-glsp/server';
import { GModelElement, GModelIndex } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { AstNode, streamAst } from 'langium';
import * as uuid from 'uuid';
Expand Down Expand Up @@ -65,4 +65,13 @@ export class CrossModelIndex extends GModelIndex {
}
return semanticNode;
}

protected override doIndex(element: GModelElement): void {
if (this.idToElement.has(element.id)) {
// super method throws error which is a bit too extreme, simply log the error to the client
this.services.shared.logger.ClientLogger.error('Duplicate element id in graph: ' + element.id);
return;
}
super.doIndex(element);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import { Command, DeleteElementOperation, GEdge, GNode, JsonOperationHandler, ModelState, remove } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import {
AttributeMapping,
AttributeMappingSource,
NumberLiteral,
SourceObject,
SourceObjectRelations,
isAttributeMapping,
StringLiteral,
isAttributeMappingSource,
isNumberLiteral,
isReferenceSource,
isSourceObject,
Expand All @@ -25,7 +27,7 @@ export class MappingDiagramDeleteElementOperationHandler extends JsonOperationHa

override createCommand(operation: DeleteElementOperation): Command | undefined {
const deleteInfo = this.findElementsToDelete(operation);
if (deleteInfo.sources.length === 0 && deleteInfo.mapping.length === 0) {
if (deleteInfo.sources.length === 0 && deleteInfo.attributeSources.length === 0 && deleteInfo.relations.length === 0) {
return undefined;
}

Expand All @@ -36,55 +38,69 @@ export class MappingDiagramDeleteElementOperationHandler extends JsonOperationHa
const container = this.modelState.mapping.sources;
remove(container, ...deleteInfo.sources);

deleteInfo.attributeSources.forEach(source => remove(source.$container.sources, source));

// remove any mapping that does not contain any sources after deleting sources and attribute sources
const mappings = this.modelState.mapping.target.mappings;
remove(mappings, ...deleteInfo.mapping);
remove(mappings, ...mappings.filter(mapping => mapping.sources.length === 0));

deleteInfo.relations.forEach(relation => remove(relation.$container.relations, relation));
}

protected findElementsToDelete(operation: DeleteElementOperation): DeleteInfo {
const deleteInfo: DeleteInfo = { sources: [], mapping: [], relations: [] };
const mapping = this.modelState.mapping;
const deleteInfo: DeleteInfo = { sources: [], relations: [], attributeSources: [] };
operation.elementIds.forEach(id => {
const graphElement = this.modelState.index.get(id);
if (graphElement instanceof GNode) {
const astNode = this.modelState.index.findSemanticElement(id);
if (isSourceObject(astNode)) {
deleteInfo.mapping.push(
...mapping.target.mappings.filter(
attributeMapping =>
isReferenceSource(attributeMapping.source) &&
attributeMapping.source.value.ref &&
getOwner(attributeMapping.source.value?.ref) === astNode
)
);
deleteInfo.sources.push(astNode);
deleteInfo.relations.push(
...mapping.sources.flatMap(source => source.relations).filter(relation => relation.source.ref === astNode)
);
} else if (isStringLiteral(astNode) || isNumberLiteral(astNode)) {
// Literal nodes are contained by the corresponding targetAttributeMapping=> we only have to delete
// the mapping that correlates to the outgoing edge of the literal node
this.modelState.index.getOutgoingEdges(graphElement).forEach(edge => {
const attributeMapping = this.modelState.index.findSemanticElement(edge.id, isAttributeMapping);
if (attributeMapping) {
deleteInfo.mapping.push(attributeMapping);
}
});
}
this.deleteNode(graphElement, deleteInfo);
} else if (graphElement instanceof GEdge) {
const attributeMapping = this.modelState.index.findSemanticElement(graphElement.id, isAttributeMapping);
if (attributeMapping) {
deleteInfo.mapping.push(attributeMapping);
}
this.deleteEdge(graphElement, deleteInfo);
}
});
return deleteInfo;
}

protected deleteNode(node: GNode, deleteInfo: DeleteInfo): void {
const astNode = this.modelState.index.findSemanticElement(node.id);
if (isSourceObject(astNode)) {
this.deleteSourceObject(node, astNode, deleteInfo);
} else if (isStringLiteral(astNode) || isNumberLiteral(astNode)) {
this.deleteLiteralObject(node, astNode, deleteInfo);
}
}

protected deleteSourceObject(node: GNode, source: SourceObject, deleteInfo: DeleteInfo): void {
const mapping = this.modelState.mapping;
// delete source and all relations and attribute sources that reference that source
deleteInfo.sources.push(source);
deleteInfo.relations.push(...mapping.sources.flatMap(src => src.relations).filter(relation => relation.source.ref === source));
deleteInfo.attributeSources.push(
...mapping.target.mappings.flatMap(attrMapping =>
attrMapping.sources.filter(
attrSource => isReferenceSource(attrSource) && attrSource.value.ref && getOwner(attrSource.value?.ref) === source
)
)
);
}

protected deleteLiteralObject(node: GNode, literal: StringLiteral | NumberLiteral, deleteInfo: DeleteInfo): void {
// Literal nodes are contained by the corresponding targetAttributeMapping => we only have to delete
// the mapping that correlates to the outgoing edge of the literal node
this.modelState.index.getOutgoingEdges(node).forEach(edge => this.deleteEdge(edge, deleteInfo));
}

protected deleteEdge(edge: GEdge, deleteInfo: DeleteInfo): void {
// the edge has the source as semantic element with an additional UUID cause the user may use the same source multiple times
// see the generation of the edge id in edges.ts
const source = this.modelState.index.findSemanticElement(edge.id, isAttributeMappingSource);
if (source) {
deleteInfo.attributeSources.push(source);
}
}
}

interface DeleteInfo {
sources: SourceObject[];
mapping: AttributeMapping[];
relations: SourceObjectRelations[];
attributeSources: AttributeMappingSource[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export class MappingDiagramLayoutEngine implements LayoutEngine {
@inject(MappingModelState) protected modelState!: MappingModelState;

layout(): MaybePromise<GModelRoot> {
if (!this.modelState.mapping) {
return this.modelState.root;
}

const index = this.modelState.index;

// position source nodes (references and literals) in correct order
Expand Down Expand Up @@ -54,13 +58,11 @@ export class MappingDiagramLayoutEngine implements LayoutEngine {
protected getSourceNodeOrderFunction(): (left: GNode, right: GNode) => number {
// sort mappings by the target attribute order and extract the source node id
const target = this.modelState.mapping.target;
const index = this.modelState.index;

const idx = this.modelState.index;
const sourceNodeOrder = [...target.mappings]
.sort((left, right) => (left.attribute.value.ref?.$containerIndex ?? 0) - (right.attribute.value.ref?.$containerIndex ?? 0))
.map(mapping =>
isReferenceSource(mapping.source) ? index.createId(getOwner(mapping.source.value.ref)) : index.createId(mapping.source)
);
.flatMap(mapping => mapping.sources.map(source => idx.createId(isReferenceSource(source) ? getOwner(source.value.ref) : source)));
return (left: GNode, right: GNode): number => {
if (!sourceNodeOrder.includes(left.id)) {
return 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,29 @@

import { TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE, createLeftPortId, createRightPortId } from '@crossbreeze/protocol';
import { GEdge, GEdgeBuilder } from '@eclipse-glsp/server';
import { AttributeMapping, isReferenceSource } from '../../../language-server/generated/ast.js';
import { AttributeMappingSource, isReferenceSource } from '../../../language-server/generated/ast.js';
import { MappingModelIndex } from './mapping-model-index.js';

export class GTargetObjectEdge extends GEdge {
override type = TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE;

static override builder(): GTargeMappingEdgeBuilder {
return new GTargeMappingEdgeBuilder(GTargetObjectEdge).type(TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE);
static override builder(): GTargetMappingSourceEdgeBuilder {
return new GTargetMappingSourceEdgeBuilder(GTargetObjectEdge).type(TARGET_ATTRIBUTE_MAPPING_EDGE_TYPE);
}
}

export class GTargeMappingEdgeBuilder extends GEdgeBuilder<GTargetObjectEdge> {
set(mapping: AttributeMapping, index: MappingModelIndex): this {
this.id(index.createId(mapping));
export class GTargetMappingSourceEdgeBuilder extends GEdgeBuilder<GTargetObjectEdge> {
set(source: AttributeMappingSource, index: MappingModelIndex): this {
const mapping = source.$container;
const sourceId = isReferenceSource(source) ? createRightPortId(index.createId(source.value.ref)) : index.createId(source);
const targetId = createLeftPortId(index.createId(mapping.attribute));

const id = 'edge_' + index.createId(source);
this.id(id);
index.indexSemanticElement(id, source);
this.addCssClasses('diagram-edge', 'mapping-edge', 'attribute-mapping');
this.addArg('edgePadding', 5);

const sourceId = isReferenceSource(mapping.source)
? createRightPortId(index.createId(mapping.source.value.ref))
: index.createId(mapping.source);
const targetId = createLeftPortId(index.createId(mapping.attribute));

this.sourceId(sourceId);
this.targetId(targetId);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,21 +39,22 @@ export class MappingDiagramGModelFactory implements GModelFactory {

// literals that serve as source
mappingRoot.target.mappings
.map(mapping => this.createSourceLiteralNode(mapping.source))
.flatMap(mapping => mapping.sources)
.map(source => this.createSourceLiteralNode(source))
.filter(node => node !== undefined)
.forEach(node => graphBuilder.add(node!));

// target node
graphBuilder.add(this.createTargetNode(mappingRoot.target));

// attribute mapping edges
mappingRoot.target.mappings.map(mapping => this.createTargetObjectEdge(mapping)).forEach(edge => graphBuilder.add(edge));
mappingRoot.target.mappings.flatMap(mapping => this.createTargetObjectEdge(mapping)).forEach(edge => graphBuilder.add(edge));

return graphBuilder.build();
}

protected createTargetObjectEdge(attribute: AttributeMapping): GEdge {
return GTargetObjectEdge.builder().set(attribute, this.modelState.index).build();
protected createTargetObjectEdge(attribute: AttributeMapping): GEdge[] {
return attribute.sources.map(src => GTargetObjectEdge.builder().set(src, this.modelState.index).build());
}

protected createSourceObjectNode(sourceObject: SourceObject): GNode {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class MappingModelIndex extends CrossModelIndex {
}

protected createAttributeMappingId(mapping: AttributeMapping): string | undefined {
const sourceId = this.findId(mapping.source);
const sourceId = mapping.sources.map(source => this.findId(source)).join('_');
const targetId = this.findId(mapping.attribute);
return `${mapping.$containerIndex ?? 0}_${sourceId}_to_${targetId}`;
}
Expand All @@ -71,11 +71,11 @@ export class MappingModelIndex extends CrossModelIndex {
}

protected createLiteralId(literal: NumberLiteral | StringLiteral): string {
return `mapping_${literal.$container?.$containerIndex ?? 0}_source_${literal.value}`;
return `mapping_${literal.$container?.$containerIndex ?? 0}_source_${literal.$containerIndex ?? 0}_${literal.value}`;
}

protected createReferenceSourceId(source: ReferenceSource): string | undefined {
return `mapping_${source.$container?.$containerIndex ?? 0}_source_${this.createId(source.value.ref)}`;
return `mapping_${source.$container?.$containerIndex ?? 0}_source_${source.$containerIndex ?? 0}_${this.createId(source.value.ref)}`;
}

protected getIndex(node: AstNode): number {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ import {
} from 'langium';
import { getExplicitRuleType } from 'langium/internal';
import { v4 as uuid } from 'uuid';
import { CompletionItemKind, InsertTextFormat } from 'vscode-languageserver-protocol';
import { CompletionItemKind, InsertTextFormat, TextEdit } from 'vscode-languageserver-protocol';
import type { Range } from 'vscode-languageserver-types';
import { CrossModelServices } from './cross-model-module.js';
import { RelationshipAttribute } from './generated/ast.js';
import { RelationshipAttribute, isAttributeMapping, isReferenceSource } from './generated/ast.js';
import { fixDocument } from './util/ast-util.js';
import { isExpressionStart } from '@crossbreeze/protocol';

/**
* Custom completion provider that only shows the short options to the user if a longer, fully-qualified version is also available.
Expand Down Expand Up @@ -72,6 +73,20 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
if (assignment.feature === 'id') {
return this.completionForId(context, assignment, acceptor);
}
if (isAttributeMapping(context.node) && assignment.feature === 'expression') {
const node = context.node;
const text = context.textDocument.getText();
const existingText = text.substring(context.tokenOffset, context.offset);
if (isExpressionStart(existingText)) {
node.sources.filter(isReferenceSource).forEach(source => {
acceptor(context, {
label: source.value.$refText,
textEdit: TextEdit.insert(context.textDocument.positionAt(context.offset), source.value.$refText)
});
});
return;
}
}
if (GrammarAST.isRuleCall(assignment.terminal) && assignment.terminal.rule.ref) {
const type = this.getRuleType(assignment.terminal.rule.ref);
switch (type) {
Expand Down
Loading

0 comments on commit 4174776

Please sign in to comment.