Home Reference Source

lib/Node.js

'use strict'

const neo4j = require('neo4j-driver/lib/v1')
const { Model } = require('maggoo')
const NodeCollection = require('./NodeCollection')
const RelatedNode = require('./RelatedNode')
const RelatedNodeCollection = require('./RelatedNodeCollection')
const Relationship = require('./Relationship')
const Graph = require('./Graph')
const DB = require('./DB')
const shortid = require('shortid')
const merge = require('deepmerge')


/**
 * Query options object
 * @typedef {Object} QueryOptions
 * @property {Object.<String, *>} [parameters] - Parameters used in the query.
 * @property {String} [variable] - The variable used to identify the Node in the query, 'n' by default.
 * @property {Array} [variables] - All variables used in the query that should be added to the results.
 * @property {String|Array} [where] - The where clause, arrays are joined by the 'AND' operator.
 * @property {Number} [limit] - The maximum number of results to be returned
 * @property {String} [with] - The name of relationship to include, use the dot-notation to include multiple levels
 * @property {Array} [with] - An array of names of relationships to include
 * @property {Object.<String, Boolean|Object>} [with] - Relationships to include (true) or exclude (false) or filter(object)
 * @property {Object.<String, NodeLink>} [links] - Linked results to connect to this model
 */

/**
 * @typedef {Object} NodeLink
 * @property {String} [start] Reference to the start node (defaults to 'n')
 * @property {String} end Reference to the end node
 * @property {Boolean} [singular] Only one result per record is returned (eg. when using scalar values or by using `collect`)
 */

/**
 * Relationship definition object
 * @typedef {object} RelationshipDefinition
 * @property {Node|function} Model - {@link Relationship.Model}
 * @property {string} type - {@link Relationship.type}
 * @property {string} direction - {@link Relationship.direction}
 * @property {boolean} singular - {@link Relationship.singular}
 */

const REGEX_LEVEL_SPLIT = /\.(.*)/
const REGEX_INDEX = /([^(]*)\(?([^)]*)\)?/i

// Private
const _related = Symbol('related')
const _linked = Symbol('linked')
const _labels = Symbol('labels')

/**
 * Class representing a single node
 */
class Node extends Model
{
	/**
	 * Creates an instance of Node.
	 *
	 * @param {Node|neo4j.types.Node} node Node data object or neo4j Node
	 * @param {Graph} [graph] The current graph
	 */
	constructor(node, graph)
	{
		if (node instanceof neo4j.types.Node)
		{
			super(node.properties)

			this.$labels = node.labels

			/**
			 * @type {neo4.Node} The original node data from the graph
			 */
			this.$entity = node
		}
		else
		{
			super(node)
		}

		/**
		 * @private
		 */
		this[_related] = {}

		/**
		 * @private
		 */
		this[_linked] = {}

		/**
		 * @type {Graph}
		 */
		this.$graph = graph
	}

	/**
	 * ID generator, uses ShortId by default
	 * @returns {string} ID
	 */
	static uuid()
	{
		return shortid.generate()
	}

	static addIndex(property, now = false)
	{
		return DB.addIndex(this.prototype.$type, property, now)
	}

	static addUnique(property, now = false)
	{
		return DB.addUnique(this.prototype.$type, property, now)
	}

	static async dropIndex(property)
	{
		await DB.dropIndex(this.prototype.$type, property)
	}

	static async dropUnique(property)
	{
		await DB.dropUnique(this.prototype.$type, property)
	}

	static get Collection()
	{
		const self = this

		return class extends NodeCollection
		{
			constructor(items)
			{
				super(self, items)
			}
		}
	}

	/**
	 * The node ID.
	 * This will be automatically generated when the node is saved
	 *
	 * @type {string}
	 */
	get id()
	{
		return this.$data.id
	}

	set id(value)
	{
		this.$data.id = value
	}

	/**
	 * The neo4j entity ID.
	 * Note that this could change over time, use id instead.
	 *
	 * @readonly
	 * @type {number}
	 */
	get $id()
	{
		return this.$entity ? this.$entity.identity.toNumber() : null
	}

	/**
	 * Whether or not the node is already stored in the graph database.
	 *
	 * @type {boolean}
	 */
	get $new()
	{
		return !this.$entity
	}

	/**
	 * Whether or not the Node has changed properties.
	 *
	 * @type {boolean}
	 */
	get $dirty()
	{
		return this.$new || super.$dirty
	}

	/**
	 * Set property value
	 *
	 * @param {string} key Property name
	 * @param {any} value Property value
	 */
	setProperty(key, value)
	{
		if (value && value.toNumber)
		{
			value = value.toNumber()
		}

		super.setProperty(key, value)
	}

	/**
	 * Get a Node property or related node(s).
	 *
	 * @param {string} key The name of the property or relationship
	 * @returns {any|RelatedNodeCollection} The value or related node(s)
	 */
	get(key)
	{
		if (key in this.constructor.relationships)
		{
			return this.getRelated(key)
		}

		const value = super.get(key)

		if (typeof value === 'undefined')
		{
			return this.getLinked(key)
		}

		return value
	}

	/**
	 * Set a Node property or related node(s).
	 *
	 * @param {string} key The name of the property or relationship
	 * @param {any} value The value or related node(s)
	 * @returns {boolean} Whether or not the property was successfully set
	 */
	set(key, value)
	{
		if (key in this.constructor.relationships)
		{
			return this.setRelated(key, value)
		}

		return super.set(key, value)
	}

	has(key)
	{
		// TODO: check related / linked

		return super.has(key)
	}

	/**
	 * Definitions of the relationships for this Node
	 *
	 * @member
	 * @readonly
	 * @type {object.<string, RelationshipDefinition>}
	 */
	static get relationships()
	{
		return {}
	}

	/**
	 * Get a Relationship class by name.
	 *
	 * @param {string} name The name of the relationship
	 * @returns {Relationship} The Relationship class
	 */
	static getRelationship(name)
	{
		const definition = this.relationships[name]

		if (definition.prototype instanceof Relationship)
		{
			return definition
		}

		class $Relationship extends Relationship {}

		Object.assign($Relationship, definition)

		return $Relationship
	}

	/**
	 * Get a Relationship class by name.
	 *
	 * @param {string} name The name of the relationship
	 * @returns {Relationship} The Relationship class
	 */
	getRelationship(name)
	{
		return this.constructor.getRelationship(name)
	}

	/**
	 * Get related node(s).
	 *
	 * @param {string} name The name of the relationship
	 * @returns {RelatedNodeCollection} The related node(s)
	 */
	getRelated(name)
	{
		const $Relationship = this.getRelationship(name)

		if (!this[_related][name])
		{
			const nodes = this.$graph ? this.$graph.getRelated(this, $Relationship) : []
			const related = new RelatedNodeCollection($Relationship, nodes, this)

			this[_related][name] = related
		}

		const result = this[_related][name]

		if (!result.length && $Relationship.singular)
		{
			return null
		}

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

	/**
	 * Get linked results.
	 *
	 * @param {string} name The name of the link
	 * @returns {*} The related node(s) / value(s) or null if none is found
	 */
	getLinked(name)
	{
		if (!this.$graph)
		{
			return null
		}

		if (!this.$graph.links || !(name in this.$graph.links))
		{
			return null
		}

		if (!this[_linked][name])
		{
			const linked = this.$graph ? this.$graph.getLinked(this, name) : []

			this[_linked][name] = linked
		}

		return this[_linked][name]
	}

	/**
	 * Add related node(s).
	 * Existing nodes will not be overwritten unless the relationship is singular.
	 *
	 * @param {string} name The name of the relationship
	 * @param {Node|NodeCollection|Node[]} node Node(s)
	 * @param {Object} [properties={}] Properties to set on the relationship(s)
	 */
	addRelated(name, node, properties = {})
	{
		this.setRelated(name, node, properties, false)
	}

	/**
	 * Set related node(s).
	 *
	 * @param {string} name The name of the relationship
	 * @param {Node|NodeCollection|Node[]} node Node(s)
	 * @param {any} [properties={}] Properties to set on the relationship(s)
	 * @param {boolean} [overwrite=true] If set to false existing nodes will not be overwritten unless the relationship is singular
	 */
	setRelated(name, node, properties = {}, overwrite = true)
	{
		const $Relationship = this.getRelationship(name)

		let result = null

		if ($Relationship.singular)
		{
			result = new RelatedNodeCollection($Relationship, [node], this)
			result[0].$rel.setProperties(properties)
		}
		else
		{
			const nodes = node instanceof Array ? node : [node]
			const items = []

			for (node of nodes)
			{
				const item = new RelatedNode($Relationship, this, node, properties, this.$graph)

				items.push(item)
			}

			if (overwrite)
			{
				result = new RelatedNodeCollection($Relationship, items, this)
			}
			else
			{
				result = this.getRelated(name)
				result.push(...items)
			}
		}

		this.setChanged(name, result)

		this[_related][name] = result
	}

	clearCachedRelationships(relationships = null)
	{
		if (relationships === null)
		{
			relationships = Object.keys(this.constructor.relationships)
		}

		relationships = this.constructor.normalizeRelationshipLevels(relationships)

		for (const relationship of Object.keys(relationships))
		{
			const filters = relationships[relationship]

			if (this[_related][relationship] && filters.with)
			{
				this[_related][relationship].clearCachedRelationships(filters.with)
			}

			delete this[_related][relationship]
		}
	}

	static get labels()
	{
		const labels = [this.name]

		if (this === Node)
		{
			return labels
		}

		let parent = Object.getPrototypeOf(this)

		while (parent !== Node && Reflect.has(parent, 'labels'))
		{
			const $labels = Reflect.get(parent, 'labels', parent)

			labels.push(...$labels)
			parent = Object.getPrototypeOf(parent)
		}

		return labels
	}

	static get baseLabel()
	{
		return this.labels[0]
	}

	get $base()
	{
		return this.constructor.baseLabel
	}

	/**
	 * Node labels. Will be added the node in the graph database.
	 * By default it uses the constructor name.
	 *
	 * @type {Array}
	 */
	get $labels()
	{
		if (this[_labels])
		{
			return this[_labels]
		}

		const labels = this.constructor.labels

		// Mark as private to exclude from esdoc
		/** @private */
		this[_labels] = labels

		return labels
	}

	/**
	 * Node labels. Will be added the node in the graph database.
	 * By default it uses the constructor name.
	 *
	 * @type {Array}
	 * @param {Array} labels Labels
	 */
	set $labels(labels)
	{
		// Mark as private to exclude from esdoc
		/** @private */
		this[_labels] = labels
	}

	/* * * * * * * * * * * * * * * *
	 *        WRITE METHODS        *
	 * * * * * * * * * * * * * * * */


	/**
	 * Save the Node to the graph database.
	 *
	 * @param {boolean|string[]} [deep=false] If true save all related nodes, or specific relationships defined as an array of strings.
	 * @param {any} [tx=null] The current database transaction
	 */
	async save(deep = false, tx = null)
	{
		// Prevent infinite loops in circular graphs
		if (tx && tx.$nodes.includes(this.id))
		{
			return
		}

		let session = null

		if (!tx)
		{
			tx = DB.beginTransaction()
			session = tx.session
		}

		this.id = this.id || this.constructor.uuid()

		tx.$nodes.push(this.id)

		if (this.$dirty)
		{
			const properties = Object.assign({}, this.$data)
			const parameters = { properties }

			let query = ''

			parameters.id = this.id

			query += `MERGE (n:${ this.$base } {id: {id}})`
			query += '\n'

			// TODO: remove properties that were explicitly deleted

			query += `
				SET n:${ this.$labels.join(':') }
				SET n += {properties}
				RETURN n
			`

			const result = await tx.run(query, parameters).then(r => r)

			this.$entity = result.records[0].get('n')
			this.reset()
		}

		if (deep)
		{
			const relationships = typeof deep === 'boolean' ? null : deep

			await this.saveRelated(relationships, tx)
		}

		if (session)
		{
			await tx.commit().then(r => r)

			session.close()
		}
	}

	/**
	 * Save related nodes and their relationships
	 *
	 * @param {Array} [keys] The relationships to be saved (all by default)
	 * @param {Transaction} [tx] The current database transaction
	 */
	async saveRelated(keys = null, tx = null)
	{
		let relationships = keys || Object.keys(this.constructor.relationships)

		if (typeof relationships === 'string')
		{
			relationships = [relationships]
		}

		for (const key of relationships)
		{
			let nodes = this.getRelated(key)

			if (nodes && !(nodes instanceof Array))
			{
				nodes = [nodes]
			}

			if (!nodes || !nodes.length)
			{
				continue
			}

			for (const node of nodes)
			{
				const deep = keys === null

				await node.save(deep, tx)

				if (node.$rel.$dirty)
				{
					await node.$rel.save(tx)
				}
			}
		}
	}

	/**
	 * Delete node from the graph database
	 *
	 * @param {boolean} [deep=false] Delete related nodes
	 * @param {Transaction} [tx=null] The current database transaction
	 */
	async delete(deep = false, tx = null)
	{
		if (!this.id)
		{
			throw new Error('Can not delete a node without ID')
		}

		// Prevent infinite loops in circular graphs
		if (tx && tx.$nodes.includes(this.id))
		{
			return
		}

		let session = null

		if (!tx)
		{
			tx = DB.beginTransaction()
			session = tx.session
			tx.$nodes = []
		}

		tx.$nodes.push(this.id)

		const query = `
			MATCH (n:${ this.$base })
			WHERE n.id = {id}
			DETACH DELETE n
		`

		const parameters = { id: this.id }

		await tx.run(query, parameters).then(r => r)

		if (deep)
		{
			await this.deleteRelated(tx)
		}

		if (this.$graph)
		{
			this.$graph.remove(this.$entity)
		}

		this.$entity = null
		this.$data.id = null

		if (session)
		{
			await tx.commit().then(r => r)

			session.close()
		}
	}

	/**
	 * Delete related nodes and their relationships
	 *
	 * @param {Transaction} [tx] The current database transaction
	 */
	async deleteRelated(tx = null)
	{
		for (const key of Object.keys(this.constructor.relationships))
		{
			const $Relationship = this.getRelationship(key)
			let nodes = this.getRelated(key)

			if ($Relationship.singular)
			{
				nodes = nodes ? [nodes] : []
			}

			for (const node of nodes)
			{
				await node.delete(true, tx)
			}
		}
	}

	async addLabel(label, tx = null)
	{
		await this.addLabels(label, tx)
	}

	async addLabels(labels, tx = null)
	{
		let query = `
			MATCH (n:${ this.$base })
			WHERE n.id = {id}
		`

		if (!Array.isArray(labels))
		{
			labels = [labels]
		}

		for (const label of labels)
		{
			query += `
				SET n:${ label }
			`
		}

		const parameters = { id: this.id }

		let session = null

		if (!tx)
		{
			tx = DB.beginTransaction()
			session = tx.session
		}

		await tx.run(query, parameters).then(r => r)

		const added = labels.filter(label => !this.$labels.includes(label))

		this.$labels.push(...added)

		if (session)
		{
			await tx.commit().then(r => r)

			session.close()
		}
	}

	async removeLabel(label, tx = null)
	{
		await this.removeLabels(label, tx)
	}

	async removeLabels(labels, tx = null)
	{
		let query = `
			MATCH (n:${ this.$base })
			WHERE n.id = {id}
		`

		if (!Array.isArray(labels))
		{
			labels = [labels]
		}

		for (const label of labels)
		{
			query += `
				REMOVE n:${ label }
			`
		}

		const parameters = { id: this.id }

		let session = null

		if (!tx)
		{
			tx = DB.beginTransaction()
			session = tx.session
		}

		await tx.run(query, parameters).then(r => r)

		this.$labels = this.$labels.filter(label => !labels.includes(label))

		if (session)
		{
			await tx.commit().then(r => r)

			session.close()
		}
	}


	/* * * * * * * * * * * * * * * *
	 *        READ METHODS         *
	 * * * * * * * * * * * * * * * */


	/**
	 * Default values for query options
	 */
	static get queryDefaults()
	{
		return {
			parameters: {},
			variable: 'n',
			variables: [],
			links: null
		}
	}

	/**
	 * Fetch a single Node
	 *
	 * @param {string|object} filters Filter object or Node id
	 * @param {QueryOptions} [o={}] Query options
	 * @returns {Node} Result
	 *
	 * @example
	 * const node = await Node.get('foo')
	 *
	 * @example
	 * const node = await Node.get({ id: 'foo' })
	 */
	static async get(filters, o = {})
	{
		o.singular = true

		return this.find(filters, o)
	}

	/**
	 * Fetch Nodes
	 *
	 * @param {QueryOptions} o Query options
	 * @return {NodeCollection} Results
	 *
	 * @example
	 * const nodes = await Node.all({ limit: 10 })
	 */
	static async all(o)
	{
		return this.find(null, o)
	}

	/**
	 * Use a query statement to search for Nodes
	 * By default returns results for 'n' as Node collection
	 *
	 * @param {string} query A search query
	 * @param {object} [parameters={}] Parameters used in query
	 * @param {QueryOptions} [o={}] Query options
	 * @returns {NodeCollection} Results
	 *
	 * @example
	 * const nodes = await Node.query(`
	 *     MATCH (n:Node)
	 *     WHERE n.foo > {foo}
	 * `, { foo: 1 })
	 */
	static async query(query, parameters = {}, o = {})
	{
		o = merge(o, { query, parameters })

		return this.find(null, o)
	}

	/**
	 * Use a query statement to search for Nodes
	 * By default returns results for 'n' as Node collection
	 *
	 * @param {string} where A where clause
	 * @param {object} [parameters={}] Parameters used in query
	 * @param {QueryOptions} [o={}] Query options
	 * @returns {NodeCollection} Results
	 *
	 * @example
	 * const nodes = await Node.where('n.foo > {foo}', { foo: 1 })
	 */
	static async where(where, parameters = {}, o = {})
	{
		o = merge(o, { where, parameters })

		return this.find(null, o)
	}

	/**
	 * Find Nodes based on filter or Node ID
	 *
	 * @param {object|string|number} filters Search filters, Node ID (string), or neo4j node identifier (number)
	 * @param {QueryOptions} [o={}] Query options
	 * @returns {NodeCollection} Results
	 *
	 * @example
	 * // Find by ID
	 * const nodes = Node.find('foo')
	 *
	 * @example
	 * // Find by filter
	 * const nodes = Node.find({ foo: 1 })
	 *
	 * @example
	 * // Find by filter, with related Nodes
	 * const nodes = Node.find({ foo: 1 }, { with: 'relatives' })
	 * nodes[0].relatives
	 */
	static async find(filters, o = {})
	{
		o = merge(merge({}, this.queryDefaults), o)

		if (!o.variables.includes(o.variable))
		{
			o.variables.push(o.variable)
		}

		if (o.models)
		{
			Object.keys(o.models).map(variable =>
			{
				if (!o.variables.includes(variable))
				{
					o.variables.push(variable)
				}
			})
		}

		if (o.links)
		{
			Object.keys(o.links).map(variable =>
			{
				if (!o.variables.includes(variable))
				{
					o.variables.push(variable)
				}
			})
		}

		if (filters)
		{
			o = merge(o, this.parseQueryFilters(filters, o.variable))
		}

		if (o.with)
		{
			o = merge(o, this.getRelationshipQueryOptions(o.with, o.variables, o.variable))
		}

		const query = this.buildQuery(o)

		const models = o.models || {}

		if (!models[o.variable])
		{
			models[o.variable] = this
		}

		const graph = await Graph.build(query, o.parameters, models, o.links)

		const nodes = graph.getNodes(o.variable)

		if (o.singular)
		{
			return nodes && nodes.length ? nodes[0] : null
		}

		return new NodeCollection(this, nodes)
	}

	/**
	 * Build a search query
	 *
	 * @param {object} o Query options
	 * @returns {string} Query string
	 */
	static buildQuery(o)
	{
		o = merge(this.queryDefaults, o)

		if (o.index && typeof o.index == 'string')
		{
			let [, label, property] = o.index.match(REGEX_INDEX)

			if (!property)
			{
				property = label
				label = this.baseLabel
			}

			o.index = { label, property }
		}

		// Build Query
		let query = o.query || `MATCH (${ o.variable }:${ o.index ? o.index.label : this.labels.join(':') })\n`

		if (o.index)
		{
			query += `USING INDEX ${ o.variable }:${ o.index.label }(${ o.index.property })\n`
		}

		// Define filters
		if (Array.isArray(o.where) && o.where.length)
		{
			query += 'WHERE '
			query += o.where.join('\nAND ')
			query += '\n'
		}
		else if (typeof o.where == 'string')
		{
			query += 'WHERE '
			query += o.where
			query += '\n'
		}

		if (o.matches)
		{
			query += o.matches.join('\n')
			query += '\n'
		}

		if (o.set)
		{
			const lines = Array.isArray(o.set) ? o.set : [o.set]

			for (const line of lines)
			{
				query += `SET ${ line }\n`
			}
		}

		query += 'RETURN '

		// Define return values
		if (o.return)
		{
			query += o.return
		}
		else
		{
			query += o.variables.join(', ')
		}

		query += '\n'

		if (o.orderBy)
		{
			query += `ORDER BY ${ o.orderBy }\n`
		}

		if (o.skip)
		{
			query += `SKIP ${ parseInt(o.skip, 10) }\n`
		}

		if (o.limit)
		{
			query += `LIMIT ${ parseInt(o.limit, 10) }\n`
		}
		else if (o.singular)
		{
			query += 'LIMIT 1\n'
		}

		return query
	}

	/**
	 * Parse search filters
	 *
	 * @param {object|string|number} filters Search filters, Node ID (string), or neo4j node identifier (number)
	 * @param {string} variable Variable for the current node
	 * @returns {QueryOptions} Filters options object
	 */
	static parseQueryFilters(filters, variable)
	{
		if (typeof filters == 'number')
		{
			filters = { $id: filters }
		}
		else if (typeof filters == 'string')
		{
			filters = { id: filters }
		}
		else if (!filters || filters.constructor !== Object)
		{
			throw new Error(`Invalid filter object: ${ filters }`)
		}

		const o = {
			parameters: {},
			where: []
		}

		for (const [key, value] of Object.entries(filters))
		{
			const valueVariable = `${ variable }_${ key }`
			let condition = ''

			if (key === '$id')
			{
				condition += `id(${ variable }) `
			}
			else
			{
				condition += `${ variable }.${ key } `
			}

			if (value instanceof RegExp)
			{
				condition += `=~ {${ valueVariable }}`

				const flags = `(?${ value.flags.replace('g', '') })`

				o.parameters[valueVariable] = flags + value.source
			}
			else if (Array.isArray(value))
			{
				const names = []

				value.forEach((v, i) =>
				{
					const name = valueVariable + i

					o.parameters[name] = v
					names.push(`{${ name }}`)
				})

				condition += `IN [${ names.join(', ') }]`
			}
			else
			{
				condition += `= {${ valueVariable }}`
				o.parameters[valueVariable] = value
			}

			o.where.push(condition)
		}

		return o
	}

	/**
	 * Count nodes in graph database
	 *
	 * @param {object|string} filters Search filters (object) or where clause (string)
	 * @param {any} [parameters={}] Parameters used in where clause
	 * @param {QueryOptions} [o={}] Query options
	 * @returns {number} Number of nodes
	 */
	static async count(filters, parameters = {}, o = {})
	{
		o = merge(this.queryDefaults, o)
		o = merge(o, { parameters })

		if (typeof filters == 'string')
		{
			o.where = filters
		}
		else if (!!filters && filters.constructor === Object)
		{
			o = merge(o, this.parseQueryFilters(filters, o.variable))
		}

		o.return = `count(distinct(${ o.variable })) as total`

		const query = this.buildQuery(o)

		const total = await DB.getScalar(query, o.parameters)

		return total.toInt()
	}

	/**
	 * Build query options for related nodes
	 * Mostly intended for internal usage.
	 *
	 * @param {string} levels Filter for related nodes in dot-format (eg. father.children will fetch the related 'father' and its 'children')
	 * @param {object} variables The variables currently used for building the query
	 * @param {string} [reference='n'] Reference variable for the current node
	 * @returns {options} Query options object
	 * @property {array} matches Optional matches for each relationship
	 * @property {object} variables The variables currently used for building the query
	 */
	static getRelationshipQueryOptions(levels, variables, reference = 'n')
	{
		let o = {
			matches: [],
			variables
		}

		levels = this.normalizeRelationshipLevels(levels)

		for (const name of Object.keys(levels))
		{
			let next = null
			const filters = levels[name]

			if (filters.with)
			{
				next = filters.with
				delete filters.with
			}
			else
			{
				next = null
			}

			const $Relationship = this.getRelationship(name)
			const nodeVar = `${ reference }_${ name }`
			const relationshipVar = `${ reference }_r_${ name }`
			const collectionVar = '_col'

			let query = `OPTIONAL MATCH (${ reference })`

			if ($Relationship.direction === Relationship.IN || $Relationship.direction === Relationship.BOTH)
			{
				query += '<'
			}

			query += `-[${ reference }_r_${ name }:${ $Relationship.type }]-`

			if ($Relationship.direction === Relationship.OUT || $Relationship.direction === Relationship.BOTH)
			{
				query += '>'
			}

			query += `(${ nodeVar }:${ $Relationship.Model.baseLabel })`

			if (!next)
			{
				o = merge(o, this.parseQueryFilters(filters, nodeVar))

				if (o.where && o.where.length)
				{
					query += '\n'
					query += 'WHERE '
					query += o.where.join('\nAND ')

					delete o.where
				}
			}

			query += '\n'
			query += `WITH ${ o.variables.join(', ') }, `
			query += `COLLECT(${ nodeVar }) as ${ nodeVar + collectionVar }, `
			query += `COLLECT(${ relationshipVar }) as ${ relationshipVar + collectionVar }`

			o.matches.push(query)

			o.variables.push(nodeVar + collectionVar)
			o.variables.push(relationshipVar + collectionVar)

			if (next)
			{
				const nextO = $Relationship.Model.getRelationshipQueryOptions(next, o.variables, nodeVar)

				o = merge(o, nextO)
			}
		}

		return o
	}

	static normalizeRelationshipLevels(levels)
	{
		if (typeof levels === 'string')
		{
			const [current, rest] = levels.split(REGEX_LEVEL_SPLIT)

			levels = { [current]: {} }

			if (rest)
			{
				levels[current].with = this.normalizeRelationshipLevels(rest)
			}
		}
		else if (Array.isArray(levels))
		{
			levels = levels.reduce((obj, current) =>
			{
				Object.assign(obj, this.normalizeRelationshipLevels(current))
				return obj
			}, {})
		}

		return levels
	}

	/**
	 * Get a node or a create a new one based on the given properties
	 *
	 * @param {object} criteria Specific properties to match
	 * @param {object} properties Properties to be set on the existing or created node
	 * @param {QueryOptions} [o={}] Query options
	 * @returns {Node} Resulting Node
	 */
	static async merge(criteria, properties, o = {})
	{
		o = merge(merge({}, this.queryDefaults), o)

		o.singular = true

		let query = `MERGE (${ o.variable }:${ this.labels.join(':') }`
		const keys = Object.keys(criteria)

		const queryParameters = {
			criteria,
			properties,
		}

		if (keys.length)
		{
			const parameters = []

			for (const key of keys)
			{
				parameters.push(`${ key }: {criteria}.${ key }`)
			}

			query += ` {${ parameters.join(', ') }}`
		}

		query += ')\n'

		if (!criteria.id && (!properties || !properties.id))
		{
			queryParameters.id = this.uuid()
			query += `ON CREATE SET ${ o.variable }.id={id}\n`
		}

		if (o.onCreate)
		{
			query += `ON CREATE ${ o.onCreate }\n`
		}

		if (o.onMatch)
		{
			query += `ON MATCH ${ o.onMatch }\n`
		}

		o.set = [`${ o.variable } += {criteria}`]

		if (properties)
		{
			o.set.push(`${ o.variable } += {properties}`)
		}

		return await this.query(query, queryParameters, o)
	}

	/**
	 * Fetch related Nodes connected to the current node.
	 *
	 * @param {object|array|string} relationships Similar to 'with' option in #Node.find
	 * @returns {NodeCollection|Object.<string, NodeCollection>} The related nodes
	 */
	async fetchRelated(relationships)
	{
		const $static = this.constructor

		let o = merge({}, $static.queryDefaults)

		o = merge(o, $static.parseQueryFilters({ id: this.id }, o.variable))
		o = merge(o, $static.getRelationshipQueryOptions(relationships, [o.variable]))

		const query = $static.buildQuery(o)

		await this.$graph.run(query, o.parameters)

		this.clearCachedRelationships(relationships)

		const normalized = $static.normalizeRelationshipLevels(relationships)
		const keys = Object.keys(normalized)
		const results = {}

		for (const key of keys)
		{
			results[key] = this.getRelated(key)
		}

		if (typeof relationships === 'string')
		{
			return results[keys[0]]
		}

		return results
	}
}

Node.addIndex('id')

module.exports = Node