I recently had to model a data warehouse star schema. The central table of a data warehouse is called a fact table. This table has two kinds of columns: dimensions and facts. If I were modelling it from scratch, I could have used two separate child links for the different columns. In pseudocode:

concept FactTable
children:
- dimensions: DimensionColumn[0..n]
- facts: FactColumn[0..n]

However, in my case I had to make sure the FactTable is a KernelF record so it had to implement the IRecordDeclaration interface concept:

interface concept IRecordDeclaration extends ...
children: 
- members : IRecordMember[0..n] 

Basically, I had to store two kinds of members in the members link. Not a problem by itself, I can have both DimensionColumn and FactColumn implement the IRecordMember interface. However, besides being record members they are actually quite different: a dimension column would reference a dimension from a catalog of all dimensions and inherit that dimension’s name and type, whereas a fact column would specify its own name and type. The editors of DimensionColumn and FactColumn would look quite different, and when creating an editor for the FactTable concept I would like to split the collection into two and have dimensions listed separately from facts.

Implementation

Let’s have a look at what functionality MPS provides to implement this split. In fact, you see an example of it every time you work with MPS: a concept definition lets you define children and references separately but both of them are actually stored in AbstractConceptDeclaration.linkDeclaration containment link.

To do this we include the members list twice in the editor:

FactTable editor definition

Each instance specifies a filter, element factory, and element action map:

Child cell declaration (inspector)

  1. We tell MPS that we only want to display a subset of all children.
  2. This piece of code is invoked when we create a new element by pressing Enter or Shift+Enter. We create an element of the specific subclass, so that it is displayed in the right place.
  3. We specify an action map that handles the deletion of each element properly.

Element Deletion

The default behavior of MPS after an element is deleted is to select its nearest neighbor in the same containment link. However, in our case the nearest neighbor may be of a different kind so the cursor may jump into a different section. We need to write code to explicitly select the neighbor of the same kind or the appropriate placeholder cell if we deleted the last element of a particular kind.

The action map we use is pretty simple and calls out to a Java class. Moving the logic to a separate class lets us define helper methods.

Action map definition

The helper class looks as follows:

public class FactTableColumnDeletionHelper { 
   
  private static boolean canDelete(EditorContext editorContext, node<FactTableColumn> node) { 
    EditorCell nodeCell = editorContext.getEditorComponent().findNodeCell(node); 
    return nodeCell != null
      && !ReadOnlyUtil.isCellOrSelectionReadOnlyInEditor(editorContext.getEditorComponent(), nodeCell)
      && node.parent.isInstanceOf(FactTable);
  } 
   
  private static [node<>, node<>] getPrevNext(node<> node, sequence<node<>> nodes) { 
    node<> prevNode = null; 
    node<> nextNode = null; 
    boolean nodeVisited = false; 
    foreach n in nodes { 
      if (n == node) { 
        nodeVisited = true; 
        continue; 
      } 
      if (!nodeVisited) { 
        prevNode = n; 
      } else { 
        nextNode = n; 
        break; 
      } 
    } 
    return [prevNode, nextNode]; 
  } 
   
  public static void delete(EditorContext editorContext, node<FactTableColumn> node, boolean isBackspace) { 
    if (!canDelete(editorContext, node)) { return; } 
     
    if (node.approveDelete [in: editorContext]) { return; } 
     
    boolean isDimension = node.isInstanceOf(DimensionColumn); 
    node<FactTable> containing = node.parent:FactTable; 
    sequence<node<FactTableColumn>> similarMembers = containing.members.where({~it => isDimension ? it.isInstanceOf(DimensionColumn) : it.isInstanceOf(FactColumn); }); 
     
    node<> prev; 
    node<> next; 
    [prev, next] = getPrevNext(node, similarMembers); 
    node.detach; 
     
    if (isBackspace) { 
      if (prev != null) { 
        prev.select[in: editorContext, cell: LAST, selectionStart: -1]; 
        return; 
      } 
       
      if (next != null) { 
        next.select[in: editorContext, cell: FIRST, selectionStart: 0]; 
        return; 
      } 
    } else { 
      if (next != null) { 
        next.select[in: editorContext, cell: FIRST, selectionStart: 0]; 
        return; 
      } 
       
      if (prev != null) { 
        prev.select[in: editorContext, cell: LAST, selectionStart: -1]; 
        return; 
      } 
    } 
     
    if (isDimension) { 
      containing.select[in: editorContext, cell: emptyDimensionsPlaceholder, selectionStart: 0]; 
    } else { 
      containing.select[in: editorContext, cell: emptyValuesPlaceholder, selectionStart: 0]; 
    } 
  } 
}

The code is a simplified version of the code in LinkDeclarationsDeleteActions action map (MPS link). The overall algorithm is as follows:

  1. Check that the node and the cell is not read-only, otherwise don’t delete anything.
  2. Support two-step deletion: don’t do anything if the deletion wasn’t approved yet.
  3. Figure out our neighboring nodes so that we know which node to select after the current node will be gone.
  4. Remove (detach) the current node.
  5. Set the selection according to point 3.

To be able to select placeholder cells programmatically we need to create custom empty cells and give them explicit IDs (cell id property).

Now dimensions and facts can be edited as two separate lists even though there is only one underlying child link, IRecordDeclaration.members:

Resulting editor