mirror of
				https://github.com/datahub-project/datahub.git
				synced 2025-11-04 12:51:23 +00:00 
			
		
		
		
	
		
			
	
	
		
			320 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			320 lines
		
	
	
		
			8.9 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 
								 | 
							
								import EmberObject, { set, get } from '@ember/object';
							 | 
						||
| 
								 | 
							
								import { computed } from '@ember/object';
							 | 
						||
| 
								 | 
							
								import { groupBy } from '@datahub/utils/array/group-by';
							 | 
						||
| 
								 | 
							
								import { setProperties } from '@ember/object';
							 | 
						||
| 
								 | 
							
								import { keyBy } from 'lodash';
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								/**
							 | 
						||
| 
								 | 
							
								 * INode interface. It represents a node in a graph, you can
							 | 
						||
| 
								 | 
							
								 * specify the type of the payload (default is undefined) for that node
							 | 
						||
| 
								 | 
							
								 */
							 | 
						||
| 
								 | 
							
								export interface INode<T> {
							 | 
						||
| 
								 | 
							
								  // unique id for a node
							 | 
						||
| 
								 | 
							
								  id: number;
							 | 
						||
| 
								 | 
							
								  // level of the tree that this node belongs
							 | 
						||
| 
								 | 
							
								  level: number;
							 | 
						||
| 
								 | 
							
								  // if node is selected or not
							 | 
						||
| 
								 | 
							
								  selected?: boolean;
							 | 
						||
| 
								 | 
							
								  // if node has loaded its data
							 | 
						||
| 
								 | 
							
								  loaded?: boolean;
							 | 
						||
| 
								 | 
							
								  // Custom data for the node
							 | 
						||
| 
								 | 
							
								  payload?: T;
							 | 
						||
| 
								 | 
							
								}
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								/**
							 | 
						||
| 
								 | 
							
								 * IEdge interface. Represents the relation between to nodes, also,
							 | 
						||
| 
								 | 
							
								 * it has direction, that is why it has from and to.
							 | 
						||
| 
								 | 
							
								 */
							 | 
						||
| 
								 | 
							
								export interface IEdge<T> {
							 | 
						||
| 
								 | 
							
								  // edge starting node
							 | 
						||
| 
								 | 
							
								  from: INode<T>['id'];
							 | 
						||
| 
								 | 
							
								  // edge finish node
							 | 
						||
| 
								 | 
							
								  to: INode<T>['id'];
							 | 
						||
| 
								 | 
							
								}
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								/**
							 | 
						||
| 
								 | 
							
								 * GraphDb helps us to create and query a graph with one peculiarity, it is divided into
							 | 
						||
| 
								 | 
							
								 * two section (which suits the dataset lineage usecase): Upstream and Downstream, which
							 | 
						||
| 
								 | 
							
								 * is represented as level -1 (upstream) or 1 (downstream). A downstream subtree will be
							 | 
						||
| 
								 | 
							
								 * generated by adding children to nodes. A upstream subtree will be constructed add parents
							 | 
						||
| 
								 | 
							
								 * to nodes.
							 | 
						||
| 
								 | 
							
								 *
							 | 
						||
| 
								 | 
							
								 * Also note that this graph is made to represent treelike structures with ability of a node to have
							 | 
						||
| 
								 | 
							
								 * multiple parents but still having levels
							 | 
						||
| 
								 | 
							
								 */
							 | 
						||
| 
								 | 
							
								export default class GraphDb<T> extends EmberObject {
							 | 
						||
| 
								 | 
							
								  // list of nodes in the graph
							 | 
						||
| 
								 | 
							
								  nodes: Array<INode<T>> = [];
							 | 
						||
| 
								 | 
							
								  // edges in the graph
							 | 
						||
| 
								 | 
							
								  edges: Array<IEdge<T>> = [];
							 | 
						||
| 
								 | 
							
								  // unique keys that helps to identity a node when only its payload is provided
							 | 
						||
| 
								 | 
							
								  uniqueKeys: Array<string> = [];
							 | 
						||
| 
								 | 
							
								  // internal id generator
							 | 
						||
| 
								 | 
							
								  idGenerator: number = 1;
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Index to access edges by from
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('edges')
							 | 
						||
| 
								 | 
							
								  get edgesByFrom(): Record<string, Array<IEdge<T>>> {
							 | 
						||
| 
								 | 
							
								    return groupBy(this.edges, 'from');
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Index to access edges by to
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('edges')
							 | 
						||
| 
								 | 
							
								  get edgesByTo(): Record<string, Array<IEdge<T>>> {
							 | 
						||
| 
								 | 
							
								    return groupBy(this.edges, 'to');
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Index to access nodes by id
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes')
							 | 
						||
| 
								 | 
							
								  get nodesById(): Record<string, INode<T>> {
							 | 
						||
| 
								 | 
							
								    return keyBy(this.nodes, 'id');
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Index to access the parents of a node
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes')
							 | 
						||
| 
								 | 
							
								  get parentsByNodeId(): Record<string, Array<INode<T>>> {
							 | 
						||
| 
								 | 
							
								    return this.nodes.reduce((parentIdsByNodeId, node): Record<string, Array<INode<T>>> => {
							 | 
						||
| 
								 | 
							
								      const parentIds: Array<INode<T>> = (this.edgesByFrom[node.id] || []).map(
							 | 
						||
| 
								 | 
							
								        (edge): INode<T> => this.nodesById[edge.to]
							 | 
						||
| 
								 | 
							
								      );
							 | 
						||
| 
								 | 
							
								      return { ...parentIdsByNodeId, [node.id]: parentIds };
							 | 
						||
| 
								 | 
							
								    }, {});
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Index to access the children of a node
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes')
							 | 
						||
| 
								 | 
							
								  get childrenByNodeId(): Record<string, Array<INode<T>>> {
							 | 
						||
| 
								 | 
							
								    return this.nodes.reduce((childIdsByNodeId, node): Record<string, Array<INode<T>>> => {
							 | 
						||
| 
								 | 
							
								      const childIds: Array<INode<T>> = (this.edgesByTo[node.id] || []).map(
							 | 
						||
| 
								 | 
							
								        (edge): INode<T> => this.nodesById[edge.from]
							 | 
						||
| 
								 | 
							
								      );
							 | 
						||
| 
								 | 
							
								      return { ...childIdsByNodeId, [node.id]: childIds };
							 | 
						||
| 
								 | 
							
								    }, {});
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Filter downstream nodes only
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes')
							 | 
						||
| 
								 | 
							
								  get downstreamNodes(): Array<INode<T>> {
							 | 
						||
| 
								 | 
							
								    return this.nodes.filter((node): boolean => node.level > 0);
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Filter upstream nodes only
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes')
							 | 
						||
| 
								 | 
							
								  get upstreamNodes(): Array<INode<T>> {
							 | 
						||
| 
								 | 
							
								    return this.nodes.filter((node): boolean => node.level < 0);
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Creates dynamically indexes for the payload to quickly identify nodes
							 | 
						||
| 
								 | 
							
								   * when only payload is provided
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes', 'uniqueKeys')
							 | 
						||
| 
								 | 
							
								  get uniqueIndexes(): Record<string, Record<string, INode<T>>> {
							 | 
						||
| 
								 | 
							
								    return this.uniqueKeys.reduce((indexes, propertyName): Record<string, Record<string, INode<T>>> => {
							 | 
						||
| 
								 | 
							
								      return {
							 | 
						||
| 
								 | 
							
								        ...indexes,
							 | 
						||
| 
								 | 
							
								        [propertyName]: keyBy(
							 | 
						||
| 
								 | 
							
								          this.nodes,
							 | 
						||
| 
								 | 
							
								          (node): INode<T>[keyof INode<T>] => get(node, `payload.${propertyName}` as keyof INode<T>) as number
							 | 
						||
| 
								 | 
							
								        )
							 | 
						||
| 
								 | 
							
								      };
							 | 
						||
| 
								 | 
							
								    }, {});
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Returns the min level of the graph
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  @computed('nodes')
							 | 
						||
| 
								 | 
							
								  get minLevel(): number {
							 | 
						||
| 
								 | 
							
								    return this.nodes.reduce((min, node): number => (node.level < min ? node.level : min), 0);
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Using the payload and the uniqueKeys index (which needs to be specified), it will find
							 | 
						||
| 
								 | 
							
								   * the node that contains that payload
							 | 
						||
| 
								 | 
							
								   * @param nodePayload the payload of a node
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  findNode(nodePayload: T): INode<T> | undefined {
							 | 
						||
| 
								 | 
							
								    const node = this.uniqueKeys.reduce((object: INode<T> | undefined, uniqueKey): INode<T> => {
							 | 
						||
| 
								 | 
							
								      if (object) {
							 | 
						||
| 
								 | 
							
								        return object;
							 | 
						||
| 
								 | 
							
								      }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								      const index = this.uniqueIndexes[uniqueKey];
							 | 
						||
| 
								 | 
							
								      const propertyValue = `${get(nodePayload, uniqueKey as keyof T)}`;
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								      return index[propertyValue];
							 | 
						||
| 
								 | 
							
								    }, undefined);
							 | 
						||
| 
								 | 
							
								    return node;
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Return is a node is a downstream node or upstream
							 | 
						||
| 
								 | 
							
								   * @param node
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  getIsUpstream(node: INode<T>): boolean {
							 | 
						||
| 
								 | 
							
								    return node.level < 0;
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Get all ancestor or descendants in a flat array
							 | 
						||
| 
								 | 
							
								   * @param node
							 | 
						||
| 
								 | 
							
								   * @param up
							 | 
						||
| 
								 | 
							
								   * @param stopAtLevel you can stop at a certain level by the default the boundary of upstream/downstream
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  getHierarchyNodes(node: INode<T>, up: boolean = true, stopAtLevel: number = 0): Array<INode<T>> {
							 | 
						||
| 
								 | 
							
								    let exploredNodes: Array<INode<T>> = [node];
							 | 
						||
| 
								 | 
							
								    let currentNodes: Array<INode<T>> = [node];
							 | 
						||
| 
								 | 
							
								    while (currentNodes.length !== 0) {
							 | 
						||
| 
								 | 
							
								      currentNodes = currentNodes.reduce((newNodes, node): Array<INode<T>> => {
							 | 
						||
| 
								 | 
							
								        if (node.level !== stopAtLevel) {
							 | 
						||
| 
								 | 
							
								          const graphDbQuery = up ? this.parentsByNodeId : this.childrenByNodeId;
							 | 
						||
| 
								 | 
							
								          return [...newNodes, ...graphDbQuery[node.id]];
							 | 
						||
| 
								 | 
							
								        }
							 | 
						||
| 
								 | 
							
								        return newNodes;
							 | 
						||
| 
								 | 
							
								      }, []);
							 | 
						||
| 
								 | 
							
								      exploredNodes = [...exploredNodes, ...currentNodes];
							 | 
						||
| 
								 | 
							
								    }
							 | 
						||
| 
								 | 
							
								    return exploredNodes;
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Add a node to the graph.
							 | 
						||
| 
								 | 
							
								   * For the first node in the graph, parentNode or upstream is not required.
							 | 
						||
| 
								 | 
							
								   * After the first one, parentNode is required.
							 | 
						||
| 
								 | 
							
								   * @param node
							 | 
						||
| 
								 | 
							
								   * @param parentNode
							 | 
						||
| 
								 | 
							
								   * @param upstream will increase or decrease the parent's node level this the new node.
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  addNode(node: T, parentNode?: INode<T>, upstream?: boolean): INode<T> {
							 | 
						||
| 
								 | 
							
								    let newNode = this.findNode(node);
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    if (!newNode) {
							 | 
						||
| 
								 | 
							
								      const level = parentNode ? (upstream ? parentNode.level - 1 : parentNode.level + 1) : 0;
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								      newNode = {
							 | 
						||
| 
								 | 
							
								        id: this.idGenerator,
							 | 
						||
| 
								 | 
							
								        level: level,
							 | 
						||
| 
								 | 
							
								        payload: node
							 | 
						||
| 
								 | 
							
								      };
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								      setProperties(this, {
							 | 
						||
| 
								 | 
							
								        nodes: [...this.nodes, newNode],
							 | 
						||
| 
								 | 
							
								        idGenerator: this.idGenerator + 1
							 | 
						||
| 
								 | 
							
								      });
							 | 
						||
| 
								 | 
							
								    }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    if (parentNode) {
							 | 
						||
| 
								 | 
							
								      const edgeParent = upstream ? parentNode : newNode;
							 | 
						||
| 
								 | 
							
								      const edgeChild = upstream ? newNode : parentNode;
							 | 
						||
| 
								 | 
							
								      this.addEdge(edgeChild, edgeParent);
							 | 
						||
| 
								 | 
							
								    }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    return newNode;
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Will add a edge to the graph making sure there is not already one
							 | 
						||
| 
								 | 
							
								   * @param parent
							 | 
						||
| 
								 | 
							
								   * @param child
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  addEdge(parent: INode<T>, child: INode<T>): void {
							 | 
						||
| 
								 | 
							
								    const edge = {
							 | 
						||
| 
								 | 
							
								      from: child.id,
							 | 
						||
| 
								 | 
							
								      to: parent.id
							 | 
						||
| 
								 | 
							
								    };
							 | 
						||
| 
								 | 
							
								    const fromIndex = this.edgesByFrom[edge.from] || [];
							 | 
						||
| 
								 | 
							
								    const alreadyExists = fromIndex.find((otherEdge): boolean => otherEdge.to === edge.to);
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    if (!alreadyExists) {
							 | 
						||
| 
								 | 
							
								      set(this, 'edges', [...this.edges, edge]);
							 | 
						||
| 
								 | 
							
								    }
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Change node attributes in a Redux style (not mutating the object)
							 | 
						||
| 
								 | 
							
								   * @param id
							 | 
						||
| 
								 | 
							
								   * @param attrs
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  setNodeAttrs(id: number, attrs: Partial<INode<T>>): void {
							 | 
						||
| 
								 | 
							
								    set(
							 | 
						||
| 
								 | 
							
								      this,
							 | 
						||
| 
								 | 
							
								      'nodes',
							 | 
						||
| 
								 | 
							
								      this.nodes.map(
							 | 
						||
| 
								 | 
							
								        (node): INode<T> => {
							 | 
						||
| 
								 | 
							
								          if (node.id === id) {
							 | 
						||
| 
								 | 
							
								            return {
							 | 
						||
| 
								 | 
							
								              ...node,
							 | 
						||
| 
								 | 
							
								              ...attrs
							 | 
						||
| 
								 | 
							
								            };
							 | 
						||
| 
								 | 
							
								          }
							 | 
						||
| 
								 | 
							
								          return node;
							 | 
						||
| 
								 | 
							
								        }
							 | 
						||
| 
								 | 
							
								      )
							 | 
						||
| 
								 | 
							
								    );
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								  /**
							 | 
						||
| 
								 | 
							
								   * Will toggle the node.
							 | 
						||
| 
								 | 
							
								   * If the node is unselected, will unselect all children.
							 | 
						||
| 
								 | 
							
								   * If the node is selected, will select the current ancestors path, and unselect all other nodes
							 | 
						||
| 
								 | 
							
								   * @param id
							 | 
						||
| 
								 | 
							
								   */
							 | 
						||
| 
								 | 
							
								  toggle(id: number): void {
							 | 
						||
| 
								 | 
							
								    const node = this.nodesById[id];
							 | 
						||
| 
								 | 
							
								    const { selected } = this.nodesById[id];
							 | 
						||
| 
								 | 
							
								    const upstream = this.getIsUpstream(node);
							 | 
						||
| 
								 | 
							
								    let toUnselect: Array<INode<T>> = [];
							 | 
						||
| 
								 | 
							
								    let toSelect: Array<INode<T>> = [];
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    if (selected) {
							 | 
						||
| 
								 | 
							
								      if (node.level === 0) {
							 | 
						||
| 
								 | 
							
								        // unselect all
							 | 
						||
| 
								 | 
							
								        toUnselect = this.nodes;
							 | 
						||
| 
								 | 
							
								      } else {
							 | 
						||
| 
								 | 
							
								        // unselect descendants from this node
							 | 
						||
| 
								 | 
							
								        toUnselect = this.getHierarchyNodes(node, upstream);
							 | 
						||
| 
								 | 
							
								      }
							 | 
						||
| 
								 | 
							
								    } else {
							 | 
						||
| 
								 | 
							
								      // select operation: Will unselect everything and select the right nodes
							 | 
						||
| 
								 | 
							
								      if (upstream) {
							 | 
						||
| 
								 | 
							
								        toUnselect = this.upstreamNodes;
							 | 
						||
| 
								 | 
							
								      } else {
							 | 
						||
| 
								 | 
							
								        toUnselect = this.downstreamNodes;
							 | 
						||
| 
								 | 
							
								      }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								      // Then select the right node chain
							 | 
						||
| 
								 | 
							
								      toSelect = this.getHierarchyNodes(node, !upstream);
							 | 
						||
| 
								 | 
							
								    }
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    toUnselect.forEach((node): void => {
							 | 
						||
| 
								 | 
							
								      this.setNodeAttrs(node.id, {
							 | 
						||
| 
								 | 
							
								        selected: false
							 | 
						||
| 
								 | 
							
								      });
							 | 
						||
| 
								 | 
							
								    });
							 | 
						||
| 
								 | 
							
								    toSelect.forEach((node): void => {
							 | 
						||
| 
								 | 
							
								      this.setNodeAttrs(node.id, {
							 | 
						||
| 
								 | 
							
								        selected: true
							 | 
						||
| 
								 | 
							
								      });
							 | 
						||
| 
								 | 
							
								    });
							 | 
						||
| 
								 | 
							
								
							 | 
						||
| 
								 | 
							
								    this.setNodeAttrs(node.id, {
							 | 
						||
| 
								 | 
							
								      selected: !selected
							 | 
						||
| 
								 | 
							
								    });
							 | 
						||
| 
								 | 
							
								  }
							 | 
						||
| 
								 | 
							
								}
							 |