Home Reference Source

lib/Graph.js

'use strict'

const neo4j = require('neo4j-driver/lib/v1')

const _db = Symbol('db')
const _Node = Symbol('Node')
const _Relationship = Symbol('Relationship')
const _NodeCollection = Symbol('NodeCollection')

/**
 * Object-Graph Map
 *
 * @class Graph
 */
class Graph
{
	static get TYPE_NODE()
	{
		return 'node'
	}

	static get TYPE_RELATIONSHIP()
	{
		return 'relationship'
	}

	static get TYPE_INTEGER()
	{
		return 'integer'
	}

	static get TYPE_ARRAY()
	{
		return 'array'
	}

	/**
	 * Get a database instance.
	 *
	 * @readonly
	 * @static
	 */
	static get db()
	{
		/**
		 * @private
		 */
		this[_db] = this[_db] || require('./DB')

		return this[_db]
	}

	static get Node()
	{
		if (!this[_Node])
		{
			/**
			 * @private
			 */
			this[_Node] = require('./Node')
		}

		return this[_Node]
	}

	static get Relationship()
	{
		if (!this[_Relationship])
		{
			/**
			 * @private
			 */
			this[_Relationship] = require('./Relationship')
		}

		return this[_Relationship]
	}

	static get NodeCollection()
	{
		if (!this[_NodeCollection])
		{
			/**
			 * @private
			 */
			this[_NodeCollection] = require('./NodeCollection')
		}

		return this[_NodeCollection]
	}

	/**
	 * Build a graph from query results.
	 * Map variables returned from the query to Nodes
	 *
	 * @static
	 * @param {string} query The search query.
	 * @param {object} parameters The parameters used in the search query.
	 * @param {object} models variables and Node models as key-value pairs.
	 * @param {object} [links=null] virtual relationships between records
	 * @returns {Graph} The resulting graph.
	 */
	static build(query, parameters, models = {}, links = null)
	{
		return this.db.query(query, parameters)
			.then(result =>
			{
				return new this(result, models, links)
			})
	}

	/**
	 * Creates an instance of Graph.
	 *
	 * @param {neo4j.Result} result Result of a query.
	 * @param {object} models variables and Node models as key-value pairs.
	 * @param {object} [links=null] virtual relationships between records
	 */
	constructor(result, models = {}, links = null)
	{
		this.nodes         = new Map()
		this.relationships = new Map()
		this.index         = new Map()
		this.references    = {}
		this.models        = models
		this.links         = links

		if (result && result.records)
		{
			this.addRecords(result.records)
		}
	}

	/**
	 * Run a Cypher query and add the result to the current Graph instance
	 *
	 * @param {string} query A Cypher query
	 * @param {Object} parameters Parameters used in the query
	 * @param {Object} [models] Map variables to Models
	 * @param {Object} [links] Link results to a specific Model
	 * @returns {Promise} A promise
	 */
	run(query, parameters, models = {}, links = null)
	{
		Object.assign(this.models, models)

		if (links)
		{
			this.links = this.links || {}
			Object.assign(this.links, links)
		}

		return this.constructor.db.query(query, parameters)
			.then(result =>
			{
				this.addRecords(result.records)
			})
	}

	/**
	 * Add records to the graph
	 *
	 * @param {neo4j.Record[]} records Neo4j Records
	 */
	addRecords(records)
	{
		for (const record of records)
		{
			record.forEach(this.add.bind(this))

			if (this.links)
			{
				this.addLinks(record)
			}
		}
	}

	/**
	 * Link record items
	 *
	 * @param {neo4j.Record} record Neo4j Record
	 */
	addLinks(record)
	{
		const names = Object.keys(this.links)

		for (const name of names)
		{
			const link = this.links[name]

			link.items = link.items || []

			const startKey = link.start || Object.keys(this.models)[0]
			const endKey = link.end || name

			if (record.has(startKey) && record.has(endKey))
			{
				const start = record.get(startKey).identity.toNumber()

				const entity = record.get(endKey)
				const type = Graph.typeOf(entity)
				const value = entity.identity ? entity.identity.toNumber() : entity

				const item = {
					start,
					value,
					type
				}

				if (endKey in this.models)
				{
					link.model = this.models[endKey]
				}

				link.items.push(item)
			}
		}
	}

	/**
	 * Add one or more entities to the graph
	 *
	 * @param {neo4j.types.Node|neo4j.types.Node[]|neo4j.types.Relationship|neo4j.types.Relationship[]} entity One or more entities
	 * @param {string} key The key associated with this
	 */
	add(entity, key)
	{
		const type = Graph.typeOf(entity)

		if (type === Graph.TYPE_ARRAY)
		{
			for (const item of entity)
			{
				this.add(item, key)
			}
			return
		}

		const id = Graph.getId(entity)

		this.references[key] = this.references[key] || []
		this.references[key].push(id !== null ? id : entity)

		if (type !== Graph.TYPE_NODE && type !== Graph.TYPE_RELATIONSHIP)
		{
			return
		}

		entity.$key = key

		const map = this.getMap(entity)
		let current

		if (map.has(id))
		{
			current = map.get(id)
		}
		else
		{
			current = {
				entity
			}
		}

		if (key in this.models)
		{
			const $Model = this.models[key]

			current.models = current.models || {}
			current.models[$Model.name] = new $Model(entity, this)
		}

		map.set(id, current)

		if (type === Graph.TYPE_RELATIONSHIP)
		{
			this.setRelationshipsIndex(entity)
		}
	}

	setRelationshipsIndex(entity)
	{
		const id = Graph.getId(entity)
		const index = this.index

		index[entity.type] = index[entity.type] || {
			in: {},
			out: {},
		}

		const start = Graph.getId(entity.start)
		const end = Graph.getId(entity.end)

		const map = index[entity.type]

		map.out[start] = map.out[start] || {}
		map.out[start][end] = id

		map.in[end] = map.in[end] || {}
		map.in[end][start] = id
	}

	/**
	 * Remove one or more entities from the graph
	 *
	 * @param {neo4j.types.Node|neo4j.types.Node[]|neo4j.types.Relationship|neo4j.types.Relationship[]|Node|Node[]|Relationship|Relationship[]} obj One or more objects
	 */
	remove(obj)
	{
		if (Array.isArray(obj))
		{
			for (const item of obj)
			{
				this.remove(item)
			}
			return
		}

		const entity = Graph.getEntity(obj)
		const id = Graph.getId(entity)

		this.getMap(entity).delete(id)

		const map = this.references[entity.$key]
		const i = map.indexOf(id)

		if (i >= 0)
		{
			map.splice(i, 1)
		}
	}

	/**
	 * Get the Neo4j node entity
	 *
	 * @param {Node|neo4j.types.Node|Object} subject Anything that could represent a node
	 * @returns {neo4j.types.Node} The Neo4j Node entity
	 */
	static getEntity(subject)
	{
		if (subject instanceof Graph.Node || subject instanceof Graph.Relationship)
		{
			return subject.$entity
		}

		if (subject instanceof neo4j.types.Node || subject instanceof neo4j.types.Relationship)
		{
			return subject
		}

		if (subject.entity)
		{
			return subject.entity
		}

		return null
	}

	/**
	 * Get the type of the entity
	 *
	 * @param {any} entity Entity object
	 * @returns {string} Graph.TYPE_NODE, Graph.TYPE_RELATIONSHIP, Graph.TYPE_INTEGER or native JS type
	 */
	static typeOf(entity)
	{
		if (entity instanceof neo4j.types.Node)
		{
			return Graph.TYPE_NODE
		}

		if (entity instanceof neo4j.types.Relationship)
		{
			return Graph.TYPE_RELATIONSHIP
		}

		if (neo4j.isInt(entity))
		{
			return Graph.TYPE_INTEGER
		}

		if (Array.isArray(entity))
		{
			return Graph.TYPE_ARRAY
		}

		return typeof entity
	}

	/**
	 * Get Neo4j Node id
	 *
	 * @param {Node|neo4j.types.Node|Object} subject Anything that could represent a node
	 * @returns {Number} The Neo4j Node ID as number
	 */
	static getId(subject)
	{
		if (!isNaN(subject))
		{
			return parseInt(subject)
		}

		if (subject instanceof Graph.Node || subject instanceof Graph.Relationship)
		{
			return subject.$id
		}

		if (subject instanceof neo4j.types.Node || subject instanceof neo4j.types.Relationship)
		{
			return subject.identity.toNumber()
		}

		return null
	}

	/**
	 * Get the map used for the entity
	 *
	 * @param {any} entity Entity object
	 * @returns {Map} The entity map
	 */
	getMap(entity)
	{
		const type = Graph.typeOf(entity)

		if (type === Graph.TYPE_NODE || entity instanceof Graph.Node)
		{
			return this.nodes
		}

		if (type === Graph.TYPE_RELATIONSHIP || (entity.prototype && entity.prototype instanceof Graph.Relationship))
		{
			return this.relationships
		}

		throw new Error(`No map defined for this entity: ${ entity }`)
	}

	/**
	 * Get Relationship from the graph
	 *
	 * @param {Node} start Start node
	 * @param {Node} end End node
	 * @param {Function} $Relationship Relationship class
	 * @returns {Relationship} Relationship or null if no match was found
	 */
	getRelationship(start, end, $Relationship)
	{
		start = Graph.getId(start)
		end = Graph.getId(end)

		let relationshipId = null

		const index = this.index[$Relationship.type]

		if (!index)
		{
			return null
		}

		if ($Relationship.direction === Graph.Relationship.OUT)
		{
			if (index.out[start] && index.out[start][end] >= 0)
			{
				relationshipId = index.out[start][end]
			}
			else
			{
				return null
			}
		}
		else if ($Relationship.direction === Graph.Relationship.IN)
		{
			if (index.in[start] && index.in[start][end] >= 0)
			{
				relationshipId = index.in[start][end]
			}
			else
			{
				return null
			}
		}
		// At this point the relationship could be in any direction so,
		// we simply check if a matching relationship is found in the index.
		else if (index.out[start] && index.out[start][end] >= 0)
		{
			relationshipId = index.out[start][end]
		}
		else if (index.in[start] && index.in[start][end] >= 0)
		{
			relationshipId = index.in[start][end]
		}
		else
		{
			return null
		}

		const relationship = this.relationships.get(relationshipId)

		if (!relationship.model)
		{
			relationship.model = new $Relationship(relationship.entity, this)
		}

		return relationship.model
	}

	/**
	 * Get related nodes
	 *
	 * @param {Node} node Start node
	 * @param {Function} $Relationship Relationship class
	 * @returns {Node[]} Related nodes
	 */
	getRelated(node, $Relationship)
	{
		const id = Graph.getId(node)

		const type = $Relationship.type
		const direction = $Relationship.direction

		const index = this.index[type]
		let others = []

		if (!index)
		{
			return others
		}

		if ([Graph.Relationship.OUT, Graph.Relationship.ANY].includes(direction))
		{
			if (id in index.out)
			{
				others = others.concat(Object.keys(index.out[id]))
			}
		}

		if ([Graph.Relationship.IN, Graph.Relationship.ANY, Graph.Relationship.BOTH].includes(direction))
		{
			if (id in index.in)
			{
				others = others.concat(Object.keys(index.in[id]))
			}
		}

		const unique = others.filter((other, position, array) => array.indexOf(other) === position)

		const results = unique.map(other =>
		{
			return this.getNodeModel(other, $Relationship.Model)
		})

		return $Relationship.singular ? results[0] : results
	}

	/**
	 * Wrap a Neo4j node in a specified Node model
	 *
	 * @param {Number|neo4j.integer} id The Neo4j Node ID
	 * @param {Function<Node>} $Node The Node Model Class
	 * @returns {Node} The Node model
	 */
	getNodeModel(id, $Node)
	{
		if (neo4j.isInt(id))
		{
			id = id.toNumber()
		}

		id = parseInt(id, 10)

		const node = this.nodes.get(id)

		if (!node)
		{
			throw new Error('Node not found')
		}

		if (!$Node)
		{
			$Node = Graph.Node
		}

		node.models = node.models || {}

		if (!node.models[$Node.name])
		{
			node.models[$Node.name] = new $Node(node.entity, this)
		}

		return node.models[$Node.name]
	}

	/**
	 * Get all Nodes for a reference used in the Cypher query
	 *
	 * @param {string} reference The reference
	 * @returns {array} An array of nodes
	 */
	getNodes(reference)
	{
		const ids = this.references[reference]
		const nodes = []
		const Model = this.models[reference]

		if (!ids)
		{
			return nodes
		}

		for (const id of ids)
		{
			nodes.push(this.getNodeModel(id, Model))
		}

		return nodes
	}

	/**
	 * Get linked results mapped to Models
	 *
	 * @param {Node} node The start node
	 * @param {string} name The name of the link
	 * @returns {any} The linked result
	 */
	getLinked(node, name)
	{
		if (!this.links || !this.links[name] || !this.links[name].items)
		{
			return null
		}

		const entity = Graph.getEntity(node)
		const id = Graph.getId(entity)
		const link = this.links[name]
		const results = []

		for (const item of link.items)
		{
			if (item.start !== id)
			{
				continue
			}

			let value = item.value

			if (item.type === Graph.TYPE_NODE)
			{
				value = this.getNodeModel(value, link.model)
			}

			if (item.type === Graph.TYPE_INTEGER)
			{
				value = value.toNumber()
			}

			if (item.type === Graph.TYPE_ARRAY && link.model && link.model.prototype instanceof Graph.NodeCollection)
			{
				const Collection = link.model

				value = new Collection(value)

				// Assume collections are 'singular' by default
				if (link.singular !== false)
				{
					return value
				}
			}

			if (link.singular)
			{
				return value
			}

			results.push(value)
		}

		return results
	}
}

module.exports = Graph