├── .babelrc ├── test ├── fixtures │ └── ScanDirTest.js ├── instance.js ├── __instance.js ├── ModelMap.spec.js ├── RelationshipType.spec.js ├── Services │ ├── UpdateNode.spec.js │ ├── GenerateDefaultValues.spec.js │ ├── DeleteNode.spec.js │ ├── FindAll.spec.js │ └── CleanValue.spec.js ├── Collection.spec.js ├── Entity.spec.js ├── Schema.spec.js ├── Model.spec.js ├── Query │ └── EagerUtils.spec.js └── Factory.spec.js ├── .gitignore ├── example ├── neode.js ├── Person.js └── index.js ├── .npmignore ├── src ├── Query │ ├── WhereRaw.js │ ├── WithStatement.js │ ├── Order.js │ ├── WithDistinctStatement.js │ ├── Return.js │ ├── WhereId.js │ ├── Property.js │ ├── WhereBetween.js │ ├── Where.js │ ├── Create.js │ ├── Match.js │ ├── Relationship.js │ ├── WhereStatement.js │ ├── EagerUtils.js │ ├── Statement.js │ └── Builder.js ├── Services │ ├── DeleteAll.js │ ├── DeleteRelationship.js │ ├── DetachFrom.js │ ├── FindById.js │ ├── First.js │ ├── Create.js │ ├── UpdateRelationship.js │ ├── FindAll.js │ ├── UpdateNode.js │ ├── MergeOn.js │ ├── GenerateDefaultValues.js │ ├── FindWithinDistance.js │ ├── RelateTo.js │ ├── CleanValue.js │ ├── DeleteNode.js │ ├── WriteUtils.js │ └── Validator.js ├── TransactionError.js ├── ValidationError.js ├── Property.js ├── Collection.js ├── ModelMap.js ├── Schema.js ├── Entity.js ├── Queryable.js ├── RelationshipType.js ├── Relationship.js ├── Factory.js ├── Model.js ├── Node.js └── index.js ├── .eslintrc ├── TODO.md ├── .env.example ├── .travis.yml ├── LICENSE └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } -------------------------------------------------------------------------------- /test/fixtures/ScanDirTest.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | id: {type: 'string', primary: true}, 3 | name: {type: 'string'} 4 | }; -------------------------------------------------------------------------------- /test/instance.js: -------------------------------------------------------------------------------- 1 | import Neode from '../src/index'; 2 | 3 | module.exports = function() { 4 | return Neode.fromEnv(); 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | public/bower_components/ 3 | node_modules/ 4 | coverage/ 5 | .gulp-cache 6 | .env 7 | .nyc_output 8 | config/**/* 9 | test.js -------------------------------------------------------------------------------- /example/neode.js: -------------------------------------------------------------------------------- 1 | import Neode from '../src'; 2 | 3 | /** 4 | * Create and export a new instance using .env variables 5 | */ 6 | export default Neode.fromEnv(); -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | src/* 3 | test/* 4 | coverage/* 5 | .babelrc 6 | .env 7 | .env.example 8 | .eslintrc 9 | .gitignore 10 | gulpfile.js 11 | .travis.yml 12 | TODO.md -------------------------------------------------------------------------------- /src/Query/WhereRaw.js: -------------------------------------------------------------------------------- 1 | export default class WhereRaw { 2 | constructor(statement) { 3 | this._statement = statement; 4 | } 5 | 6 | toString() { 7 | return this._statement; 8 | } 9 | } -------------------------------------------------------------------------------- /src/Services/DeleteAll.js: -------------------------------------------------------------------------------- 1 | // TODO : Delete Dependencies 2 | 3 | export default function DeleteAll(neode, model) { 4 | const query = `MATCH (node:${model.labels().join(':')}) DETACH DELETE node`; 5 | 6 | return neode.writeCypher(query); 7 | } -------------------------------------------------------------------------------- /src/Query/WithStatement.js: -------------------------------------------------------------------------------- 1 | export default class WithStatement { 2 | constructor(...args) { 3 | this._with = args; 4 | } 5 | 6 | toString() { 7 | const vars = this._with.join(','); 8 | return 'WITH '+ vars; 9 | } 10 | } -------------------------------------------------------------------------------- /src/Query/Order.js: -------------------------------------------------------------------------------- 1 | export default class Order { 2 | constructor(what, how) { 3 | this._what = what; 4 | this._how = how || ''; 5 | } 6 | 7 | toString() { 8 | return `${this._what} ${this._how}`.trim(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/TransactionError.js: -------------------------------------------------------------------------------- 1 | export const ERROR_TRANSACTION_FAILED = 'ERROR_TRANSACTION_FAILED'; 2 | 3 | export default class TransactionError extends Error { 4 | constructor(errors) { 5 | super(ERROR_TRANSACTION_FAILED, 500); 6 | 7 | this.errors = errors; 8 | } 9 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | parserOptions: 2 | sourceType: module 3 | rules: 4 | strict: 5 | - 1 6 | indent: 7 | - 2 8 | - 4 9 | - SwitchCase: 1 10 | semi: 11 | - error 12 | - always 13 | env: 14 | es6: true 15 | node: true 16 | extends: 'eslint:recommended' 17 | -------------------------------------------------------------------------------- /src/Query/WithDistinctStatement.js: -------------------------------------------------------------------------------- 1 | export default class WithDistinctStatement { 2 | constructor(...args) { 3 | this._with = args; 4 | } 5 | 6 | toString() { 7 | const vars = this._with.join(','); 8 | return 'WITH DISTINCT '+ vars; 9 | } 10 | } -------------------------------------------------------------------------------- /src/Services/DeleteRelationship.js: -------------------------------------------------------------------------------- 1 | export default function DeleteRelationship(neode, identity) { 2 | const query = ` 3 | MATCH ()-[rel]->() 4 | WHERE id(rel) = $identity 5 | DELETE rel 6 | `; 7 | 8 | return neode.writeCypher(query, { identity }); 9 | } -------------------------------------------------------------------------------- /src/ValidationError.js: -------------------------------------------------------------------------------- 1 | export const ERROR_VALIDATION = 'ERROR_VALIDATION'; 2 | 3 | export default class ValidationError extends Error { 4 | constructor(details, input, _joiError) { 5 | super(ERROR_VALIDATION, 422); 6 | 7 | this.details = details; 8 | this.input = input; 9 | this._joiError = _joiError; 10 | } 11 | } -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | - Routing policies and countries for CC 3 | - Relationships 4 | - Relationship Constraints 5 | - Delete dependencies when deleting a node beyond the first degree 6 | - Schema 7 | - Composite indexes 8 | - Query Builder 9 | - More where clauses 10 | - CREATE 11 | - SET 12 | - DELETE 13 | - Match Relationship 14 | - Match path 15 | -------------------------------------------------------------------------------- /src/Query/Return.js: -------------------------------------------------------------------------------- 1 | export default class Return { 2 | constructor(alias, as) { 3 | // TODO: Does alias carry an 'as' value? 4 | this._alias = alias; 5 | this._as = as; 6 | } 7 | 8 | toString() { 9 | let output = this._alias; 10 | 11 | if (this._as) { 12 | output += ' AS '+ this._as; 13 | } 14 | 15 | return output; 16 | } 17 | } -------------------------------------------------------------------------------- /src/Services/DetachFrom.js: -------------------------------------------------------------------------------- 1 | export default function DetachFrom(neode, from, to) { 2 | let params = { 3 | from_id: from.identity(), 4 | to_id: to.identity(), 5 | }; 6 | 7 | const query = ` 8 | MATCH (from)-[rel]-(to) 9 | WHERE id(from) = $from_id 10 | AND id(to) = $to_id 11 | DELETE rel 12 | `; 13 | 14 | return neode.writeCypher(query, params) 15 | .then(() => [from, to]); 16 | } -------------------------------------------------------------------------------- /src/Query/WhereId.js: -------------------------------------------------------------------------------- 1 | export default class WhereId { 2 | constructor(alias, param) { 3 | this._alias = alias; 4 | this._param = param; 5 | 6 | this._negative = false; 7 | } 8 | 9 | setNegative() { 10 | this._negative = true; 11 | } 12 | 13 | toString() { 14 | const negative = this._negative ? 'NOT ' : ''; 15 | return `${negative}id(${this._alias}) = $${this._param}`; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/__instance.js: -------------------------------------------------------------------------------- 1 | import Neode from '../src/index'; 2 | 3 | function createInstance() { 4 | return Neode.fromEnv(); 5 | } 6 | 7 | module.exports = createInstance; 8 | 9 | 10 | /** Testing * / 11 | before(done => { 12 | instance = require('../instance')(); 13 | model = instance.model(label, schema); 14 | 15 | instance.deleteAll(label).then(() => done()) 16 | }); 17 | after(() => instance.close()); 18 | /** End Testing */ -------------------------------------------------------------------------------- /src/Query/Property.js: -------------------------------------------------------------------------------- 1 | export default class Property { 2 | constructor(property, param, operator = '=') { 3 | this._property = property; 4 | this._param = `$${param}` || 'null'; 5 | this._operator = operator; 6 | } 7 | 8 | toString() { 9 | return `${this._property} ${this._operator} ${this._param}`.trim(); 10 | } 11 | 12 | toInlineString() { 13 | return `${this._property}: ${this._param}`.trim(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Services/FindById.js: -------------------------------------------------------------------------------- 1 | import Builder, {mode} from '../Query/Builder'; 2 | import { eagerNode, } from '../Query/EagerUtils'; 3 | 4 | export default function FindById(neode, model, id) { 5 | const alias = 'this'; 6 | 7 | const builder = new Builder(neode); 8 | 9 | return builder.match(alias, model) 10 | .whereId(alias, id) 11 | .return( eagerNode(neode, 1, alias, model) ) 12 | .limit(1) 13 | .execute(mode.READ) 14 | .then(res => neode.hydrateFirst(res, alias, model)); 15 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEO4J_PROTOCOL=bolt 2 | NEO4J_HOST=localhost 3 | NEO4J_USERNAME=neo4j 4 | NEO4J_PASSWORD=neo 5 | NEO4J_PORT=7687 6 | 7 | NEO4J_ENCRYPTED=ENCRYPTION_ON 8 | NEO4J_TRUST=TRUST_SIGNED_CERTIFICATES 9 | NEO4J_TRUSTED_CERTIFICATES=/path/to/cert.pem 10 | NEO4J_KNOWN_HOSTS=127.0.0.1 11 | NEO4J_MAX_CONNECTION_POOLSIZE=100 12 | NEO4J_MAX_TRANSACTION_RETRY_TIME=5000 13 | NEO4J_LOAD_BALANCING_STRATEGY=least_connected 14 | NEO4J_MAX_CONNECTION_LIFETIME=36000 15 | NEO4J_CONNECTION_TIMEOUT=36000 16 | NEO4J_DISABLE_LOSSLESS_INTEGERS=false -------------------------------------------------------------------------------- /src/Query/WhereBetween.js: -------------------------------------------------------------------------------- 1 | export default class WhereBetween { 2 | 3 | constructor(alias, floor, ceiling) { 4 | this._alias = alias; 5 | this._floor = floor; 6 | this._ceiling = ceiling; 7 | this._negative = false; 8 | } 9 | 10 | setNegative() { 11 | this._negative = true; 12 | } 13 | 14 | toString() { 15 | const negative = this._negative ? 'NOT ' : ''; 16 | 17 | return `${negative}$${this._floor} <= ${this._alias} <= $${this._ceiling}`; 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /src/Query/Where.js: -------------------------------------------------------------------------------- 1 | export const OPERATOR_EQUALS = '='; 2 | 3 | export default class Where { 4 | 5 | constructor(left, operator, right) { 6 | this._left = left; 7 | this._operator = operator; 8 | this._right = right; 9 | this._negative = false; 10 | } 11 | 12 | setNegative() { 13 | this._negative = true; 14 | } 15 | 16 | toString() { 17 | const negative = this._negative ? 'NOT ' : ''; 18 | 19 | return `${negative}${this._left} ${this._operator} ${this._right}`; 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | enabled: false 2 | language: node_js 3 | 4 | jdk: 5 | - oraclejdk8 6 | 7 | env: 8 | - NEO4J_VERSION="4.0.0" 9 | 10 | before_install: 11 | - wget dist.neo4j.org/neo4j-enterprise-$NEO4J_VERSION-unix.tar.gz 12 | - tar -xzf neo4j-enterprise-$NEO4J_VERSION-unix.tar.gz 13 | - neo4j-enterprise-$NEO4J_VERSION/bin/neo4j-admin set-initial-password TravisCI 14 | - neo4j-enterprise-$NEO4J_VERSION/bin/neo4j start 15 | 16 | branches: 17 | only: 18 | - master 19 | 20 | node_js: 21 | - "8" 22 | - "12" 23 | 24 | script: 25 | - "npm run test" 26 | -------------------------------------------------------------------------------- /src/Query/Create.js: -------------------------------------------------------------------------------- 1 | import Model from '../Model'; 2 | 3 | export default class Create { 4 | constructor(alias, model = false) { 5 | this._alias = alias; 6 | this._model = model; 7 | } 8 | 9 | toString() { 10 | const alias = this._alias || ''; 11 | let model = ''; 12 | 13 | if ( this._model instanceof Model ) { 14 | model = `:${this._model.labels().join(':')}`; 15 | } 16 | else if ( typeof this._model == 'string' ) { 17 | model = `:${this._model}`; 18 | } 19 | 20 | return `(${alias}${model ? model : ''})`; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/Person.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Person Definition 3 | */ 4 | export default { 5 | person_id: { 6 | type: 'uuid', 7 | primary: true, 8 | }, 9 | name: { 10 | type: 'string', 11 | index: true, 12 | }, 13 | age: 'number', 14 | knows: { 15 | type: 'relationship', 16 | relationship: 'KNOWS', 17 | direction: 'out', 18 | properties: { 19 | since: { 20 | type: 'localdatetime', 21 | default: () => new Date, 22 | }, 23 | }, 24 | }, 25 | createdAt: { 26 | type: 'datetime', 27 | default: () => new Date, 28 | } 29 | }; -------------------------------------------------------------------------------- /src/Services/First.js: -------------------------------------------------------------------------------- 1 | import Builder, {mode} from '../Query/Builder'; 2 | import { eagerNode, } from '../Query/EagerUtils'; 3 | 4 | export default function First(neode, model, key, value) { 5 | const alias = 'this'; 6 | 7 | const builder = new Builder(neode); 8 | 9 | // Match 10 | builder.match(alias, model); 11 | 12 | // Where 13 | if (typeof key == 'object') { 14 | // Process a map of properties 15 | Object.keys(key).forEach(property => { 16 | builder.where(`${alias}.${property}`, key[ property ]); 17 | }); 18 | } 19 | else { 20 | // Straight key/value lookup 21 | builder.where(`${alias}.${key}`, value); 22 | } 23 | 24 | const output = eagerNode(neode, 1, alias, model); 25 | 26 | return builder.return(output) 27 | .limit(1) 28 | .execute(mode.READ) 29 | .then(res => neode.hydrateFirst(res, alias, model)); 30 | } -------------------------------------------------------------------------------- /src/Services/Create.js: -------------------------------------------------------------------------------- 1 | import GenerateDefaultValues from './GenerateDefaultValues'; 2 | import Validator from './Validator'; 3 | import Builder, {mode} from '../Query/Builder'; 4 | import { eagerNode, } from '../Query/EagerUtils'; 5 | import { addNodeToStatement, ORIGINAL_ALIAS } from './WriteUtils'; 6 | 7 | export default function Create(neode, model, properties) { 8 | return GenerateDefaultValues(neode, model, properties) 9 | .then(properties => Validator(neode, model, properties)) 10 | .then(properties => { 11 | const alias = ORIGINAL_ALIAS; 12 | 13 | const builder = new Builder(neode); 14 | 15 | addNodeToStatement(neode, builder, alias, model, properties, [ alias ]); 16 | 17 | // Output 18 | const output = eagerNode(neode, 1, alias, model); 19 | 20 | return builder.return(output) 21 | .execute(mode.WRITE) 22 | .then(res => neode.hydrateFirst(res, alias)); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/Query/Match.js: -------------------------------------------------------------------------------- 1 | // TODO: Rename this, NodePattern? 2 | import Model from '../Model'; 3 | 4 | export default class Match { 5 | constructor(alias, model = false, properties = []) { 6 | this._alias = alias; 7 | this._model = model; 8 | this._properties = properties; 9 | } 10 | 11 | toString() { 12 | const alias = this._alias || ''; 13 | let model = ''; 14 | let properties = ''; 15 | 16 | if ( this._model instanceof Model ) { 17 | model = `:${this._model.labels().join(':')}`; 18 | } 19 | else if ( typeof this._model == 'string' ) { 20 | model = `:${this._model}`; 21 | } 22 | 23 | if ( this._properties.length ) { 24 | properties = ' { '; 25 | 26 | properties += this._properties.map(property => { 27 | return property.toInlineString(); 28 | }).join(', '); 29 | 30 | properties += ' }'; 31 | } 32 | 33 | return `(${alias}${model ? model : ''}${properties})`; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Services/UpdateRelationship.js: -------------------------------------------------------------------------------- 1 | import CleanValue from './CleanValue'; 2 | import Validator from './Validator'; 3 | 4 | export default function UpdateRelationship(neode, model, identity, properties) { 5 | const query = ` 6 | MATCH ()-[rel]->() 7 | WHERE id(rel) = $identity 8 | SET rel += $properties 9 | RETURN properties(rel) as properties 10 | `; 11 | 12 | // Clean up values 13 | const schema = model.schema(); 14 | 15 | Object.keys(schema).forEach(key => { 16 | const config = typeof schema[ key ] == 'string' ? {type: schema[ key ]} : schema[ key ]; 17 | 18 | // Clean Value 19 | if (properties[ key ]) { 20 | properties[ key ] = CleanValue(config, properties[ key ]); 21 | } 22 | }); 23 | 24 | return Validator(neode, model, properties) 25 | .then(properties => { 26 | return neode.writeCypher(query, { identity, properties }) 27 | .then(res => { 28 | return res.records[0].get('properties'); 29 | }); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/Services/FindAll.js: -------------------------------------------------------------------------------- 1 | import Builder, {mode} from '../Query/Builder'; 2 | import { eagerNode, } from '../Query/EagerUtils'; 3 | 4 | export default function FindAll(neode, model, properties, order, limit, skip) { 5 | const alias = 'this'; 6 | 7 | const builder = new Builder(neode); 8 | 9 | // Match 10 | builder.match(alias, model); 11 | 12 | // Where 13 | if (properties) { 14 | Object.keys(properties).forEach(key => { 15 | builder.where(`${alias}.${key}`, properties[ key ]); 16 | }); 17 | } 18 | 19 | // Order 20 | if (typeof order == 'string') { 21 | builder.orderBy(`${alias}.${order}`); 22 | } 23 | else if (typeof order == 'object') { 24 | Object.keys(order).forEach(key => { 25 | builder.orderBy(`${alias}.${key}`, order[ key ]); 26 | }); 27 | } 28 | 29 | // Output 30 | const output = eagerNode(neode, 1, alias, model); 31 | 32 | return builder.return(output) 33 | .limit(limit) 34 | .skip(skip) 35 | .execute(mode.READ) 36 | .then(res => neode.hydrate(res, alias)); 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2018 Adam Cowley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import neode from './neode'; 3 | import Person from './Person'; 4 | 5 | /** 6 | * Define a Person 7 | */ 8 | neode.model('Person', Person); 9 | 10 | 11 | /** 12 | * Create a couple of People nodes 13 | */ 14 | Promise.all([ 15 | neode.create('Person', {name: 'Adam'}), 16 | neode.create('Person', {name: 'Joe'}) 17 | ]) 18 | 19 | /** 20 | * Log out some details and relate the two together 21 | */ 22 | .then(([adam, joe]) => { 23 | console.log('adam', adam.id(), adam.get('person_id'), adam.get('name')); 24 | console.log('joe', joe.id(), joe.get('person_id'), joe.get('name')); 25 | 26 | return adam.relateTo(joe, 'knows', {since: new Date('2017-01-02 12:34:56')}); 27 | }) 28 | 29 | /** 30 | * Log out relationship details 31 | */ 32 | .then(rel => { 33 | console.log('rel', rel.id(), rel.get('since')); 34 | 35 | return rel; 36 | }) 37 | 38 | /** 39 | * Delete the two nodes 40 | */ 41 | .then(rel => { 42 | return Promise.all([ 43 | rel.startNode().delete(), 44 | rel.endNode().delete() 45 | ]); 46 | }) 47 | 48 | /** 49 | * Close Driver 50 | */ 51 | .then(() => neode.close()); 52 | -------------------------------------------------------------------------------- /src/Services/UpdateNode.js: -------------------------------------------------------------------------------- 1 | import Validator from './Validator'; 2 | import CleanValue from './CleanValue'; 3 | 4 | export default function UpdateNode(neode, model, identity, properties) { 5 | const query = ` 6 | MATCH (node) 7 | WHERE id(node) = $identity 8 | SET node += $properties 9 | WITH node 10 | 11 | UNWIND keys($properties) AS key 12 | RETURN key, node[key] AS value 13 | `; 14 | 15 | // Clean up values 16 | const schema = model.schema(); 17 | 18 | Object.keys(schema).forEach(key => { 19 | const config = typeof schema[ key ] == 'string' ? {type: schema[ key ]} : schema[ key ]; 20 | 21 | // Clean Value 22 | if (properties[ key ]) { 23 | properties[ key ] = CleanValue(config, properties[ key ]); 24 | } 25 | }); 26 | 27 | return Validator(neode, model, properties) 28 | .then(properties => { 29 | return neode.writeCypher(query, { identity, properties }) 30 | .then(res => { 31 | return res.records.map(row => ({ key: row.get('key'), value: row.get('value') })) 32 | }); 33 | }); 34 | } -------------------------------------------------------------------------------- /src/Query/Relationship.js: -------------------------------------------------------------------------------- 1 | import { DIRECTION_IN, DIRECTION_OUT, ALT_DIRECTION_IN, ALT_DIRECTION_OUT } from '../RelationshipType'; 2 | 3 | export default class Relationship { 4 | constructor(relationship, direction, alias, traversals) { 5 | this._relationship = relationship; 6 | this._direction = direction ? direction.toUpperCase() : ''; 7 | this._alias = alias; 8 | this._traversals = traversals; 9 | } 10 | 11 | toString() { 12 | const dir_in = this._direction == DIRECTION_IN || this._direction == ALT_DIRECTION_IN ? '<' : ''; 13 | const dir_out = this._direction == DIRECTION_OUT || this._direction == ALT_DIRECTION_OUT ? '>' : ''; 14 | const alias = this._alias ? `${this._alias}` : ''; 15 | 16 | let relationship = this._relationship || ''; 17 | 18 | if ( Array.isArray(relationship) ) { 19 | relationship = relationship.join('`|`'); 20 | } 21 | 22 | if ( relationship != '' ) { 23 | relationship = `:\`${relationship}\``; 24 | } 25 | 26 | const traversals = this._traversals ? `*${this._traversals}` : ''; 27 | 28 | const rel = this._relationship || this._alias || this._traversals ? `[${alias}${relationship}${traversals}]` : ''; 29 | 30 | return `${dir_in}-${rel}-${dir_out}`; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/ModelMap.spec.js: -------------------------------------------------------------------------------- 1 | import ModelMap from '../src/ModelMap'; 2 | import Model from '../src/Model'; 3 | import {assert, expect} from 'chai'; 4 | 5 | describe('src/ModelMap.js', () => { 6 | const map = new ModelMap(); 7 | 8 | describe('::set', () => { 9 | it('should set and get a new model', () => { 10 | const name = 'ModelMap'; 11 | const model = new Model(null, name); 12 | 13 | map.set(name, model); 14 | 15 | expect( map.get(name) ).to.equal(model); 16 | }); 17 | }); 18 | 19 | describe('::getByLabels', () => { 20 | it('should identify a single label model', () => { 21 | const name = 'SingleLabelModel'; 22 | const model = new Model(null, name); 23 | const schema = {} 24 | 25 | map.set(name, model); 26 | 27 | expect( map.getByLabels([ name ]) ).to.equal(model); 28 | }); 29 | 30 | it('should identify a model with multiple labels', () => { 31 | const name = 'MultipleLabelModel'; 32 | const schema = { 33 | labels: ['Multiple', 'Labels'] 34 | } 35 | const model = new Model(null, name, schema); 36 | 37 | map.set(name, model); 38 | 39 | expect( map.getByLabels(schema.labels) ).to.equal(model); 40 | }); 41 | 42 | }); 43 | 44 | }); -------------------------------------------------------------------------------- /src/Services/MergeOn.js: -------------------------------------------------------------------------------- 1 | /* 2 | import GenerateDefaultValues from './GenerateDefaultValues'; 3 | import Node from '../Node'; 4 | import Validator from './Validator'; 5 | import { DIRECTION_IN, DIRECTION_OUT } from '../RelationshipType'; 6 | import { eagerNode } from '../Query/EagerUtils'; 7 | 8 | const MAX_CREATE_DEPTH = 99; 9 | const ORIGINAL_ALIAS = 'this'; 10 | */ 11 | import GenerateDefaultValues from './GenerateDefaultValues'; 12 | import Validator from './Validator'; 13 | import Builder, { mode, } from '../Query/Builder'; 14 | import { eagerNode, } from '../Query/EagerUtils'; 15 | import { addNodeToStatement, ORIGINAL_ALIAS } from './WriteUtils'; 16 | 17 | 18 | export default function MergeOn(neode, model, merge_on, properties) { 19 | return GenerateDefaultValues(neode, model, properties) 20 | .then(properties => Validator(neode, model, properties)) 21 | .then(properties => { 22 | const alias = ORIGINAL_ALIAS; 23 | 24 | const builder = new Builder(neode); 25 | 26 | addNodeToStatement(neode, builder, alias, model, properties, [ alias ], 'merge', merge_on); 27 | 28 | // Output 29 | const output = eagerNode(neode, 1, alias, model); 30 | 31 | return builder.return(output) 32 | .execute(mode.WRITE) 33 | .then(res => neode.hydrateFirst(res, alias)); 34 | }); 35 | } -------------------------------------------------------------------------------- /src/Property.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Container holding information for a property. 3 | * 4 | * TODO: Schema validation to enforce correct data types 5 | */ 6 | export default class Property { 7 | constructor(name, schema) { 8 | if ( typeof schema == 'string' ) { 9 | schema = {type:schema}; 10 | } 11 | 12 | this._name = name; 13 | this._schema = schema; 14 | 15 | // TODO: Clean Up 16 | Object.keys(schema).forEach(key => { 17 | this['_'+ key] = schema[key]; 18 | }); 19 | } 20 | 21 | name() { 22 | return this._name; 23 | } 24 | 25 | type() { 26 | return this._schema.type; 27 | } 28 | 29 | primary() { 30 | return this._primary || false; 31 | } 32 | 33 | unique() { 34 | return this._unique || false; 35 | } 36 | 37 | exists() { 38 | return this._exists || false; 39 | } 40 | 41 | required() { 42 | return this._exists || this._required || false; 43 | } 44 | 45 | indexed() { 46 | return this._index || false; 47 | } 48 | 49 | protected() { 50 | return this._primary || this._protected; 51 | } 52 | 53 | hidden() { 54 | return this._hidden; 55 | } 56 | 57 | readonly() { 58 | return this._readonly || false; 59 | } 60 | 61 | convertToInteger() { 62 | return this._type == 'int' || this._type == 'integer'; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Query/WhereStatement.js: -------------------------------------------------------------------------------- 1 | export const CONNECTOR_AND = 'AND'; 2 | export const CONNECTOR_OR = 'OR'; 3 | 4 | export default class WhereStatement { 5 | 6 | constructor(prefix) { 7 | this._prefix = prefix || ''; 8 | this._clauses = []; 9 | this._connector = CONNECTOR_AND; 10 | } 11 | 12 | /** 13 | * Set the Connector string for chaining statements (AND, OR) 14 | * 15 | * @param {String} connector 16 | */ 17 | setConnector(connector) { 18 | this._connector = connector; 19 | } 20 | 21 | /** 22 | * Append a new clause 23 | * 24 | * @param {Where} clause Where clause to append 25 | * @return {WhereStatement} 26 | */ 27 | append(clause) { 28 | this._clauses.push(clause); 29 | 30 | return this; 31 | } 32 | 33 | /** 34 | * Return the last condition in the collection 35 | * 36 | * @return {Where} 37 | */ 38 | last() { 39 | return this._clauses[ this._clauses.length - 1 ]; 40 | } 41 | 42 | /** 43 | * Convert this Where Statement to a String 44 | * 45 | * @return {String} 46 | */ 47 | toString() { 48 | if (!this._clauses.length) return; 49 | 50 | const statements = this._clauses.map(clause => { 51 | return clause.toString(); 52 | }).join(' '+ this._connector+ ' '); 53 | 54 | return `${this._prefix} (${statements}) `; 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neode", 3 | "version": "0.4.9", 4 | "description": "Neo4j OGM for NodeJS", 5 | "main": "build/index.js", 6 | "types": "types/index.d.ts", 7 | "scripts": { 8 | "build": "./node_modules/@babel/cli/bin/babel.js src -d build", 9 | "test": "./node_modules/.bin/mocha --timeout 5000 --require @babel/register ./test{/,/**/}*.spec.js", 10 | "lint": "./node_modules/.bin/eslint src/", 11 | "coverage": "./node_modules/.bin/nyc --reporter=html npm test", 12 | "tdd": "./node_modules/.bin/mocha --require @babel/register --watch" 13 | }, 14 | "keywords": [ 15 | "neo4j", 16 | "graph", 17 | "cypher", 18 | "ogm" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/adam-cowley/neode.git" 23 | }, 24 | "author": "Adam Cowley ", 25 | "license": "MIT", 26 | "dependencies": { 27 | "@hapi/joi": "^15.1.1", 28 | "dotenv": "^4.0.0", 29 | "neo4j-driver": "^4.2.2", 30 | "uuid": "^3.4.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "^7.8.4", 34 | "@babel/core": "^7.9.6", 35 | "@babel/node": "^7.8.4", 36 | "@babel/preset-env": "^7.9.6", 37 | "@babel/register": "^7.8.6", 38 | "chai": "^3.5.0", 39 | "eslint": "^4.19.1", 40 | "mocha": "^5.2.0", 41 | "nyc": "^14.1.1" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/adam-cowley/neode/issues" 45 | }, 46 | "homepage": "https://github.com/adam-cowley/neode#readme" 47 | } 48 | -------------------------------------------------------------------------------- /src/Services/GenerateDefaultValues.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid'; 2 | import ValidationError from '../ValidationError'; 3 | import CleanValue from './CleanValue'; 4 | 5 | function GenerateDefaultValuesAsync(neode, model, properties) { 6 | const schema = model.schema(); 7 | const output = {}; 8 | 9 | if ( !(properties instanceof Object )) { 10 | throw new ValidationError('`properties` must be an object.', properties); 11 | } 12 | 13 | // Get All Config 14 | Object.keys(schema).forEach(key => { 15 | const config = typeof schema[ key ] == 'string' ? {type: schema[ key ]} : schema[ key ]; 16 | 17 | switch (config.type) { 18 | case 'uuid': 19 | config.default = uuid.v4; 20 | break; 21 | } 22 | 23 | if (properties.hasOwnProperty(key)) { 24 | output[ key ] = properties[ key ]; 25 | } 26 | 27 | // Set Default Value 28 | else if (typeof config.default !== "undefined") { 29 | output[ key ] = typeof config.default == 'function' ? config.default() : config.default; 30 | } 31 | 32 | // Clean Value 33 | if (output[ key ]) { 34 | output[ key ] = CleanValue(config, output[ key ]); 35 | } 36 | }); 37 | 38 | return output; 39 | } 40 | 41 | /** 42 | * Generate default values where no values are not currently set. 43 | * 44 | * @param {Neode} neode 45 | * @param {Model} model 46 | * @param {Object} properties 47 | * @return {Promise} 48 | */ 49 | function GenerateDefaultValues(neode, model, properties) { 50 | const output = GenerateDefaultValuesAsync(neode, model, properties); 51 | 52 | return Promise.resolve(output); 53 | } 54 | 55 | GenerateDefaultValues.async = GenerateDefaultValuesAsync; 56 | 57 | export default GenerateDefaultValues; 58 | -------------------------------------------------------------------------------- /test/RelationshipType.spec.js: -------------------------------------------------------------------------------- 1 | import RelationshipType, { DIRECTION_IN, DIRECTION_OUT } from '../src/RelationshipType'; 2 | import Property from '../src/Property'; 3 | import Model from '../src/Model'; 4 | import {assert, expect} from 'chai'; 5 | 6 | describe('RelationshipType.js', () => { 7 | let instance; 8 | let model; 9 | 10 | 11 | it('should construct', () => { 12 | const name = 'test'; 13 | const type = 'relationships'; 14 | const rel = 'TEST_RELATIONSHIP'; 15 | const direction = 'in'; 16 | const target = new Model(null, 'name', {}); 17 | const schema = { 18 | name: 'string', 19 | }; 20 | const eager = true; 21 | const cascade = 'delete'; 22 | const node_alias = 'alias'; 23 | 24 | const relationship = new RelationshipType(name, type, rel, direction, target, schema, eager, cascade, node_alias); 25 | 26 | expect(relationship.name()).to.equal(name); 27 | expect(relationship.type()).to.equal(type); 28 | expect(relationship.relationship()).to.equal(rel); 29 | expect(relationship.direction()).to.equal(DIRECTION_IN); 30 | expect(relationship.target()).to.equal(target); 31 | expect(relationship.schema()).to.equal(schema); 32 | expect(relationship.eager()).to.equal(eager); 33 | expect(relationship.cascade()).to.equal(cascade); 34 | expect(relationship.nodeAlias()).to.equal(node_alias); 35 | 36 | const props = relationship.properties(); 37 | 38 | expect(props).to.be.an.instanceOf(Map); 39 | expect(props.has('name')).to.equal(true); 40 | expect(props.get('name')).to.be.an.instanceOf(Property); 41 | 42 | expect(props.get('name').type()).to.equal('string'); 43 | 44 | relationship.setDirection('nonesense'); 45 | 46 | expect(relationship.direction()).to.equal(DIRECTION_OUT); 47 | 48 | }); 49 | 50 | }); -------------------------------------------------------------------------------- /src/Collection.js: -------------------------------------------------------------------------------- 1 | export default class Collection { 2 | 3 | /** 4 | * @constructor 5 | * @param {Neode} neode Neode Instance 6 | * @param {Node[]} values Array of Node 7 | * @return {Collection} 8 | */ 9 | constructor(neode, values) { 10 | this._neode = neode; 11 | this._values = values || []; 12 | } 13 | 14 | /** 15 | * Get length property 16 | * 17 | * @return {Int} 18 | */ 19 | get length() { 20 | return this._values.length; 21 | } 22 | 23 | /** 24 | * Iterator 25 | */ 26 | [Symbol.iterator]() { 27 | return this._values.values(); 28 | } 29 | 30 | 31 | /** 32 | * Get a value by it's index 33 | * 34 | * @param {Int} index 35 | * @return {Node} 36 | */ 37 | get(index) { 38 | return this._values[index]; 39 | } 40 | 41 | /** 42 | * Get the first Node in the Collection 43 | * 44 | * @return {Node} 45 | */ 46 | first() { 47 | return this._values[0]; 48 | } 49 | 50 | /** 51 | * Map a function to all values 52 | * 53 | * @param {Function} fn 54 | * @return {mixed} 55 | */ 56 | map(fn) { 57 | return this._values.map(fn); 58 | } 59 | 60 | /** 61 | * Find value in collection 62 | * 63 | * @param {Function} fn 64 | * @return {mixed} 65 | */ 66 | find(fn) { 67 | return this._values.find(fn); 68 | } 69 | 70 | /** 71 | * Run a function on all values 72 | * @param {Function} fn 73 | * @return {mixed} 74 | */ 75 | forEach(fn) { 76 | return this._values.forEach(fn); 77 | } 78 | 79 | /** 80 | * Map the 'toJson' function on all values 81 | * 82 | * @return {Promise} 83 | */ 84 | toJson() { 85 | return Promise.all(this._values.map(value => { 86 | return value.toJson(); 87 | })); 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/Services/FindWithinDistance.js: -------------------------------------------------------------------------------- 1 | import Builder, {mode} from '../Query/Builder'; 2 | import { eagerNode, } from '../Query/EagerUtils'; 3 | 4 | export default function FindWithinDistance(neode, model, location_property, point, distance, properties, order, limit, skip) { 5 | const alias = 'this'; 6 | 7 | const builder = new Builder(neode); 8 | 9 | // Match 10 | builder.match(alias, model); 11 | 12 | // Where 13 | if (properties) { 14 | Object.keys(properties).forEach(key => { 15 | builder.where(`${alias}.${key}`, properties[ key ]); 16 | }); 17 | } 18 | 19 | // Prefix key on Properties 20 | if (properties) { 21 | Object.keys(properties).forEach(key => { 22 | properties[ `${alias}.${key}` ] = properties[ key ]; 23 | 24 | delete properties[ key ]; 25 | }); 26 | } 27 | 28 | // Distance from Point 29 | // TODO: When properties are passed match them as well .where(properties); 30 | let pointString = isNaN(point.x) ? `latitude:${point.latitude}, longitude:${point.longitude}` : `x:${point.x}, y:${point.y}`; 31 | if (!isNaN(point.z)) { 32 | pointString += `, z:${point.z}`; 33 | } 34 | 35 | if (!isNaN(point.height)) { 36 | pointString += `, height:${point.height}`; 37 | } 38 | 39 | builder.whereRaw(`distance (this.${location_property}, point({${pointString}})) <= ${distance}`); 40 | 41 | 42 | // Order 43 | if (typeof order == 'string') { 44 | order = `${alias}.${order}`; 45 | } 46 | else if (typeof order == 'object') { 47 | Object.keys(order).forEach(key => { 48 | builder.orderBy(`${alias}.${key}`, order[ key ]); 49 | }); 50 | } 51 | 52 | // Output 53 | const output = eagerNode(neode, 1, alias, model); 54 | 55 | // Complete Query 56 | return builder.orderBy(order) 57 | .skip(skip) 58 | .limit(limit) 59 | .return(output) 60 | .execute(mode.READ) 61 | .then(res => neode.hydrate(res, alias)); 62 | } -------------------------------------------------------------------------------- /test/Services/UpdateNode.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import FindAll from '../../src/Services/FindAll'; 3 | import Create from '../../src/Services/Create'; 4 | import Node from '../../src/Node'; 5 | 6 | describe('UpdateNode', () => { 7 | let instance; 8 | let model; 9 | const label = 'UpdateTest'; 10 | const schema = { 11 | uuid: { 12 | type: 'uuid', 13 | primary: true, 14 | }, 15 | name: { 16 | type: 'string', 17 | required: true, 18 | }, 19 | age: 'integer', 20 | enabled: { 21 | type: 'boolean', 22 | default: false, 23 | }, 24 | dob: { 25 | type: 'datetime', 26 | default: Date.now, 27 | }, 28 | point: { 29 | type: 'point', 30 | default: { 31 | latitude: 51.506164642, 32 | longitude: -0.124832834, 33 | }, 34 | }, 35 | } 36 | 37 | 38 | before(() => { 39 | instance = require('../instance')(); 40 | model = instance.model(label, schema); 41 | }); 42 | 43 | after(done => { 44 | instance.deleteAll(label) 45 | .then(() => { 46 | return instance.close() 47 | }) 48 | .then(() => done()); 49 | }); 50 | 51 | it('should update a node including null properties', (done) => { 52 | const data = { 53 | name: 'James', 54 | age: 21, 55 | }; 56 | const updates = { name: 'Adam', age: null } 57 | 58 | Create(instance, model, data) 59 | .then(res => { 60 | return res.update(updates) 61 | }) 62 | .then(res => { 63 | // console.log(res) 64 | Object.keys(updates).map(key => { 65 | expect(res.get(key)).to.equal(updates[ key ]) 66 | }) 67 | 68 | return res.toJson() 69 | }) 70 | .then(json => { 71 | Object.keys(updates).map(key => { 72 | expect(json[ key ]).to.equal(updates[ key ]) 73 | }) 74 | 75 | done() 76 | }) 77 | .catch(e => done(e)) 78 | 79 | 80 | }) 81 | 82 | }) -------------------------------------------------------------------------------- /src/Services/RelateTo.js: -------------------------------------------------------------------------------- 1 | import { 2 | DIRECTION_IN, 3 | DIRECTION_OUT 4 | } from '../RelationshipType'; 5 | import Relationship from '../Relationship'; 6 | 7 | import GenerateDefaultValues from './GenerateDefaultValues'; 8 | import Validator from './Validator'; 9 | 10 | export default function RelateTo(neode, from, to, relationship, properties, force_create = false) { 11 | return GenerateDefaultValues(neode, relationship, properties) 12 | .then(properties => Validator(neode, relationship.schema(), properties)) 13 | .then(properties => { 14 | const direction_in = relationship.direction() == DIRECTION_IN ? '<' : ''; 15 | const direction_out = relationship.direction() == DIRECTION_OUT ? '>' : ''; 16 | const type = relationship.relationship(); 17 | 18 | let params = { 19 | from_id: from.identity(), 20 | to_id: to.identity(), 21 | }; 22 | let set = ''; 23 | 24 | if ( Object.keys(properties).length ) { 25 | set += 'SET '; 26 | set += Object.keys(properties).map(key => { 27 | params[`set_${key}`] = properties[ key ]; 28 | return `rel.${key} = $set_${key}`; 29 | }).join(', '); 30 | } 31 | 32 | const mode = force_create ? 'CREATE' : 'MERGE'; 33 | 34 | const query = ` 35 | MATCH (from), (to) 36 | WHERE id(from) = $from_id 37 | AND id(to) = $to_id 38 | ${mode} (from)${direction_in}-[rel:${type}]-${direction_out}(to) 39 | ${set} 40 | RETURN rel 41 | `; 42 | 43 | return neode.writeCypher(query, params) 44 | .then(res => { 45 | const rel = res.records[0].get('rel'); 46 | const hydrate_from = relationship.direction() == DIRECTION_IN ? to : from; 47 | const hydrate_to = relationship.direction() == DIRECTION_IN ? from : to; 48 | 49 | const properties = new Map; 50 | 51 | Object.keys(rel.properties).forEach(key => { 52 | properties.set( key, rel.properties[ key ] ); 53 | }); 54 | 55 | return new Relationship(neode, relationship, rel.identity, rel.type, properties, hydrate_from, hydrate_to); 56 | }); 57 | }); 58 | } -------------------------------------------------------------------------------- /test/Services/GenerateDefaultValues.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import GenerateDefaultValues from '../../src/Services/GenerateDefaultValues'; 3 | 4 | describe('Services/GenerateDefaultValues.js', () => { 5 | let instance; 6 | let model; 7 | 8 | const label = 'GenerateDefaultValues'; 9 | const schema = { 10 | uuid: 'uuid', 11 | someNumber: 'integer', 12 | 13 | defaultFunction: { 14 | type: 'string', 15 | default: () => '_DEFAULT_', 16 | }, 17 | 18 | defaultValue: { 19 | type: 'number', 20 | default: 100, 21 | }, 22 | }; 23 | 24 | before(() => { 25 | instance = require('../instance')(); 26 | model = instance.model(label, schema); 27 | }); 28 | 29 | after(() => { 30 | instance.close(); 31 | }); 32 | 33 | describe('::GenerateDefaultValues', () => { 34 | it('should throw an error when something other than an object is passed', done => { 35 | try { 36 | GenerateDefaultValues(instance, model, null); 37 | 38 | done(new Error('Error not thrown')); 39 | } 40 | catch(e) { 41 | done(); 42 | } 43 | }) 44 | 45 | it('should not treat 0 as a null value', done => { 46 | const input = { someNumber: 0 }; 47 | 48 | GenerateDefaultValues(instance, model, input) 49 | .then(output => { 50 | expect(input.someNumber).to.deep.equal(output.someNumber); 51 | expect(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/.test(output.uuid)).to.equal(true); 52 | 53 | done(); 54 | }) 55 | .catch(e => done(e)); 56 | }); 57 | 58 | it('should generate a default based on the function', done => { 59 | GenerateDefaultValues(instance, model, {}) 60 | .then(output => { 61 | expect(output.defaultValue).to.deep.equal(schema.defaultValue.default); 62 | 63 | done(); 64 | }) 65 | .catch(e => done(e)); 66 | }); 67 | 68 | it('should generate a default based on a literal value', done => { 69 | GenerateDefaultValues(instance, model, {}) 70 | .then(output => { 71 | expect(output.defaultFunction).to.deep.equal(schema.defaultFunction.default()); 72 | 73 | done(); 74 | }) 75 | .catch(e => done(e)); 76 | }); 77 | }); 78 | 79 | }); 80 | -------------------------------------------------------------------------------- /test/Services/DeleteNode.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import {expect} from 'chai'; 3 | 4 | const TIMEOUT = 10000; 5 | 6 | describe('Services/DeleteNode.js', () => { 7 | let instance; 8 | 9 | const label = 'DeleteTest'; 10 | const schema = { 11 | uuid: { 12 | type: 'uuid', 13 | primary: true, 14 | }, 15 | name: { 16 | type: 'string', 17 | required: true, 18 | }, 19 | toDelete: { 20 | type: 'node', 21 | relationship: 'DELETE_ME', 22 | target: label, 23 | direction: 'out', 24 | eager: true, 25 | cascade: 'delete', 26 | }, 27 | toDetach: { 28 | type: 'node', 29 | relationship: 'DETACH_ME', 30 | target: label, 31 | direction: 'out', 32 | eager: true, 33 | // cascade: 'detach', 34 | } 35 | }; 36 | 37 | before(done => { 38 | instance = require('../instance')(); 39 | 40 | instance.model(label, schema); 41 | 42 | instance.deleteAll(label).then(() => done()); 43 | }); 44 | 45 | after(done => { 46 | instance.deleteAll(label) 47 | .then(() => { 48 | return instance.close(); 49 | }) 50 | .then(() => done()); 51 | }); 52 | 53 | describe('::DeleteNode', () => { 54 | it('should cascade delete a node to specified depth', done => { 55 | instance.create(label, { 56 | name: 'level1', 57 | toDelete: { 58 | name: 'level 2', 59 | toDelete: { 60 | name: 'level 3', 61 | toDelete: { 62 | name: 'level 4', 63 | } 64 | } 65 | }, 66 | toDetach: { 67 | name: 'Detach' 68 | } 69 | }) 70 | .then(res => res.delete(2)) 71 | .then(() => { 72 | return instance.cypher(`MATCH (n:${label}) RETURN n.name AS name ORDER BY name ASC`) 73 | .then(( {records} ) => { 74 | expect( records.length ).to.equal(2); 75 | 76 | const actual = records.map(r => r.get('name')); 77 | const expected = [ 'Detach', 'level 4' ]; 78 | 79 | expect( actual ).to.deep.equal( expected ); 80 | }); 81 | }) 82 | .then(() => done()) 83 | .catch(e => done(e)); 84 | }).timeout(TIMEOUT); 85 | }); 86 | 87 | }); 88 | -------------------------------------------------------------------------------- /src/ModelMap.js: -------------------------------------------------------------------------------- 1 | import Model from './Model'; 2 | 3 | export default class ModelMap { 4 | 5 | /** 6 | * @constuctor 7 | * 8 | * @param {Neode} neode 9 | */ 10 | constructor(neode) { 11 | this._neode = neode; 12 | this.models = new Map(); 13 | } 14 | 15 | /** 16 | * Check if a model has been defined 17 | * 18 | * @param {String} key 19 | * @return {bool} 20 | */ 21 | has(key) { 22 | return this.models.has(key); 23 | } 24 | 25 | /** 26 | * Namesof the models defined. 27 | * 28 | * @return {Array} 29 | */ 30 | keys() { 31 | return [... this.models.keys() ]; 32 | } 33 | 34 | /** 35 | * Getter 36 | * 37 | * @param {String} 38 | * @return {Model|false} 39 | */ 40 | get(key) { 41 | return this.models.get(key); 42 | } 43 | 44 | /** 45 | * Setter 46 | * 47 | * @param {String} key 48 | * @param {Model} value 49 | * @return {ModelMap} 50 | */ 51 | set(key, value) { 52 | this.models.set(key, value); 53 | 54 | return this; 55 | } 56 | 57 | /** 58 | * Run a forEach function on the models 59 | * 60 | * @param {Function} 61 | * @return {void} 62 | */ 63 | forEach(fn) { 64 | return this.models.forEach(fn); 65 | } 66 | 67 | /** 68 | * Get the definition for an array labels 69 | * 70 | * @param {Array} labels 71 | * @return {Definition} 72 | */ 73 | getByLabels(labels) { 74 | if ( !Array.isArray(labels) ) { 75 | labels = [ labels ]; 76 | } 77 | 78 | for (let entry of this.models) { 79 | const [ name, definition ] = entry; // eslint-disable-line no-unused-vars 80 | 81 | if ( definition.labels().sort().join(':') == labels.sort().join(':') ) { 82 | return definition; 83 | } 84 | } 85 | 86 | return false; 87 | } 88 | 89 | /** 90 | * Extend a model with extra configuration 91 | * 92 | * @param {String} name Original Model to clone 93 | * @param {String} as New Model name 94 | * @param {Object} using Schema changes 95 | * @return {Model} 96 | */ 97 | extend(name, as, using) { 98 | // Get Original Model 99 | const original = this.models.get(name); 100 | 101 | // Add new Labels 102 | const labels = original.labels().slice(0); 103 | labels.push(as); 104 | labels.sort(); 105 | 106 | // Merge Schema 107 | const schema = Object.assign({}, original.schema(), using); 108 | 109 | // Create and set 110 | const model = new Model(this._neode, as, schema); 111 | 112 | model.setLabels(...labels); 113 | 114 | this.models.set(as, model); 115 | 116 | return model; 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /src/Schema.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function UniqueConstraintCypher(label, property, mode = 'CREATE') { 4 | return `${mode} CONSTRAINT ON (model:${label}) ASSERT model.${property} IS UNIQUE`; 5 | } 6 | 7 | function ExistsConstraintCypher(label, property, mode = 'CREATE') { 8 | return `${mode} CONSTRAINT ON (model:${label}) ASSERT EXISTS(model.${property})`; 9 | } 10 | 11 | function IndexCypher(label, property, mode = 'CREATE') { 12 | return `${mode} INDEX ON :${label}(${property})`; 13 | } 14 | 15 | function runAsync(session, queries, resolve, reject) { 16 | const next = queries.pop(); 17 | 18 | return session.run(next) 19 | .then(() => { 20 | // If there is another query, let's run it 21 | if (queries.length) { 22 | return runAsync(session, queries, resolve, reject); 23 | } 24 | 25 | // Close Session and resolve 26 | session.close(); 27 | resolve(); 28 | }) 29 | .catch(e => { 30 | reject(e); 31 | }); 32 | } 33 | 34 | function InstallSchema(neode) { 35 | const queries = []; 36 | 37 | neode.models.forEach((model, label) => { 38 | model.properties().forEach(property => { 39 | // Constraints 40 | if (property.primary() || property.unique()) { 41 | queries.push(UniqueConstraintCypher(label, property.name())); 42 | } 43 | 44 | if (neode.enterprise() && property.required()) { 45 | queries.push(ExistsConstraintCypher(label, property.name())); 46 | } 47 | 48 | // Indexes 49 | if (property.indexed()) { 50 | queries.push(IndexCypher(label, property.name())); 51 | } 52 | }); 53 | }); 54 | 55 | return neode.batch(queries); 56 | } 57 | 58 | function DropSchema(neode) { 59 | const queries = []; 60 | 61 | neode.models.forEach((model, label) => { 62 | model.properties().forEach(property => { 63 | // Constraints 64 | if (property.unique()) { 65 | queries.push(UniqueConstraintCypher(label, property.name(), 'DROP')); 66 | } 67 | 68 | if (neode.enterprise() && property.required()) { 69 | queries.push(ExistsConstraintCypher(label, property.name(), 'DROP')); 70 | } 71 | 72 | // Indexes 73 | if (property.indexed()) { 74 | queries.push(IndexCypher(label, property.name(), 'DROP')); 75 | } 76 | }); 77 | }); 78 | 79 | const session = neode.writeSession(); 80 | 81 | return new Promise((resolve, reject) => { 82 | runAsync(session, queries, resolve, reject); 83 | }); 84 | } 85 | 86 | export default class Schema { 87 | 88 | constructor(neode) { 89 | this.neode = neode; 90 | } 91 | 92 | install() { 93 | return InstallSchema(this.neode); 94 | } 95 | 96 | drop() { 97 | return DropSchema(this.neode); 98 | } 99 | 100 | } -------------------------------------------------------------------------------- /test/Collection.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import Node from '../src/Node'; 3 | import Collection from '../src/Collection'; 4 | 5 | describe('Collection.js', () => { 6 | const neode = '__neode__'; 7 | const values = [1, 2, 3, 4]; 8 | 9 | const collection = new Collection(neode, values); 10 | 11 | describe('::constructor', () => { 12 | it('should construct', () => { 13 | expect(collection._neode).to.equal(neode); 14 | expect(collection._values).to.equal(values); 15 | }); 16 | 17 | it('should construct with an empty array', () => { 18 | const collection = new Collection(neode); 19 | expect(collection._neode).to.equal(neode); 20 | expect(collection._values).to.deep.equal([]); 21 | }); 22 | }); 23 | 24 | describe('::length', () => { 25 | it('should return the length', () => { 26 | expect(collection.length).to.equal(values.length); 27 | }); 28 | }); 29 | 30 | describe('::get', () => { 31 | it('should get an item from the internal values', () => { 32 | values.forEach((value, index) => { 33 | expect( collection.get(index) ).to.equal(value); 34 | }) 35 | }); 36 | }); 37 | 38 | describe('::[Symbol.iterator]', () => { 39 | it('should be iterable', () => { 40 | const output = []; 41 | 42 | for ( let value of values ) { 43 | output.push(value); 44 | } 45 | 46 | expect( output ).to.deep.equal( values ); 47 | }); 48 | }); 49 | 50 | describe('::first', () => { 51 | it('should get the first item in the collection', () => { 52 | expect(collection.first()).to.equal(values[0]); 53 | }); 54 | }); 55 | 56 | describe('::map', () => { 57 | it('should apply a map function to the values', () => { 58 | const output = collection.map(value => value * value); 59 | 60 | expect(output).to.deep.equal([1, 4, 9, 16]); 61 | }); 62 | }); 63 | 64 | describe('::forEach', () => { 65 | it('should apply a foreach function to the values', () => { 66 | let total = 0; 67 | 68 | collection.forEach(value => total += value); 69 | 70 | expect(total).to.equal(10); 71 | }); 72 | }); 73 | 74 | describe('::toJson', () => { 75 | class TestItem { 76 | constructor(value) { 77 | this.value = value; 78 | } 79 | 80 | toJson() { 81 | return this.value; 82 | } 83 | } 84 | 85 | const jsonTest = new Collection(null, [ 86 | new TestItem(1), 87 | new TestItem(2), 88 | new TestItem(3), 89 | new TestItem(4), 90 | ]); 91 | 92 | it('should run the toJson() function to all values', done => { 93 | jsonTest.toJson() 94 | .then(res => { 95 | expect(res).to.deep.equal([1, 2, 3, 4]); 96 | }) 97 | .then(() => done()) 98 | .catch(e => done(e)); 99 | }); 100 | }); 101 | 102 | 103 | }); -------------------------------------------------------------------------------- /src/Services/CleanValue.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import neo4j from 'neo4j-driver'; 3 | 4 | const temporal = [ 5 | 'date', 6 | 'datetime', 7 | 'time', 8 | 'localdatetime', 9 | 'localtime' 10 | ]; 11 | 12 | /** 13 | * Convert a value to it's native type 14 | * 15 | * @param {Object} config Field Configuration 16 | * @param {mixed} value Value to be converted 17 | * @return {mixed} 18 | */ 19 | export default function CleanValue(config, value) { 20 | // Convert temporal to a native date? 21 | if ( temporal.indexOf( config.type.toLowerCase() ) > -1 && (typeof value == 'number' || typeof value == 'string') ) { 22 | value = new Date(value) 23 | } 24 | 25 | // Clean Values 26 | switch (config.type.toLowerCase()) { 27 | case 'float': 28 | value = parseFloat(value); 29 | break; 30 | 31 | case 'int': 32 | case 'integer': 33 | value = neo4j.int( parseInt(value) ); 34 | break; 35 | 36 | case 'bool': 37 | case 'boolean': 38 | value = !! value; 39 | break; 40 | 41 | case 'timestamp': 42 | value = value instanceof Date ? value.getTime() : value; 43 | break; 44 | 45 | case 'date': 46 | value = value instanceof Date ? neo4j.types.Date.fromStandardDate(value) : value; 47 | break; 48 | 49 | case 'datetime': 50 | value = value instanceof Date ? neo4j.types.DateTime.fromStandardDate(value) : value; 51 | break; 52 | 53 | case 'localdatetime': 54 | value = value instanceof Date ? neo4j.types.LocalDateTime.fromStandardDate(value) : value; 55 | break; 56 | 57 | case 'time': 58 | value = value instanceof Date ? neo4j.types.Time.fromStandardDate(value) : value; 59 | break; 60 | 61 | case 'localtime': 62 | value = value instanceof Date ? neo4j.types.LocalTime.fromStandardDate(value) : value; 63 | break; 64 | 65 | case 'point': 66 | // SRID values: @https://neo4j.com/docs/developer-manual/current/cypher/functions/spatial/ 67 | if (isNaN(value.x)) { // WGS 84 68 | if (isNaN(value.height)) { 69 | value = new neo4j.types.Point( 70 | 4326, // WGS 84 2D 71 | value.longitude, 72 | value.latitude 73 | ); 74 | } 75 | else { 76 | value = new neo4j.types.Point( 77 | 4979, // WGS 84 3D 78 | value.longitude, 79 | value.latitude, 80 | value.height 81 | ); 82 | } 83 | } 84 | else { 85 | if (isNaN(value.z)) { 86 | value = new neo4j.types.Point( 87 | 7203, // Cartesian 2D 88 | value.x, 89 | value.y 90 | ); 91 | } 92 | else { 93 | value = new neo4j.types.Point( 94 | 9157, // Cartesian 3D 95 | value.x, 96 | value.y, 97 | value.z 98 | ); 99 | } 100 | } 101 | break; 102 | } 103 | 104 | return value; 105 | } -------------------------------------------------------------------------------- /test/Entity.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import Entity, { valueToJson } from '../src/Entity'; 3 | import neo4j from 'neo4j-driver'; 4 | 5 | describe('Entity.js', () => { 6 | 7 | describe('::constructor', () => { 8 | // TODO: More comprehensive entity tests 9 | }); 10 | 11 | describe('::valueToJson', () => { 12 | it('should convert an integer', () => { 13 | const input = new neo4j.int(1); 14 | const expected = 1; 15 | 16 | expect( valueToJson(null, input) ).to.equal(expected); 17 | }); 18 | 19 | it('should convert a datetime', () => { 20 | const input = new neo4j.types.DateTime(2012, 2, 3, 12, 45, 56, 123400000, 3600); 21 | const expected = '2012-02-03T12:45:56.123400000+01:00'; 22 | 23 | expect( valueToJson(null, input) ).to.equal(expected); 24 | }); 25 | 26 | it('should convert a date', () => { 27 | const input = new neo4j.types.Date(2012, 2, 3); 28 | const expected = '2012-02-03'; 29 | 30 | expect( valueToJson(null, input) ).to.equal(expected); 31 | }); 32 | 33 | it('should convert a time', () => { 34 | const input = new neo4j.types.Time(12, 34, 56, 123400000, 3600); 35 | const expected = '12:34:56.123400000+01:00'; 36 | 37 | expect( valueToJson(null, input) ).to.equal(expected); 38 | }); 39 | 40 | it('should convert a localdatetime', () => { 41 | const input = new neo4j.types.LocalDateTime(2012, 2, 3, 12, 45, 56, 123400000); 42 | const expected = '2012-02-03T12:45:56.123400000'; 43 | 44 | expect( valueToJson(null, input) ).to.equal(expected); 45 | }); 46 | 47 | it('should convert a localtime', () => { 48 | const input = new neo4j.types.LocalTime(12, 34, 56, 123400000); 49 | const expected = '12:34:56.123400000'; 50 | 51 | expect( valueToJson(null, input) ).to.equal(expected); 52 | }); 53 | 54 | it('should convert a duration', () => { 55 | const input = new neo4j.types.Duration(1, 2, 3, 123400000); 56 | const expected = 'P1M2DT3.123400000S'; 57 | 58 | expect( valueToJson(null, input) ).to.equal(expected); 59 | }); 60 | 61 | describe('Point', () => { 62 | it('should convert WGS 84 2D to Object', () => { 63 | const input = new neo4j.types.Point(4326, 1, 2); 64 | const expected = { latitude: 2, longitude: 1 }; 65 | 66 | expect( valueToJson(null, input) ).to.deep.equal(expected); 67 | }); 68 | 69 | it('should convert WGS 84 3D to Object', () => { 70 | const input = new neo4j.types.Point(4979, 1, 2, 3); 71 | const expected = { latitude: 2, longitude: 1, height: 3}; 72 | 73 | expect( valueToJson(null, input) ).to.deep.equal(expected); 74 | }); 75 | 76 | it('should convert Cartesian 2D to Object', () => { 77 | const input = new neo4j.types.Point(7203, 1, 2); 78 | const expected = { x: 1, y: 2 }; 79 | 80 | expect( valueToJson(null, input) ).to.deep.equal(expected); 81 | }); 82 | 83 | it('should convert Cartesian 3D to Object', () => { 84 | const input = new neo4j.types.Point(9157, 1, 2, 3); 85 | const expected = { x: 1, y: 2, z: 3}; 86 | 87 | expect( valueToJson(null, input) ).to.deep.equal(expected); 88 | }); 89 | }); 90 | 91 | }); 92 | 93 | 94 | 95 | }); -------------------------------------------------------------------------------- /src/Entity.js: -------------------------------------------------------------------------------- 1 | /* eslint indent: 0 */ 2 | import neo4j from 'neo4j-driver'; 3 | 4 | /** 5 | * Convert a raw property into a JSON friendly format 6 | * 7 | * @param {Property} property 8 | * @param {Mixed} value 9 | * @return {Mixed} 10 | */ 11 | export function valueToJson(property, value) { 12 | if ( neo4j.isInt(value) ) { 13 | return value.toNumber(); 14 | } 15 | else if ( 16 | neo4j.temporal.isDate(value) 17 | || neo4j.temporal.isDateTime(value) 18 | || neo4j.temporal.isTime(value) 19 | || neo4j.temporal.isLocalDateTime(value) 20 | || neo4j.temporal.isLocalTime(value) 21 | || neo4j.temporal.isDuration(value) 22 | ) { 23 | return value.toString(); 24 | } 25 | else if ( neo4j.spatial.isPoint(value) ) { 26 | switch (value.srid.toString()) { 27 | // SRID values: @https://neo4j.com/docs/developer-manual/current/cypher/functions/spatial/ 28 | case '4326': // WGS 84 2D 29 | return { longitude: value.x, latitude: value.y }; 30 | 31 | case '4979': // WGS 84 3D 32 | return { longitude: value.x, latitude: value.y, height: value.z }; 33 | 34 | case '7203': // Cartesian 2D 35 | return { x: value.x, y: value.y}; 36 | 37 | case '9157': // Cartesian 3D 38 | return { x: value.x, y: value.y, z: value.z }; 39 | } 40 | } 41 | 42 | return value; 43 | } 44 | 45 | /** 46 | * Convert a property into a cypher value 47 | * 48 | * @param {Property} property 49 | * @param {Mixed} value 50 | * @return {Mixed} 51 | */ 52 | export function valueToCypher(property, value) { 53 | if ( property.convertToInteger() && value !== null && value !== undefined ) { 54 | value = neo4j.int(value); 55 | } 56 | 57 | return value; 58 | } 59 | 60 | export default class Entity { 61 | 62 | /** 63 | * Get Internal Node ID 64 | * 65 | * @return {int} 66 | */ 67 | id() { 68 | return this._identity.toNumber(); 69 | } 70 | 71 | /** 72 | * Return internal ID as a Neo4j Integer 73 | * 74 | * @return {Integer} 75 | */ 76 | identity() { 77 | return this._identity; 78 | } 79 | 80 | /** 81 | * Return the Node's properties as an Object 82 | * 83 | * @return {Object} 84 | */ 85 | properties() { 86 | const output = {}; 87 | 88 | const model = this._model || this._definition; 89 | 90 | model.properties().forEach((property, key) => { 91 | if ( !property.hidden() && this._properties.has(key) ) { 92 | output[ key ] = this.valueToJson(property, this._properties.get( key )); 93 | } 94 | }); 95 | 96 | return output; 97 | } 98 | 99 | /** 100 | * Get a property for this node 101 | * 102 | * @param {String} property Name of property 103 | * @param {or} default Default value to supply if none exists 104 | * @return {mixed} 105 | */ 106 | get(property, or = null) { 107 | // If property is set, return that 108 | if ( this._properties.has(property) ) { 109 | return this._properties.get(property); 110 | } 111 | // If property has been set in eager, return that 112 | else if ( this._eager && this._eager.has(property) ) { 113 | return this._eager.get(property); 114 | } 115 | 116 | return or; 117 | } 118 | 119 | /** 120 | * Convert a raw property into a JSON friendly format 121 | * 122 | * @param {Property} property 123 | * @param {Mixed} value 124 | * @return {Mixed} 125 | */ 126 | valueToJson(property, value) { 127 | return valueToJson(property, value); 128 | } 129 | } -------------------------------------------------------------------------------- /src/Services/DeleteNode.js: -------------------------------------------------------------------------------- 1 | import Builder, {mode} from '../Query/Builder'; 2 | 3 | export const MAX_EAGER_DEPTH = 10; 4 | 5 | /** 6 | * Add a recursive cascade deletion 7 | * 8 | * @param {Neode} neode Neode instance 9 | * @param {Builder} builder Query Builder 10 | * @param {String} alias Alias of node 11 | * @param {RelationshipType} relationship relationship type definition 12 | * @param {Array} aliases Current aliases 13 | * @param {Integer} to_depth Maximum depth to delete to 14 | */ 15 | function addCascadeDeleteNode(neode, builder, from_alias, relationship, aliases, to_depth) { 16 | if ( aliases.length > to_depth ) return; 17 | 18 | const rel_alias = from_alias + relationship.name() + '_rel'; 19 | const node_alias = from_alias + relationship.name() + '_node'; 20 | const target = neode.model( relationship.target() ); 21 | 22 | // Optional Match 23 | builder.optionalMatch(from_alias) 24 | .relationship(relationship.relationship(), relationship.direction(), rel_alias) 25 | .to(node_alias, relationship.target()); 26 | 27 | // Check for cascade deletions 28 | target.relationships().forEach(relationship => { 29 | switch ( relationship.cascade() ) { 30 | case 'delete': 31 | addCascadeDeleteNode(neode, builder, node_alias, relationship, aliases.concat(node_alias), to_depth); 32 | break; 33 | 34 | // case 'detach': 35 | // addDetachNode(neode, builder, node_alias, relationship, aliases); 36 | // break; 37 | } 38 | }); 39 | 40 | // Delete it 41 | builder.detachDelete(node_alias); 42 | } 43 | 44 | /** 45 | * Delete the relationship to the other node 46 | * 47 | * @param {Neode} neode Neode instance 48 | * @param {Builder} builder Query Builder 49 | * @param {String} from_alias Alias of node at start of the match 50 | * @param {RelationshipType} relationship model definition 51 | * @param {Array} aliases Current aliases 52 | * / 53 | function addDetachNode(neode, builder, from_alias, relationship, aliases) { 54 | // builder.withDistinct(aliases); 55 | 56 | const rel_alias = from_alias + relationship.name() + '_rel'; 57 | 58 | builder.optionalMatch(from_alias) 59 | .relationship(relationship.relationship(), relationship.direction(), rel_alias) 60 | .toAnything() 61 | .delete(rel_alias); 62 | 63 | // builder.withDistinct( aliases ); 64 | } 65 | */ 66 | 67 | /** 68 | * Cascade Delete a Node 69 | * 70 | * @param {Neode} neode Neode instance 71 | * @param {Integer} identity Neo4j internal ID of node to delete 72 | * @param {Model} model Model definition 73 | * @param {Integer} to_depth Maximum deletion depth 74 | */ 75 | export default function DeleteNode(neode, identity, model, to_depth = MAX_EAGER_DEPTH) { 76 | const alias = 'this'; 77 | // const to_delete = []; 78 | const aliases = [alias]; 79 | // const depth = 1; 80 | 81 | const builder = (new Builder(neode)) 82 | .match(alias, model) 83 | .whereId(alias, identity); 84 | 85 | // Cascade delete to relationships 86 | model.relationships().forEach(relationship => { 87 | switch ( relationship.cascade() ) { 88 | case 'delete': 89 | addCascadeDeleteNode(neode, builder, alias, relationship, aliases, to_depth); 90 | break; 91 | 92 | // case 'detach': 93 | // addDetachNode(neode, builder, alias, relationship, aliases); 94 | // break; 95 | } 96 | }); 97 | 98 | // Detach Delete target node 99 | builder.detachDelete(alias); 100 | 101 | return builder.execute(mode.WRITE); 102 | } -------------------------------------------------------------------------------- /src/Queryable.js: -------------------------------------------------------------------------------- 1 | import Builder from './Query/Builder'; 2 | import Create from './Services/Create'; 3 | import DeleteAll from './Services/DeleteAll'; 4 | import FindAll from './Services/FindAll'; 5 | import FindById from './Services/FindById'; 6 | import FindWithinDistance from './Services/FindWithinDistance'; 7 | import First from './Services/First'; 8 | import MergeOn from './Services/MergeOn'; 9 | 10 | export default class Queryable { 11 | 12 | /** 13 | * @constructor 14 | * 15 | * @param Neode neode 16 | */ 17 | constructor(neode) { 18 | this._neode = neode; 19 | } 20 | 21 | /** 22 | * Return a new Query Builder 23 | * 24 | * @return {Builder} 25 | */ 26 | query() { 27 | return new Builder(this._neode); 28 | } 29 | 30 | /** 31 | * Create a new instance of this Model 32 | * 33 | * @param {object} properties 34 | * @return {Promise} 35 | */ 36 | create(properties) { 37 | return Create(this._neode, this, properties); 38 | } 39 | 40 | /** 41 | * Merge a node based on the defined indexes 42 | * 43 | * @param {Object} properties 44 | * @return {Promise} 45 | */ 46 | merge(properties) { 47 | const merge_on = this.mergeFields(); 48 | 49 | return MergeOn(this._neode, this, merge_on, properties); 50 | } 51 | 52 | /** 53 | * Merge a node based on the supplied properties 54 | * 55 | * @param {Object} match Specific properties to merge on 56 | * @param {Object} set Properties to set 57 | * @return {Promise} 58 | */ 59 | mergeOn(match, set) { 60 | const merge_on = Object.keys(match); 61 | const properties = Object.assign({}, match, set); 62 | 63 | return MergeOn(this._neode, this, merge_on, properties); 64 | } 65 | 66 | /** 67 | * Delete all nodes for this model 68 | * 69 | * @return {Promise} 70 | */ 71 | deleteAll() { 72 | return DeleteAll(this._neode, this); 73 | } 74 | 75 | /** 76 | * Get a collection of nodes for this label 77 | * 78 | * @param {Object} properties 79 | * @param {String|Array|Object} order 80 | * @param {Int} limit 81 | * @param {Int} skip 82 | * @return {Promise} 83 | */ 84 | all(properties, order, limit, skip) { 85 | return FindAll(this._neode, this, properties, order, limit, skip); 86 | } 87 | 88 | /** 89 | * Find a Node by its Primary Key 90 | * 91 | * @param {mixed} id 92 | * @return {Promise} 93 | */ 94 | find(id) { 95 | const primary_key = this.primaryKey(); 96 | 97 | return this.first(primary_key, id); 98 | } 99 | 100 | /** 101 | * Find a Node by it's internal node ID 102 | * 103 | * @param {String} model 104 | * @param {int} id 105 | * @return {Promise} 106 | */ 107 | findById(id) { 108 | return FindById(this._neode, this, id); 109 | } 110 | 111 | /** 112 | * Find a Node by properties 113 | * 114 | * @param {String} label 115 | * @param {mixed} key Either a string for the property name or an object of values 116 | * @param {mixed} value Value 117 | * @return {Promise} 118 | */ 119 | first(key, value) { 120 | return First(this._neode, this, key, value); 121 | } 122 | 123 | /** 124 | * Get a collection of nodes within a certain distance belonging to this label 125 | * 126 | * @param {Object} properties 127 | * @param {String} location_property 128 | * @param {Object} point 129 | * @param {Int} distance 130 | * @param {String|Array|Object} order 131 | * @param {Int} limit 132 | * @param {Int} skip 133 | * @return {Promise} 134 | */ 135 | withinDistance(location_property, point, distance, properties, order, limit, skip) { 136 | return FindWithinDistance(this._neode, this, location_property, point, distance, properties, order, limit, skip); 137 | } 138 | 139 | } -------------------------------------------------------------------------------- /src/Query/EagerUtils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty */ 2 | import Builder from './Builder'; 3 | 4 | export const EAGER_ID = '__EAGER_ID__'; 5 | export const EAGER_LABELS = '__EAGER_LABELS__'; 6 | export const EAGER_TYPE = '__EAGER_TYPE__'; 7 | export const MAX_EAGER_DEPTH = 3; 8 | 9 | 10 | /** 11 | * Build a pattern to use in an eager load statement 12 | * 13 | * @param {Neode} neode Neode instance 14 | * @param {Integer} depth Maximum depth to stop at 15 | * @param {String} alias Alias for the starting node 16 | * @param {RelationshipType} rel Type of relationship 17 | */ 18 | export function eagerPattern(neode, depth, alias, rel) { 19 | const builder = new Builder(); 20 | 21 | const name = rel.name(); 22 | const type = rel.type(); 23 | const relationship = rel.relationship(); 24 | const direction = rel.direction(); 25 | const target = rel.target(); 26 | const relationship_variable = `${alias}_${name}_rel`; 27 | const node_variable = `${alias}_${name}_node`; 28 | 29 | let target_model = undefined; 30 | try { 31 | target_model = neode.model( target ); 32 | } 33 | catch(e) {} 34 | 35 | // Build Pattern 36 | builder.match(alias) 37 | .relationship(relationship, direction, relationship_variable) 38 | .to(node_variable, target_model); 39 | 40 | 41 | let fields = node_variable; 42 | 43 | switch ( type ) { 44 | case 'node': 45 | case 'nodes': 46 | fields = eagerNode(neode, depth +1, node_variable, target_model); 47 | break; 48 | 49 | case 'relationship': 50 | case 'relationships': 51 | fields = eagerRelationship(neode, depth + 1, relationship_variable, rel.nodeAlias(), node_variable, target_model); 52 | 53 | } 54 | 55 | const pattern = `${name}: [ ${builder.pattern().trim()} | ${fields} ]`; 56 | 57 | 58 | // Get the first? 59 | if ( type === 'node' || type === 'relationship' ) { 60 | return pattern + '[0]'; 61 | } 62 | 63 | return pattern; 64 | 65 | } 66 | 67 | /** 68 | * Produces a Cypher pattern for a consistant eager loading format for a 69 | * Node and any subsequent eagerly loaded models up to the maximum depth. 70 | * 71 | * @param {Neode} neode Neode instance 72 | * @param {Integer} depth Maximum depth to traverse to 73 | * @param {String} alias Alias of the node 74 | * @param {Model} model Node model 75 | */ 76 | export function eagerNode(neode, depth, alias, model) { 77 | const indent = ` `.repeat( depth * 2 ); 78 | let pattern = `\n${indent} ${alias} { `; 79 | 80 | // Properties 81 | pattern += `\n${indent}${indent}.*`; 82 | 83 | // ID 84 | pattern += `\n${indent}${indent},${EAGER_ID}: id(${alias})`; 85 | 86 | // Labels 87 | pattern += `\n${indent}${indent},${EAGER_LABELS}: labels(${alias})`; 88 | 89 | // Eager 90 | if ( model && depth <= MAX_EAGER_DEPTH ) { 91 | model.eager().forEach(rel => { 92 | pattern += `\n${indent}${indent},` + eagerPattern(neode, depth, alias, rel); 93 | }); 94 | } 95 | 96 | pattern += `\n${indent}}`; 97 | 98 | return pattern; 99 | } 100 | 101 | /** 102 | * Produces a Cypher pattern for a consistant eager loading format for a 103 | * Relationship and any subsequent eagerly loaded modules up to the maximum depth. 104 | * 105 | * @param {Neode} neode Neode instance 106 | * @param {Integer} depth Maximum depth to traverse to 107 | * @param {String} alias Alias of the node 108 | * @param {Model} model Node model 109 | */ 110 | export function eagerRelationship(neode, depth, alias, node_alias, node_variable, node_model) { 111 | const indent = ` `.repeat( depth * 2 ); 112 | let pattern = `\n${indent} ${alias} { `; 113 | 114 | // Properties 115 | pattern += `\n${indent}${indent}.*`; 116 | 117 | // ID 118 | pattern += `\n${indent}${indent},${EAGER_ID}: id(${alias})`; 119 | 120 | // Type 121 | pattern += `\n${indent}${indent},${EAGER_TYPE}: type(${alias})`; 122 | 123 | // Node Alias 124 | // pattern += `\n,${indent}${indent},${node_alias}` 125 | pattern +=`\n${indent}${indent},${node_alias}: `; 126 | pattern += eagerNode(neode, depth+1, node_variable, node_model); 127 | 128 | pattern += `\n${indent}}`; 129 | 130 | return pattern; 131 | } -------------------------------------------------------------------------------- /src/RelationshipType.js: -------------------------------------------------------------------------------- 1 | import Property from './Property'; 2 | 3 | export const DIRECTION_IN = 'DIRECTION_IN'; 4 | export const DIRECTION_OUT = 'DIRECTION_OUT'; 5 | export const DIRECTION_BOTH = 'DIRECTION_BOTH'; 6 | 7 | export const ALT_DIRECTION_IN = 'IN'; 8 | export const ALT_DIRECTION_OUT = 'OUT'; 9 | 10 | export const DEFAULT_ALIAS = 'node'; 11 | 12 | export default class RelationshipType { 13 | 14 | /** 15 | * Constructor 16 | * @param {String} name The name given to the relationship 17 | * @param {String} type Type of Relationship (relationship, relationships, node, nodes) 18 | * @param {String} relationship Internal Neo4j Relationship type (ie 'KNOWS') 19 | * @param {String} direction Direction of Node (Use constants DIRECTION_IN, DIRECTION_OUT, DIRECTION_BOTH) 20 | * @param {String|Model|null} target Target type definition for the Relationship 21 | * @param {Object} schema Relationship definition schema 22 | * @param {Bool} eager Should this relationship be eager loaded? 23 | * @param {Bool|String} cascade Cascade delete policy for this relationship 24 | * @param {String} node_alias Alias to give to the node in the pattern comprehension 25 | * @return {Relationship} 26 | */ 27 | constructor(name, type, relationship, direction, target, schema = {}, eager = false, cascade = false, node_alias = DEFAULT_ALIAS) { 28 | this._name = name; 29 | this._type = type; 30 | this._relationship = relationship; 31 | this.setDirection(direction); 32 | 33 | this._target = target; 34 | this._schema = schema; 35 | 36 | this._eager = eager; 37 | this._cascade = cascade; 38 | this._node_alias = node_alias; 39 | 40 | this._properties = new Map; 41 | 42 | for (let key in schema) { 43 | const value = schema[ key ]; 44 | 45 | // TODO: 46 | switch ( key ) { 47 | default: 48 | this._properties.set(key, new Property(key, value)); 49 | break; 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * Name 56 | * 57 | * @return {String} 58 | */ 59 | name() { 60 | return this._name; 61 | } 62 | 63 | /** 64 | * Type 65 | * 66 | * @return {String} 67 | */ 68 | type() { 69 | return this._type; 70 | } 71 | 72 | /** 73 | * Get Internal Relationship Type 74 | * 75 | * @return {String} 76 | */ 77 | relationship() { 78 | return this._relationship; 79 | } 80 | 81 | /** 82 | * Set Direction of relationship 83 | * 84 | * @return {RelationshipType} 85 | */ 86 | setDirection(direction) { 87 | direction = direction.toUpperCase(); 88 | 89 | if ( direction == ALT_DIRECTION_IN ) { 90 | direction = DIRECTION_IN; 91 | } 92 | else if ( direction == ALT_DIRECTION_OUT ) { 93 | direction = DIRECTION_OUT; 94 | } 95 | else if ( [ DIRECTION_IN, DIRECTION_OUT, DIRECTION_BOTH ].indexOf(direction) == -1 ) { 96 | direction = DIRECTION_OUT; 97 | } 98 | 99 | this._direction = direction; 100 | 101 | return this; 102 | } 103 | 104 | /** 105 | * Get Direction of Node 106 | * 107 | * @return {String} 108 | */ 109 | direction() { 110 | return this._direction; 111 | } 112 | 113 | /** 114 | * Get the target node definition 115 | * 116 | * @return {Model} 117 | */ 118 | target() { 119 | return this._target; 120 | } 121 | 122 | /** 123 | * Get Schema object 124 | * 125 | * @return {Object} 126 | */ 127 | schema() { 128 | return this._schema; 129 | } 130 | 131 | /** 132 | * Should this relationship be eagerly loaded? 133 | * 134 | * @return {bool} 135 | */ 136 | eager() { 137 | return this._eager; 138 | } 139 | 140 | /** 141 | * Cascade policy for this relationship type 142 | * 143 | * @return {String} 144 | */ 145 | cascade() { 146 | return this._cascade; 147 | } 148 | 149 | /** 150 | * Get Properties defined for this relationship 151 | * 152 | * @return Map 153 | */ 154 | properties() { 155 | return this._properties; 156 | } 157 | 158 | /** 159 | * Get the alias given to the node 160 | * 161 | * @return {String} 162 | */ 163 | nodeAlias() { 164 | return this._node_alias; 165 | } 166 | 167 | } -------------------------------------------------------------------------------- /src/Relationship.js: -------------------------------------------------------------------------------- 1 | import Entity from './Entity'; 2 | import UpdateRelationship from './Services/UpdateRelationship'; 3 | import DeleteRelationship from './Services/DeleteRelationship'; 4 | import { DIRECTION_IN, } from './RelationshipType'; 5 | 6 | export default class Relationship extends Entity { 7 | /** 8 | * 9 | * @param {Neode} neode Neode instance 10 | * @param {RelationshipType} definition Relationship type definition 11 | * @param {Integer} identity Identity 12 | * @param {String} relationship Relationship type 13 | * @param {Map} properties Map of properties for the relationship 14 | * @param {Node} start Start Node 15 | * @param {Node} end End Node 16 | * @param {String} node_alias Alias given to the Node when converting to JSON 17 | */ 18 | constructor(neode, definition, identity, type, properties, start, end, node_alias) { 19 | super(); 20 | 21 | this._neode = neode; 22 | this._definition = definition; 23 | this._identity = identity; 24 | this._type = type; 25 | this._properties = properties || new Map; 26 | this._start = start; 27 | this._end = end; 28 | this._node_alias = node_alias; 29 | } 30 | 31 | /** 32 | * Get the definition for this relationship 33 | * 34 | * @return {Definition} 35 | */ 36 | definition() { 37 | return this._definition; 38 | } 39 | 40 | /** 41 | * Get the relationship type 42 | */ 43 | type() { 44 | return this._type; 45 | } 46 | 47 | /** 48 | * Get the start node for this relationship 49 | * 50 | * @return {Node} 51 | */ 52 | startNode() { 53 | return this._start; 54 | } 55 | 56 | /** 57 | * Get the start node for this relationship 58 | * 59 | * @return {Node} 60 | */ 61 | endNode() { 62 | return this._end; 63 | } 64 | 65 | /** 66 | * Get the node on the opposite end of the Relationship to the subject 67 | * (ie if direction is in, get the end node, otherwise get the start node) 68 | */ 69 | otherNode() { 70 | return this._definition.direction() == DIRECTION_IN 71 | ? this.startNode() 72 | : this.endNode(); 73 | } 74 | 75 | /** 76 | * Convert Relationship to a JSON friendly Object 77 | * 78 | * @return {Promise} 79 | */ 80 | toJson() { 81 | const output = { 82 | _id: this.id(), 83 | _type: this.type(), 84 | }; 85 | 86 | const definition = this.definition(); 87 | 88 | // Properties 89 | definition.properties().forEach((property, key) => { 90 | if ( property.hidden() ) { 91 | return; 92 | } 93 | 94 | if ( this._properties.has(key) ) { 95 | output[ key ] = this.valueToJson(property, this._properties.get( key )); 96 | } 97 | }); 98 | 99 | // Get Other Node 100 | return this.otherNode().toJson() 101 | .then(json => { 102 | output[ definition.nodeAlias() ] = json; 103 | 104 | return output; 105 | }); 106 | } 107 | 108 | /** 109 | * Update the properties for this relationship 110 | * 111 | * @param {Object} properties New properties 112 | * @return {Node} 113 | */ 114 | update(properties) { 115 | // TODO: Temporary fix, add the properties to the properties map 116 | // Sorry, but it's easier than hacking the validator 117 | this._definition.properties().forEach(property => { 118 | const name = property.name(); 119 | 120 | if ( property.required() && !properties.hasOwnProperty(name) ) { 121 | properties[ name ] = this._properties.get( name ); 122 | } 123 | }); 124 | 125 | return UpdateRelationship(this._neode, this._definition, this._identity, properties) 126 | .then(properties => { 127 | Object.entries(properties).forEach(( [key, value] ) => { 128 | this._properties.set( key, value ); 129 | }); 130 | }) 131 | .then(() => { 132 | return this; 133 | }); 134 | } 135 | 136 | /** 137 | * Delete this relationship from the Graph 138 | * 139 | * @return {Promise} 140 | */ 141 | delete() { 142 | return DeleteRelationship(this._neode, this._identity) 143 | .then(() => { 144 | this._deleted = true; 145 | 146 | return this; 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/Query/Statement.js: -------------------------------------------------------------------------------- 1 | import Relationship from './Relationship'; 2 | import RelationshipType from '../RelationshipType'; 3 | import Property from './Property'; 4 | 5 | export default class Statement { 6 | constructor(prefix) { 7 | this._prefix = prefix || 'MATCH'; 8 | this._pattern = []; 9 | this._where = []; 10 | this._order = []; 11 | this._detach_delete = []; 12 | this._delete = []; 13 | this._return = []; 14 | this._set = []; 15 | this._on_create_set = []; 16 | this._on_match_set = []; 17 | this._remove = []; 18 | } 19 | 20 | match(match) { 21 | this._pattern.push(match); 22 | 23 | return this; 24 | } 25 | 26 | where(where) { 27 | this._where.push(where); 28 | 29 | return this; 30 | } 31 | 32 | limit(limit) { 33 | this._limit = limit; 34 | } 35 | 36 | skip(skip) { 37 | this._skip = skip; 38 | } 39 | 40 | order(order) { 41 | this._order.push(order); 42 | } 43 | 44 | delete(...values) { 45 | this._delete = this._delete.concat(values); 46 | 47 | return this; 48 | } 49 | 50 | detachDelete(...values) { 51 | this._detach_delete = this._detach_delete.concat(values); 52 | 53 | return this; 54 | } 55 | 56 | return(...values) { 57 | this._return = this._return.concat(values); 58 | 59 | return this; 60 | } 61 | 62 | relationship(relationship, direction, alias, degrees) { 63 | if ( relationship instanceof RelationshipType ) { 64 | const rel = relationship; 65 | 66 | relationship = rel.relationship(); 67 | direction = rel.direction(); 68 | } 69 | 70 | this._pattern.push(new Relationship(relationship, direction, alias, degrees)); 71 | 72 | return this; 73 | } 74 | 75 | set(key, value, operator = '=') { 76 | this._set.push( new Property(key, value, operator) ); 77 | 78 | return this; 79 | } 80 | 81 | setRaw(items) { 82 | this._set = this._set.concat(items); 83 | 84 | return this; 85 | } 86 | 87 | onCreateSet(key, value, operator = '=') { 88 | this._on_create_set.push( new Property(key, value, operator) ); 89 | 90 | return this; 91 | } 92 | 93 | onMatchSet(key, value, operator = '=') { 94 | this._on_match_set.push( new Property(key, value, operator) ); 95 | 96 | return this; 97 | } 98 | 99 | /** 100 | * 101 | * @param {Array} items 102 | */ 103 | remove(items) { 104 | this._remove = this._remove.concat(items); 105 | 106 | return this; 107 | } 108 | 109 | toString(includePrefix = true) { 110 | const output = []; 111 | 112 | if (this._pattern.length) { 113 | if ( includePrefix ) output.push(this._prefix); 114 | 115 | output.push(this._pattern.map(statement => { 116 | return statement.toString(); 117 | }).join('')); 118 | } 119 | 120 | if (this._where.length) { 121 | output.push(this._where.map(statement => { 122 | return statement.toString(); 123 | }).join('')); 124 | } 125 | 126 | if ( this._remove.length ) { 127 | output.push('REMOVE'); 128 | 129 | output.push(this._remove.join(', ')); 130 | } 131 | 132 | if ( this._on_create_set.length ) { 133 | output.push('ON CREATE SET'); 134 | 135 | output.push(this._on_create_set.map(output => { 136 | return output.toString(); 137 | }).join(', ')); 138 | } 139 | 140 | 141 | if ( this._on_match_set.length ) { 142 | output.push('ON MATCH SET'); 143 | 144 | output.push(this._on_match_set.map(output => { 145 | return output.toString(); 146 | }).join(', ')); 147 | } 148 | 149 | 150 | if ( this._set.length ) { 151 | output.push('SET'); 152 | 153 | output.push(this._set.map(output => { 154 | return output.toString(); 155 | }).join(', ')); 156 | } 157 | 158 | if (this._delete.length) { 159 | output.push('DELETE'); 160 | 161 | output.push(this._delete.map(output => { 162 | return output.toString(); 163 | })); 164 | } 165 | 166 | if (this._detach_delete.length) { 167 | output.push('DETACH DELETE'); 168 | 169 | output.push(this._detach_delete.map(output => { 170 | return output.toString(); 171 | })); 172 | } 173 | 174 | if (this._return.length) { 175 | output.push('RETURN'); 176 | 177 | output.push(this._return.map(output => { 178 | return output.toString(); 179 | })); 180 | } 181 | 182 | if (this._order.length) { 183 | output.push('ORDER BY'); 184 | 185 | output.push(this._order.map(output => { 186 | return output.toString(); 187 | })); 188 | } 189 | 190 | if ( this._skip ) { 191 | output.push(`SKIP ${this._skip}`); 192 | } 193 | 194 | if ( this._limit ) { 195 | output.push(`LIMIT ${this._limit}`); 196 | } 197 | 198 | return output.join('\n'); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /test/Services/FindAll.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import FindAll from '../../src/Services/FindAll'; 3 | import Create from '../../src/Services/Create'; 4 | import Node from '../../src/Node'; 5 | 6 | const TIMEOUT = 10000; 7 | 8 | describe('Services/FindAll.js', () => { 9 | let instance; 10 | let model; 11 | 12 | const other_label = 'FindAllEager'; 13 | const label = 'FindAllTest'; 14 | const schema = { 15 | uuid: { 16 | type: 'uuid', 17 | primary: true, 18 | }, 19 | name: { 20 | type: 'string', 21 | required: true, 22 | }, 23 | relationshipsToModel: { 24 | type: 'relationship', 25 | relationship: 'RELATIONSHIP_TO_MODEL', 26 | target: label, 27 | direction: 'out', 28 | alias: 'node', 29 | properties: { 30 | since: { 31 | type: 'int', 32 | default: Date.now 33 | } 34 | }, 35 | }, 36 | relationshipToOther: { 37 | type: 'relationship', 38 | relationship: 'RELATIONSHIP_TO_OTHER', 39 | target: other_label, 40 | direction: 'out', 41 | eager: true, 42 | alias: 'node', 43 | properties: { 44 | since: { 45 | type: 'int', 46 | default: Date.now 47 | } 48 | }, 49 | }, 50 | forArray: { 51 | type: 'node', 52 | relationship: 'FOR_ARRAY', 53 | target: label, 54 | direction: 'out', 55 | }, 56 | nodeToOther: { 57 | type: 'node', 58 | relationship: 'RELATIONSHIP_TO_OTHER', 59 | target: other_label, 60 | direction: 'out', 61 | eager: true, 62 | }, 63 | arrayOfRelationships: { 64 | type: 'nodes', 65 | relationship: [ 'RELATIONSHIP_TO_MODEL', 'FOR_ARRAY' ], 66 | // target: other_label, 67 | direction: 'out', 68 | eager: true, 69 | }, 70 | }; 71 | 72 | before(() => { 73 | instance = require('../instance')(); 74 | instance.model(other_label, {id: 'number'}); 75 | model = instance.model(label, schema); 76 | }); 77 | 78 | afterEach(done => { 79 | instance.deleteAll(label) 80 | // .then(() => { 81 | // return instance.close() 82 | // }) 83 | .then(() => done()); 84 | }); 85 | 86 | after(() => instance.close()); 87 | 88 | it('should find nodes filtered by properties', done => { 89 | const name = 'Filtered Node'; 90 | const eager_name = 'Eager Node'; 91 | const other_id = 1; 92 | Create(instance, model, { 93 | name, 94 | relationshipsToModel: { 95 | since: 100, 96 | node: { 97 | name: eager_name, 98 | }, 99 | }, 100 | nodeToOther: { 101 | id: other_id, 102 | }, 103 | forArray: { 104 | name: 'For Array' 105 | }, 106 | }) 107 | .then(() => { 108 | return FindAll(instance, model, { name }) 109 | .then(collection => { 110 | expect(collection.length).to.equal(1); 111 | 112 | const first = collection.first(); 113 | 114 | expect(first).to.be.an.instanceOf(Node); 115 | expect(first.get('name')).to.equal(name); 116 | 117 | // Eager 118 | expect( first._eager.get('nodeToOther').get('id') ).to.equal(other_id); 119 | expect( first._eager.get('relationshipToOther').otherNode().get('id') ).to.equal(other_id); 120 | expect( first._eager.get('arrayOfRelationships').length ).to.equal(2); 121 | }); 122 | }) 123 | .then(() => done()) 124 | .catch(e => { 125 | console.log(e) 126 | done(e) 127 | }); 128 | }); 129 | 130 | it('should apply the alias to an order', done => { 131 | // TODO: Reinstate 132 | // Promise.all([ 133 | // instance.create(label, { name: '100' }), 134 | // instance.create(label, { name: '300' }), 135 | // instance.create(label, { name: '150' }), 136 | // ]) 137 | // .then(() => { 138 | // return FindAll(instance, model, {}, 'name') 139 | // .then(res => { 140 | // const actual = res.map(r => r.get('name')); 141 | // const expected = [ '100', '150', '300' ]; 142 | 143 | // expect( actual ).to.deep.equal( expected ); 144 | // }) 145 | // .then(() => done()) 146 | // .catch(e => done(e)); 147 | // }); 148 | done() 149 | }); 150 | 151 | it('should apply the alias to a map of orders', done => { 152 | // TODO: Reinstate 153 | // Promise.all([ 154 | // instance.create(label, { name: '100' }), 155 | // instance.create(label, { name: '300' }), 156 | // instance.create(label, { name: '150' }), 157 | // ]) 158 | // .then(() => { 159 | // return FindAll(instance, model, {}, { name: 'DESC' }) 160 | // .then(res => { 161 | // const actual = res.map(r => r.get('name')); 162 | // const expected = [ '300', '150', '100' ]; 163 | 164 | // expect( actual ).to.deep.equal( expected ); 165 | // }); 166 | // }) 167 | // .then(() => done()) 168 | // .catch(e => done(e)); 169 | done() 170 | }); 171 | 172 | }); -------------------------------------------------------------------------------- /src/Factory.js: -------------------------------------------------------------------------------- 1 | import Collection from './Collection'; 2 | import Node from './Node'; 3 | import Relationship from './Relationship'; 4 | import neo4j from 'neo4j-driver'; 5 | 6 | import { EAGER_ID, EAGER_LABELS, EAGER_TYPE, } from './Query/EagerUtils'; 7 | import { DIRECTION_IN, } from './RelationshipType'; 8 | 9 | export default class Factory { 10 | 11 | /** 12 | * @constuctor 13 | * 14 | * @param Neode neode 15 | */ 16 | constructor(neode) { 17 | this._neode = neode; 18 | } 19 | 20 | /** 21 | * Hydrate the first record in a result set 22 | * 23 | * @param {Object} res Neo4j Result 24 | * @param {String} alias Alias of Node to pluck 25 | * @return {Node} 26 | */ 27 | hydrateFirst(res, alias, definition) { 28 | if ( !res || !res.records.length ) { 29 | return false; 30 | } 31 | 32 | return this.hydrateNode( res.records[0].get(alias), definition ); 33 | } 34 | 35 | /** 36 | * Hydrate a set of nodes and return a Collection 37 | * 38 | * @param {Object} res Neo4j result set 39 | * @param {String} alias Alias of node to pluck 40 | * @param {Definition|null} definition Force Definition 41 | * @return {Collection} 42 | */ 43 | 44 | hydrate(res, alias, definition) { 45 | if ( !res ) { 46 | return false; 47 | } 48 | 49 | const nodes = res.records.map( row => this.hydrateNode(row.get(alias), definition) ); 50 | 51 | return new Collection(this._neode, nodes); 52 | } 53 | 54 | /** 55 | * Get the definition by a set of labels 56 | * 57 | * @param {Array} labels 58 | * @return {Model} 59 | */ 60 | getDefinition(labels) { 61 | return this._neode.models.getByLabels(labels); 62 | } 63 | 64 | /** 65 | * Take a result object and convert it into a Model 66 | * 67 | * @param {Object} record 68 | * @param {Model|String|null} definition 69 | * @return {Node} 70 | */ 71 | hydrateNode(record, definition) { 72 | // Is there no better way to check this?! 73 | if ( neo4j.isInt( record.identity ) && Array.isArray( record.labels ) ) { 74 | record = Object.assign({}, record.properties, { 75 | [EAGER_ID]: record.identity, 76 | [EAGER_LABELS]: record.labels, 77 | }); 78 | } 79 | 80 | // Get Internals 81 | const identity = record[ EAGER_ID ]; 82 | const labels = record[ EAGER_LABELS ]; 83 | 84 | // Get Definition from 85 | if ( !definition ) { 86 | definition = this.getDefinition(labels); 87 | } 88 | else if ( typeof definition === 'string' ) { 89 | definition = this._neode.models.get(definition); 90 | } 91 | 92 | // Helpful error message if nothing could be found 93 | if ( !definition ) { 94 | throw new Error(`No model definition found for labels ${ JSON.stringify(labels) }`); 95 | } 96 | 97 | // Get Properties 98 | const properties = new Map; 99 | 100 | definition.properties().forEach((value, key) => { 101 | if ( record.hasOwnProperty(key) ) { 102 | properties.set(key, record[ key ]); 103 | } 104 | }); 105 | 106 | // Create Node Instance 107 | const node = new Node(this._neode, definition, identity, labels, properties); 108 | 109 | // Add eagerly loaded props 110 | definition.eager().forEach(eager => { 111 | const name = eager.name(); 112 | 113 | if ( !record[ name ] ) { 114 | return; 115 | } 116 | 117 | switch ( eager.type() ) { 118 | case 'node': 119 | node.setEager(name, this.hydrateNode(record[ name ]) ); 120 | break; 121 | 122 | case 'nodes': 123 | node.setEager( name, new Collection(this._neode, record[ name ].map(value => this.hydrateNode(value))) ); 124 | break; 125 | 126 | case 'relationship': 127 | node.setEager( name, this.hydrateRelationship(eager, record[ name ], node) ); 128 | break; 129 | 130 | case 'relationships': 131 | node.setEager( name, new Collection(this._neode, record[ name ].map(value => this.hydrateRelationship(eager, value, node))) ); 132 | break; 133 | } 134 | }); 135 | 136 | return node; 137 | } 138 | 139 | /** 140 | * Take a result object and convert it into a Relationship 141 | * 142 | * @param {RelationshipType} definition Relationship type 143 | * @param {Object} record Record object 144 | * @param {Node} this_node 'This' node in the current context 145 | * @return {Relationship} 146 | */ 147 | hydrateRelationship(definition, record, this_node) { 148 | // Get Internals 149 | const identity = record[ EAGER_ID ]; 150 | const type = record[ EAGER_TYPE ]; 151 | 152 | // Get Definition from 153 | // const definition = this.getDefinition(labels); 154 | 155 | // Get Properties 156 | const properties = new Map; 157 | 158 | definition.properties().forEach((value, key) => { 159 | if ( record.hasOwnProperty(key) ) { 160 | properties.set(key, record[ key ]); 161 | } 162 | }); 163 | 164 | // Start & End Nodes 165 | const other_node = this.hydrateNode( record[ definition.nodeAlias() ] ); 166 | 167 | // Calculate Start & End Nodes 168 | const start_node = definition.direction() == DIRECTION_IN 169 | ? other_node: this_node; 170 | 171 | const end_node = definition.direction() == DIRECTION_IN 172 | ? this_node : other_node; 173 | 174 | return new Relationship(this._neode, definition, identity, type, properties, start_node, end_node); 175 | } 176 | 177 | } -------------------------------------------------------------------------------- /src/Model.js: -------------------------------------------------------------------------------- 1 | import Queryable from './Queryable'; 2 | 3 | import RelationshipType, {DIRECTION_BOTH} from './RelationshipType'; 4 | import Property from './Property'; 5 | 6 | const RELATIONSHIP_TYPES = [ 'relationship', 'relationships', 'node', 'nodes' ]; 7 | 8 | export default class Model extends Queryable { 9 | constructor(neode, name, schema) { 10 | super(neode); 11 | 12 | this._name = name; 13 | this._schema = schema; 14 | 15 | this._properties = new Map; 16 | this._relationships = new Map; 17 | this._labels = [ name ]; 18 | 19 | // Default Primary Key to {label}_id 20 | this._primary_key = name.toLowerCase() + '_id'; 21 | 22 | this._unique = []; 23 | this._indexed = []; 24 | this._hidden = []; 25 | this._readonly = []; 26 | 27 | // TODO: Clean this up 28 | for (let key in schema) { 29 | const value = schema[ key ]; 30 | 31 | switch ( key ) { 32 | case 'labels': 33 | this.setLabels(...value); 34 | break; 35 | 36 | default: 37 | if ( value.type && RELATIONSHIP_TYPES.indexOf(value.type) > -1 ) { 38 | const { relationship, direction, target, properties, eager, cascade, alias } = value; 39 | 40 | this.relationship(key, value.type, relationship, direction, target, properties, eager, cascade, alias); 41 | } 42 | else { 43 | this.addProperty(key, value); 44 | } 45 | break; 46 | } 47 | } 48 | } 49 | 50 | /** 51 | * Get Model name 52 | * 53 | * @return {String} 54 | */ 55 | name() { 56 | return this._name; 57 | } 58 | 59 | /** 60 | * Get Schema 61 | * 62 | * @return {Object} 63 | */ 64 | schema() { 65 | return this._schema; 66 | } 67 | 68 | /** 69 | * Get a map of Properties 70 | * 71 | * @return {Map} 72 | */ 73 | properties() { 74 | return this._properties; 75 | } 76 | 77 | /** 78 | * Set Labels 79 | * 80 | * @param {...String} labels 81 | * @return {Model} 82 | */ 83 | setLabels(...labels) { 84 | this._labels = labels.sort(); 85 | 86 | return this; 87 | } 88 | 89 | /** 90 | * Get Labels 91 | * 92 | * @return {Array} 93 | */ 94 | labels() { 95 | return this._labels; 96 | } 97 | 98 | /** 99 | * Add a property definition 100 | * 101 | * @param {String} key Property name 102 | * @param {Object} schema Schema object 103 | * @return {Model} 104 | */ 105 | addProperty(key, schema) { 106 | const property = new Property(key, schema); 107 | 108 | this._properties.set(key, property); 109 | 110 | // Is this key the primary key? 111 | if ( property.primary() ) { 112 | this._primary_key = key; 113 | } 114 | 115 | // Is this property unique? 116 | if ( property.unique() || property.primary() ) { 117 | this._unique.push(key); 118 | } 119 | 120 | // Is this property indexed? 121 | if ( property.indexed() ) { 122 | this._indexed.push(key); 123 | } 124 | 125 | // Should this property be hidden during JSON conversion? 126 | if ( property.hidden() ) { 127 | this._hidden.push(key); 128 | } 129 | 130 | // Is this property only to be read and never written to DB (e.g. auto-generated UUIDs)? 131 | if ( property.readonly() ) { 132 | this._readonly.push(key); 133 | } 134 | 135 | return this; 136 | } 137 | 138 | /** 139 | * Add a new relationship 140 | * 141 | * @param {String} name The name given to the relationship 142 | * @param {String} type Type of Relationship 143 | * @param {String} direction Direction of Node (Use constants DIRECTION_IN, DIRECTION_OUT, DIRECTION_BOTH) 144 | * @param {String|Model|null} target Target type definition for the 145 | * @param {Object} schema Property Schema 146 | * @param {Bool} eager Should this relationship be eager loaded? 147 | * @param {Bool|String} cascade Cascade delete policy for this relationship 148 | * @param {String} node_alias Alias to give to the node in the pattern comprehension 149 | * @return {Relationship} 150 | */ 151 | relationship(name, type, relationship, direction = DIRECTION_BOTH, target, schema = {}, eager = false, cascade = false, node_alias = 'node') { 152 | if (relationship && direction && schema) { 153 | this._relationships.set(name, new RelationshipType(name, type, relationship, direction, target, schema, eager, cascade, node_alias)); 154 | } 155 | 156 | return this._relationships.get(name); 157 | } 158 | 159 | /** 160 | * Get all defined Relationships for this Model 161 | * 162 | * @return {Map} 163 | */ 164 | relationships() { 165 | return this._relationships; 166 | } 167 | 168 | /** 169 | * Get relationships defined as Eager relationships 170 | * 171 | * @return {Array} 172 | */ 173 | eager() { 174 | return Array.from(this._relationships).map(([key, value]) => { // eslint-disable-line no-unused-vars 175 | return value._eager ? value : null; 176 | }).filter(a => !!a); 177 | } 178 | 179 | /** 180 | * Get the name of the primary key 181 | * 182 | * @return {String} 183 | */ 184 | primaryKey() { 185 | return this._primary_key; 186 | } 187 | 188 | /** 189 | * Get array of hidden fields 190 | * 191 | * @return {String[]} 192 | */ 193 | hidden() { 194 | return this._hidden; 195 | } 196 | 197 | /** 198 | * Get array of indexed fields 199 | * 200 | * @return {String[]} 201 | */ 202 | indexes() { 203 | return this._indexed; 204 | } 205 | 206 | /** 207 | * Get defined merge fields 208 | * 209 | * @return {Array} 210 | */ 211 | mergeFields() { 212 | return this._unique.concat(this._indexed); 213 | } 214 | } -------------------------------------------------------------------------------- /test/Schema.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import Schema from '../src/Schema'; 3 | 4 | describe('Schema.js', () => { 5 | const label = 'SchemaThing'; 6 | let instance; 7 | 8 | before(() => { 9 | instance = require('./instance')(); 10 | 11 | instance.model(label, { 12 | id: { 13 | type: 'string', 14 | required: true, 15 | unique: true, 16 | }, 17 | name: { 18 | type: 'string', 19 | required: true 20 | }, 21 | age: { 22 | type: 'number', 23 | index: true 24 | } 25 | }); 26 | }); 27 | 28 | after(() => { 29 | instance.close(); 30 | }); 31 | 32 | it('should construct', () => { 33 | assert.instanceOf(instance.schema, Schema); 34 | assert.isFunction(instance.schema.install); 35 | assert.isFunction(instance.schema.drop); 36 | }); 37 | 38 | it('should install the schema', (done) => { 39 | // TODO: Tests for Enterprise Mode 40 | instance.schema.install() 41 | .then(() => instance.cypher('CALL db.awaitIndexes')) 42 | .then(() => instance.cypher('CALL db.constraints')) 43 | .then(constraints => { 44 | let id_unique = false; 45 | let id_exists = false; 46 | let name_exists = false; 47 | 48 | // Check Constraints 49 | const is_unique = /CONSTRAINT ON \( ([a-z0-9]+):([A-Za-z0-9]+) \) ASSERT ([a-z0-9]+).([A-Za-z0-9]+) IS UNIQUE/; 50 | const will_exist = /CONSTRAINT ON \( ([a-z0-9]+):([A-Za-z0-9]+) \) ASSERT exists\(([a-z0-9]+).([A-Za-z0-9]+)\)/; 51 | 52 | constraints.records.forEach(constraint => { 53 | const description = constraint.get('description'); 54 | 55 | const unique = description.match(is_unique); 56 | const exists = description.match(will_exist); 57 | 58 | if (unique && unique[2] == label) { 59 | if ( unique[4] == 'id' ) { 60 | id_unique = true; 61 | } 62 | } 63 | 64 | if (exists && exists[2] == label) { 65 | if ( exists[4] == 'id' ) { 66 | id_exists = true; 67 | } 68 | else if ( exists[4] == 'name' ) { 69 | name_exists = true; 70 | } 71 | } 72 | }) 73 | 74 | // Assertions 75 | // expect(id_unique).to.equal(true); 76 | 77 | // Enterprise? 78 | if (instance.enterprise()) { 79 | expect(id_exists).to.equal(true); 80 | expect(name_exists).to.equal(true); 81 | } 82 | }) 83 | .then(() => instance.cypher('CALL db.indexes')) 84 | .then(indexes => { 85 | const expected = { 86 | 'SchemaThing.age': true 87 | }; 88 | let actual = {}; 89 | 90 | indexes.records.forEach(index => { 91 | actual[ index.get('labelsOrTypes')[0] + '.'+ index.get('properties')[0] ] = true; 92 | }); 93 | 94 | expect(actual).to.include(expected); 95 | }) 96 | .then(() => done()) 97 | .catch(e => { 98 | done(e); 99 | }) 100 | }); 101 | 102 | it('should drop the schema', (done) => { 103 | instance.schema.drop() 104 | .then(() => instance.cypher('CALL db.constraints')) 105 | .then(constraints => { 106 | let id_unique = false; 107 | let id_exists = false; 108 | let name_exists = false; 109 | 110 | // Check Constraints 111 | const is_unique = /CONSTRAINT ON \( ([a-z0-9]+):([A-Za-z0-9]+) \) ASSERT ([a-z0-9]+).([A-Za-z0-9]+) IS UNIQUE/; 112 | const will_exist = /CONSTRAINT ON \( ([a-z0-9]+):([A-Za-z0-9]+) \) ASSERT exists\(([a-z0-9]+).([A-Za-z0-9]+)\)/; 113 | 114 | constraints.records.forEach(constraint => { 115 | const description = constraint.get('description'); 116 | 117 | const unique = description.match(is_unique); 118 | const exists = description.match(will_exist); 119 | 120 | if (unique && unique[2] == label) { 121 | if ( unique[4] == 'id' ) { 122 | id_unique = true; 123 | } 124 | } 125 | 126 | if (exists && exists[2] == label) { 127 | if ( exists[4] == 'id' ) { 128 | id_exists = true; 129 | } 130 | else if ( exists[4] == 'name' ) { 131 | name_exists = true; 132 | } 133 | } 134 | }) 135 | 136 | // Assertions 137 | expect(id_unique).to.equal(false); 138 | expect(id_exists).to.equal(false); 139 | 140 | // Enterprise? 141 | if (instance.enterprise()) { 142 | expect(name_exists).to.equal(false); 143 | } 144 | }) 145 | .then(() => instance.cypher('CALL db.indexes')) 146 | // TODO: Reinstate 147 | // .then(indexes => { 148 | // const unexpected = { 149 | // age: true 150 | // }; 151 | // let actual = {}; 152 | 153 | // const has_index = /INDEX ON :([A-Za-z0-9]+)\(([A-Za-z0-9]+)\)/ 154 | 155 | // indexes.records.forEach(index => { 156 | // const description = index.get('description'); 157 | // const is_indexed = description.match(has_index); 158 | 159 | // if (is_indexed && is_indexed[1] == label) { 160 | // actual[is_indexed[2]] = true; 161 | // } 162 | // }); 163 | 164 | // expect(actual).to.not.include(unexpected); 165 | // }) 166 | .then(() => done()) 167 | .catch(e => done(e)) 168 | }); 169 | 170 | }); -------------------------------------------------------------------------------- /test/Model.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { expect, } from 'chai'; 3 | import Model from '../src/Model'; 4 | import RelationshipType from '../src/RelationshipType'; 5 | import Property from '../src/Property'; 6 | 7 | describe('Model.js', () => { 8 | let instance; 9 | let model; 10 | const name = 'ModelTest'; 11 | const schema = { 12 | labels: ['Test', 'Labels'], 13 | uuid: { 14 | type: 'uuid', 15 | primary: true, 16 | }, 17 | boolean: 'boolean', 18 | int: 'int', 19 | integer: 'integer', 20 | number: { 21 | type: 'number', 22 | hidden: true, 23 | readonly: true, 24 | }, 25 | string: { 26 | type: 'string', 27 | index: true, 28 | unique: true, 29 | required: true, 30 | }, 31 | relationship: { 32 | type: 'relationship', 33 | relationship: 'RELATIONSHIP', 34 | target: 'ModelTest', 35 | eager: true, 36 | alias: 'nodeattheend', 37 | properties: { 38 | updated: 'boolean', 39 | default: false, 40 | }, 41 | }, 42 | relationships: { 43 | type: 'relationships', 44 | relationship: 'RELATIONSHIPS', 45 | target: 'ModelTest', 46 | eager: false, 47 | }, 48 | node: { 49 | type: 'node', 50 | relationship: 'NODE', 51 | target: 'ModelTest', 52 | eager: true, 53 | }, 54 | nodes: { 55 | type: 'nodes', 56 | relationship: 'NODES', 57 | target: 'ModelTest', 58 | eager: false, 59 | }, 60 | }; 61 | 62 | before(() => { 63 | instance = require('./instance')(); 64 | model = instance.model(name, schema); 65 | }); 66 | 67 | after(done => { 68 | instance.deleteAll(name) 69 | .then(() => { 70 | return instance.close(); 71 | }) 72 | .then(() => done()) 73 | .catch(e => done(e)); 74 | }); 75 | 76 | describe('::constructor', () => { 77 | it('should construct', () => { 78 | expect( model.name() ).to.equal(name); 79 | expect( model.labels() ).to.deep.equal(schema.labels.sort()); 80 | 81 | expect( model.primaryKey() ).to.deep.equal('uuid'); 82 | 83 | // Check Properties 84 | const props = ['uuid', 'boolean', 'number', 'string', 'int', 'integer']; 85 | expect( model.properties().size ).to.equal( props.length ); 86 | 87 | props.forEach(name => { 88 | const prop = model.properties().get(name); 89 | 90 | expect( prop ).to.be.an.instanceof(Property); 91 | expect( prop.type() ).to.equal(name); 92 | }); 93 | 94 | // Check properties have been set 95 | const uuid = model.properties().get('uuid'); 96 | expect( uuid.primary() ).to.equal(true); 97 | 98 | expect( model.properties().get('string').indexed() ).to.equal(true); 99 | expect( model.properties().get('string').unique() ).to.equal(true); 100 | 101 | expect( model.properties().get('number').readonly() ).to.equal(true); 102 | expect( model.properties().get('number').hidden() ).to.equal(true); 103 | 104 | expect( model.hidden() ).to.deep.equal(['number']); 105 | 106 | expect( model.indexes() ).to.deep.equal(['string']); 107 | 108 | // Check Relationships 109 | expect( model.relationships().size ).to.equal( 4 ); 110 | 111 | const rels = [ 'relationship', 'relationships', 'node', 'nodes' ]; 112 | 113 | rels.forEach(rel => { 114 | expect( model.relationships().get(rel) ).to.be.an.instanceof(RelationshipType); 115 | }); 116 | 117 | }); 118 | 119 | it('should guess labels and primary key', () => { 120 | const model = new Model(instance, name, {}); 121 | 122 | expect( model.name() ).to.equal(name); 123 | expect( model.labels() ).to.deep.equal(['ModelTest']); 124 | 125 | expect( model.primaryKey() ).to.deep.equal('modeltest_id'); 126 | }); 127 | }); 128 | 129 | describe('::update', () => { 130 | it('should update a nodes properties', done => { 131 | instance.create(name, { string: 'old' }) 132 | .then(node => { 133 | return node.update({ string: 'new' }); 134 | }) 135 | .then(node => { 136 | expect( node.get('string') ).to.equal('new'); 137 | }) 138 | .then(() => done()) 139 | .catch(e => done(e)); 140 | }); 141 | 142 | it('should not throw an error if required properties are not included', done => { 143 | instance.create(name, { string: 'old', number: 3 }) 144 | .then(node => { 145 | return node.update({ number: 4 }); 146 | }) 147 | .then(node => { 148 | expect( node.get('string') ).to.equal('old'); 149 | expect( node.get('number') ).to.equal(4); 150 | }) 151 | .then(() => done()) 152 | .catch(e => done(e)); 153 | }); 154 | }); 155 | 156 | describe('Relationships', () => { 157 | it('should create, update and delete a relationship', done => { 158 | Promise.all([ 159 | instance.create(name, { string: 'first' }), 160 | instance.create(name, { string: 'second' }), 161 | ]) 162 | .then(([ first, second]) => { 163 | return first.relateTo(second, 'relationship'); 164 | }) 165 | .then(relationship => { 166 | return relationship.update({ updated: true }) 167 | .then(res => { 168 | expect( res.get('updated') ).to.be.true; 169 | 170 | return instance.cypher('MATCH ()-[r]->() WHERE id(r) = $id RETURN r.updated AS updated', { id: res.identity() }) 171 | .then(( {records} ) => { 172 | expect( records[0].get('updated') ).to.be.true; 173 | 174 | return res; 175 | }); 176 | }); 177 | }) 178 | .then(relationship => { 179 | return relationship.delete(); 180 | }) 181 | .then(res => { 182 | return instance.cypher('MATCH ()-[r]->() WHERE id(r) = $id RETURN r', { id: res.identity() }) 183 | .then(res => { 184 | expect( res.records.length ).to.equal(0); 185 | }); 186 | }) 187 | .then(() => done()) 188 | .catch(e => done(e)); 189 | }); 190 | }); 191 | 192 | }); -------------------------------------------------------------------------------- /src/Node.js: -------------------------------------------------------------------------------- 1 | import neo4j from 'neo4j-driver'; 2 | import Entity from './Entity'; 3 | import UpdateNode from './Services/UpdateNode'; 4 | import DeleteNode from './Services/DeleteNode'; 5 | import RelateTo from './Services/RelateTo'; 6 | import DetachFrom from './Services/DetachFrom'; 7 | import RelationshipType from './RelationshipType'; 8 | 9 | /** 10 | * Node Container 11 | */ 12 | export default class Node extends Entity { 13 | 14 | /** 15 | * @constructor 16 | * 17 | * @param {Neode} neode Neode Instance 18 | * @param {Model} model Model definition 19 | * @param {Integer} identity Internal Node ID 20 | * @param {Array} labels Node labels 21 | * @param {Object} properties Property Map 22 | * @param {Map} eager Eagerly loaded values 23 | * @return {Node} 24 | */ 25 | constructor(neode, model, identity, labels, properties, eager) { 26 | super(); 27 | 28 | this._neode = neode; 29 | this._model = model; 30 | this._identity = identity; 31 | this._labels = labels; 32 | this._properties = properties || new Map; 33 | 34 | this._eager = eager || new Map; 35 | 36 | this._deleted = false; 37 | } 38 | 39 | /** 40 | * Get the Model for this Node 41 | * 42 | * @return {Model} 43 | */ 44 | model() { 45 | return this._model; 46 | } 47 | 48 | /** 49 | * Get Labels 50 | * 51 | * @return {Array} 52 | */ 53 | labels() { 54 | return this._labels; 55 | } 56 | 57 | /** 58 | * Set an eager value on the fly 59 | * 60 | * @param {String} key 61 | * @param {Mixed} value 62 | * @return {Node} 63 | */ 64 | setEager(key, value) { 65 | this._eager.set(key, value); 66 | 67 | return this; 68 | } 69 | 70 | /** 71 | * Delete this node from the Graph 72 | * 73 | * @param {Integer} to_depth Depth to delete to (Defaults to 10) 74 | * @return {Promise} 75 | */ 76 | delete(to_depth) { 77 | return DeleteNode(this._neode, this._identity, this._model, to_depth) 78 | .then(() => { 79 | this._deleted = true; 80 | 81 | return this; 82 | }); 83 | } 84 | 85 | /** 86 | * Relate this node to another based on the type 87 | * 88 | * @param {Node} node Node to relate to 89 | * @param {String} type Type of Relationship definition 90 | * @param {Object} properties Properties to set against the relationships 91 | * @param {Boolean} force_create Force the creation a new relationship? If false, the relationship will be merged 92 | * @return {Promise} 93 | */ 94 | relateTo(node, type, properties = {}, force_create = false) { 95 | const relationship = this._model.relationships().get(type); 96 | 97 | if ( !(relationship instanceof RelationshipType) ) { 98 | return Promise.reject( new Error(`Cannot find relationship with type ${type}`) ); 99 | } 100 | 101 | return RelateTo(this._neode, this, node, relationship, properties, force_create) 102 | .then(rel => { 103 | this._eager.delete(type); 104 | 105 | return rel; 106 | }); 107 | } 108 | 109 | /** 110 | * Detach this node to another 111 | * 112 | * @param {Node} node Node to detach from 113 | * @return {Promise} 114 | */ 115 | detachFrom(other) { 116 | if (!(other instanceof Node)) { 117 | return Promise.reject(new Error(`Cannot find node with type ${other}`)); 118 | } 119 | 120 | return DetachFrom(this._neode, this, other); 121 | } 122 | 123 | /** 124 | * Convert Node to a JSON friendly Object 125 | * 126 | * @return {Promise} 127 | */ 128 | toJson() { 129 | const output = { 130 | _id: this.id(), 131 | _labels: this.labels(), 132 | }; 133 | 134 | // Properties 135 | this._model.properties().forEach((property, key) => { 136 | if ( property.hidden() ) { 137 | return; 138 | } 139 | 140 | if ( this._properties.has(key) ) { 141 | output[ key ] = this.valueToJson(property, this._properties.get( key )); 142 | } 143 | else if (neo4j.temporal.isDateTime(output[key])) { 144 | output[key] = new Date(output[key].toString()); 145 | } 146 | else if (neo4j.spatial.isPoint(output[key])) { 147 | switch (output[key].srid.toString()) { 148 | // SRID values: @https://neo4j.com/docs/developer-manual/current/cypher/functions/spatial/ 149 | case '4326': // WGS 84 2D 150 | output[key] = {longitude: output[key].x, latitude: output[key].y}; 151 | break; 152 | 153 | case '4979': // WGS 84 3D 154 | output[key] = {longitude: output[key].x, latitude: output[key].y, height: output[key].z}; 155 | break; 156 | 157 | case '7203': // Cartesian 2D 158 | output[key] = {x: output[key].x, y: output[key].y}; 159 | break; 160 | 161 | case '9157': // Cartesian 3D 162 | output[key] = {x: output[key].x, y: output[key].y, z: output[key].z}; 163 | break; 164 | } 165 | } 166 | }); 167 | 168 | // Eager Promises 169 | return Promise.all( this._model.eager().map((rel) => { 170 | const key = rel.name(); 171 | 172 | if ( this._eager.has( rel.name() ) ) { 173 | // Call internal toJson function on either a Node or NodeCollection 174 | return this._eager.get( rel.name() ).toJson() 175 | .then(value => { 176 | return { key, value }; 177 | }); 178 | } 179 | }) ) 180 | // Remove Empty 181 | .then(eager => eager.filter( e => !!e )) 182 | 183 | // Assign to Output 184 | .then(eager => { 185 | eager.forEach(({ key, value }) => output[ key ] = value); 186 | 187 | return output; 188 | }); 189 | } 190 | 191 | /** 192 | * Update the properties for this node 193 | * 194 | * @param {Object} properties New properties 195 | * @return {Node} 196 | */ 197 | update(properties) { 198 | 199 | // TODO: Temporary fix, add the properties to the properties map 200 | // Sorry, but it's easier than hacking the validator 201 | this._model.properties().forEach(property => { 202 | const name = property.name(); 203 | 204 | if ( property.required() && !properties.hasOwnProperty(name) ) { 205 | properties[ name ] = this._properties.get( name ); 206 | } 207 | }); 208 | 209 | return UpdateNode(this._neode, this._model, this._identity, properties) 210 | .then(properties => { 211 | properties.map(({ key, value }) => { 212 | this._properties.set(key, value) 213 | }) 214 | }) 215 | .then(() => { 216 | return this; 217 | }); 218 | } 219 | 220 | } -------------------------------------------------------------------------------- /test/Query/EagerUtils.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import { eagerNode, eagerRelationship, eagerPattern, } from '../../src/Query/EagerUtils'; 3 | 4 | describe('Query/EagerUtils.js', () => { 5 | const instance = require('../instance')(); 6 | const model = instance.model('EagerUtilTest', { 7 | name: 'string', 8 | number: 'number', 9 | directorRel: { 10 | type: 'relationship', 11 | relationship: 'DIRECTED', 12 | direction: 'in', 13 | target: 'Person', 14 | alias: 'director', 15 | 16 | eager: true, 17 | 18 | properties: { 19 | salary: 'float', 20 | }, 21 | }, 22 | actorRels: { 23 | type: 'relationships', 24 | relationship: 'ACTED_IN', 25 | direction: 'in', 26 | target: 'Person', 27 | alias: 'actor', 28 | 29 | eager: true, 30 | }, 31 | directorNode: { 32 | type: 'node', 33 | relationship: 'DIRECTED', 34 | direction: 'in', 35 | target: 'Person', 36 | 37 | eager: true, 38 | }, 39 | actorNodes: { 40 | type: 'nodes', 41 | relationship: 'ACTED_IN', 42 | direction: 'in', 43 | target: 'Person', 44 | 45 | eager: true, 46 | }, 47 | nonEager: { 48 | type: 'nodes', 49 | relationship: 'SHOULD_BE_IGNORED', 50 | direction: 'in', 51 | target: 'Person', 52 | 53 | }, 54 | }); 55 | 56 | instance.model('Movie', { 57 | title: 'string', 58 | }); 59 | 60 | instance.model('Person', { 61 | name: 'string', 62 | movies: { 63 | type: 'nodes', 64 | relationship: 'ACTED_IN', 65 | direction: 'out', 66 | target: 'Movie', 67 | alias: 'movie', 68 | 69 | eager: true, 70 | }, 71 | }); 72 | 73 | after(done => { 74 | instance.close(); 75 | done(); 76 | }); 77 | 78 | describe('eagerPattern', () => { 79 | it('should build a pattern for `node` and append [0]', () => { 80 | const rel = model.relationship('directorNode'); 81 | const output = eagerPattern(instance, 1, 'this', rel).replace(/\n/g, '').replace(/\s\s/g, ''); 82 | const expected = 'directorNode: [ (this)<-[this_directorNode_rel:`DIRECTED`]-(this_directorNode_node:Person) |this_directorNode_node { .*,__EAGER_ID__: id(this_directorNode_node),__EAGER_LABELS__: labels(this_directorNode_node)'; 83 | 84 | expect(output.indexOf(expected)).to.equal(0); 85 | expect(output.substr(-3)).to.equal('[0]'); 86 | }); 87 | 88 | it('should build a pattern for `nodes`', () => { 89 | const rel = model.relationship('actorNodes'); 90 | const output = eagerPattern(instance, 1, 'this', rel).replace(/\n/g, '').replace(/\s\s/g, ''); 91 | const expected = 'actorNodes: [ (this)<-[this_actorNodes_rel:`ACTED_IN`]-(this_actorNodes_node:Person) |this_actorNodes_node { .*,__EAGER_ID__: id(this_actorNodes_node),__EAGER_LABELS__: labels(this_actorNodes_node)'; 92 | 93 | expect(output.indexOf(expected)).to.equal(0); 94 | }); 95 | 96 | it('should build a pattern for `relationship` and append [0]', () => { 97 | const rel = model.relationship('directorRel'); 98 | const output = eagerPattern(instance, 1, 'this', rel).replace(/\n/g, '').replace(/\s\s/g, ''); 99 | const expected = 'directorRel: [ (this)<-[this_directorRel_rel:`DIRECTED`]-(this_directorRel_node:Person) |this_directorRel_rel { .*,__EAGER_ID__: id(this_directorRel_rel),__EAGER_TYPE__: type(this_directorRel_rel),director:this_directorRel_node { .*,__EAGER_ID__: id(this_directorRel_node),__EAGER_LABELS__: labels(this_directorRel_node),movies: [ (this_directorRel_node)-[this_directorRel_node_movies_rel:`ACTED_IN`]->(this_directorRel_node_movies_node:Movie) |this_directorRel_node_movies_node { .*,__EAGER_ID__: id(this_directorRel_node_movies_node),__EAGER_LABELS__: labels(this_directorRel_node_movies_node)} ]}} ][0]'; 100 | 101 | expect(output).to.equal(expected); 102 | }); 103 | 104 | it('should build a pattern for `relationships`', () => { 105 | const rel = model.relationship('actorRels'); 106 | const output = eagerPattern(instance, 1, 'this', rel).replace(/\n/g, '').replace(/\s\s/g, ''); 107 | 108 | const expected = 'actorRels: [ (this)<-[this_actorRels_rel:`ACTED_IN`]-(this_actorRels_node:Person) |this_actorRels_rel { .*,__EAGER_ID__: id(this_actorRels_rel),__EAGER_TYPE__: type(this_actorRels_rel),actor:this_actorRels_node { .*,__EAGER_ID__: id(this_actorRels_node),__EAGER_LABELS__: labels(this_actorRels_node),movies: [ (this_actorRels_node)-[this_actorRels_node_movies_rel:`ACTED_IN`]->(this_actorRels_node_movies_node:Movie) |this_actorRels_node_movies_node { .*,__EAGER_ID__: id(this_actorRels_node_movies_node),__EAGER_LABELS__: labels(this_actorRels_node_movies_node)} ]}} ]'; 109 | 110 | expect(output).to.equal(expected); 111 | }); 112 | }); 113 | 114 | describe('eagerNode', () => { 115 | const pattern = eagerNode(instance, 1, 'this', model).replace(/\n/g, ' ').replace(/\s{2,}/g, ''); 116 | 117 | it('should request properties and ids for a node', () => { 118 | const props = `this {.*,__EAGER_ID__: id(this),__EAGER_LABELS__: labels(this)`; 119 | 120 | expect( pattern.indexOf(props) > -1 ).to.equal(true); 121 | }); 122 | 123 | it('should request an eager `node`', () => { 124 | const props = 'directorNode: [ (this)<-[this_directorNode_rel:`DIRECTED`]-(this_directorNode_node:Person) |this_directorNode_node {.*,__EAGER_ID__: id(this_directorNode_node),__EAGER_LABELS__: labels(this_directorNode_node)'; 125 | expect( pattern.indexOf(props) > -1 ).to.equal(true); 126 | }); 127 | 128 | it('should request a nested eager statement', () => { 129 | const props = 'movies: [ (this_directorRel_node)-[this_directorRel_node_movies_rel:`ACTED_IN`]->(this_directorRel_node_movies_node:Movie) |this_directorRel_node_movies_node {.*,__EAGER_ID__: id(this_directorRel_node_movies_node),__EAGER_LABELS__: labels(this_directorRel_node_movies_node)} ]}} ]'; 130 | expect( pattern.indexOf(props) > -1 ).to.equal(true); 131 | }); 132 | 133 | 134 | it('should request eager `nodes`', () => { 135 | const props = 'actorNodes: [ (this)<-[this_actorNodes_rel:`ACTED_IN`]-(this_actorNodes_node:Person) |this_actorNodes_node {.*,__EAGER_ID__: id(this_actorNodes_node),__EAGER_LABELS__: labels(this_actorNodes_node)'; 136 | expect( pattern.indexOf(props) > -1 ).to.equal(true); 137 | }); 138 | 139 | it('should request an eager `relationship`', () => { 140 | const props = 'directorRel: [ (this)<-[this_directorRel_rel:`DIRECTED`]-(this_directorRel_node:Person) |this_directorRel_rel {.*,__EAGER_ID__: id(this_directorRel_rel),__EAGER_TYPE__: type(this_directorRel_rel)'; 141 | const director_props = 'director:this_directorRel_node {.*,__EAGER_ID__: id(this_directorRel_node),__EAGER_LABELS__: labels(this_directorRel_node)'; 142 | 143 | expect( pattern.indexOf(props) > -1 ).to.equal(true); 144 | expect( pattern.indexOf(director_props) > -1 ).to.equal(true); 145 | }); 146 | 147 | it('should request eager `relationships`', () => { 148 | const props = 'actorRels: [ (this)<-[this_actorRels_rel:`ACTED_IN`]-(this_actorRels_node:Person) |this_actorRels_rel {.*,__EAGER_ID__: id(this_actorRels_rel),__EAGER_TYPE__: type(this_actorRels_rel),actor:this_actorRels_node {.*,__EAGER_ID__: id(this_actorRels_node),__EAGER_LABELS__: labels(this_actorRels_node),movies: [ (this_actorRels_node)-[this_actorRels_node_movies_rel:`ACTED_IN`]->(this_actorRels_node_movies_node:Movie) |this_actorRels_node_movies_node {.*,__EAGER_ID__: id(this_actorRels_node_movies_node),__EAGER_LABELS__: labels(this_actorRels_node_movies_node)} ]}} ]'; 149 | expect( pattern.indexOf(props) > -1 ).to.equal(true); 150 | }); 151 | }); 152 | 153 | }); -------------------------------------------------------------------------------- /test/Services/CleanValue.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import CleanValue from '../../src/Services/CleanValue'; 3 | import neo4j from 'neo4j-driver'; 4 | 5 | describe('Services/CleanValue.js', () => { 6 | 7 | it('should handle a float', () => { 8 | const input = '1.2'; 9 | const expected = parseFloat(1.2); 10 | const output = CleanValue({ type: 'float' }, input); 11 | 12 | expect(output).to.equal(expected); 13 | }); 14 | 15 | it('should handle an int', () => { 16 | const input = '1.2'; 17 | const expected = parseInt(1.2); 18 | const output = CleanValue({ type: 'int' }, input); 19 | 20 | expect(output.toNumber()).to.equal(expected); 21 | }); 22 | 23 | it('should handle an integer', () => { 24 | const input = '1.2'; 25 | const expected = parseInt(1.2); 26 | const output = CleanValue({ type: 'integer' }, input); 27 | 28 | expect(output.toNumber()).to.equal(expected); 29 | }); 30 | 31 | it('should handle a boolean', () => { 32 | expect( CleanValue({ type: 'boolean' }, true) ).to.equal(true); 33 | expect( CleanValue({ type: 'boolean' }, '1') ).to.equal(true); 34 | expect( CleanValue({ type: 'boolean' }, 1) ).to.equal(true); 35 | expect( CleanValue({ type: 'boolean' }, 'yes') ).to.equal(true); 36 | 37 | expect( CleanValue({ type: 'boolean' }, false) ).to.equal(false); 38 | expect( CleanValue({ type: 'boolean' }, null) ).to.equal(false); 39 | expect( CleanValue({ type: 'boolean' }, undefined) ).to.equal(false); 40 | expect( CleanValue({ type: 'boolean' }, 0) ).to.equal(false); 41 | }); 42 | 43 | it('should handle a timestamp', () => { 44 | const input = new Date(); 45 | const expected = input.getTime(); 46 | const output = CleanValue({ type: 'timestamp' }, input); 47 | 48 | expect(output).to.equal(expected); 49 | }); 50 | 51 | describe('Date', () => { 52 | it('should handle a Date', () => { 53 | const input = new Date(); 54 | const output = CleanValue({ type: 'date' }, input); 55 | 56 | expect(output).to.be.an.instanceOf(neo4j.types.Date); 57 | 58 | expect(output.year).to.equal(input.getFullYear()); 59 | expect(output.month).to.equal(input.getMonth()+1); 60 | expect(output.day).to.equal(input.getDate()); 61 | }); 62 | 63 | it('should handle a Date from a timestamp', () => { 64 | const input = new Date; 65 | const output = CleanValue({ type: 'date' }, input.getTime()); 66 | 67 | expect(output).to.be.an.instanceOf(neo4j.types.Date); 68 | 69 | expect(output.year).to.equal(input.getFullYear()); 70 | expect(output.month).to.equal(input.getMonth()+1); 71 | expect(output.day).to.equal(input.getDate()); 72 | }); 73 | }); 74 | 75 | describe('DateTime', () => { 76 | it('should handle a DateTime', () => { 77 | const input = new Date(); 78 | const output = CleanValue({ type: 'datetime' }, input); 79 | 80 | expect(output).to.be.an.instanceOf(neo4j.types.DateTime); 81 | 82 | expect(output.year).to.equal(input.getFullYear()); 83 | expect(output.month).to.equal(input.getMonth()+1); 84 | expect(output.day).to.equal(input.getDate()); 85 | expect(output.hour).to.equal(input.getHours()); 86 | expect(output.minute).to.equal(input.getMinutes()); 87 | expect(output.second).to.equal(input.getSeconds()); 88 | // expect(output.timeZoneOffsetSeconds).to.equal(Math.abs(input.getTimezoneOffset()) * -60); 89 | }); 90 | 91 | it('should handle a DateTime as a timestamp', () => { 92 | const input = new Date(); 93 | const output = CleanValue({ type: 'datetime' }, input.getTime()); 94 | 95 | expect(output).to.be.an.instanceOf(neo4j.types.DateTime); 96 | 97 | expect(output.year).to.equal(input.getFullYear()); 98 | expect(output.month).to.equal(input.getMonth()+1); 99 | expect(output.day).to.equal(input.getDate()); 100 | expect(output.hour).to.equal(input.getHours()); 101 | expect(output.minute).to.equal(input.getMinutes()); 102 | expect(output.second).to.equal(input.getSeconds()); 103 | // expect(output.timeZoneOffsetSeconds).to.equal(Math.abs(input.getTimezoneOffset()) * -60); 104 | }); 105 | 106 | it('should handle a DateTime as text', () => { 107 | const input = new Date(); 108 | const output = CleanValue({ type: 'datetime' }, input.toISOString()); 109 | 110 | expect(output).to.be.an.instanceOf(neo4j.types.DateTime); 111 | 112 | expect(output.year).to.equal(input.getFullYear()); 113 | expect(output.month).to.equal(input.getMonth()+1); 114 | expect(output.day).to.equal(input.getDate()); 115 | expect(output.hour).to.equal(input.getHours()); 116 | expect(output.minute).to.equal(input.getMinutes()); 117 | expect(output.second).to.equal(input.getSeconds()); 118 | // expect(output.timeZoneOffsetSeconds).to.equal(Math.abs(input.getTimezoneOffset()) * -60); 119 | }); 120 | }); 121 | 122 | describe('LocalDateTime', () => { 123 | it('should handle a LocalDateTime', () => { 124 | const input = new Date(); 125 | const output = CleanValue({ type: 'LocalDateTime' }, input); 126 | 127 | expect(output).to.be.an.instanceOf(neo4j.types.LocalDateTime); 128 | 129 | expect(output.year).to.equal(input.getFullYear()); 130 | expect(output.month).to.equal(input.getMonth()+1); 131 | expect(output.day).to.equal(input.getDate()); 132 | expect(output.hour).to.equal(input.getHours()); 133 | expect(output.minute).to.equal(input.getMinutes()); 134 | expect(output.second).to.equal(input.getSeconds()); 135 | }); 136 | 137 | it('should handle a LocalDateTime as a timestamp', () => { 138 | const input = new Date(); 139 | const output = CleanValue({ type: 'LocalDateTime' }, input.getTime()); 140 | 141 | expect(output).to.be.an.instanceOf(neo4j.types.LocalDateTime); 142 | 143 | expect(output.year).to.equal(input.getFullYear()); 144 | expect(output.month).to.equal(input.getMonth()+1); 145 | expect(output.day).to.equal(input.getDate()); 146 | expect(output.hour).to.equal(input.getHours()); 147 | expect(output.minute).to.equal(input.getMinutes()); 148 | expect(output.second).to.equal(input.getSeconds()); 149 | }); 150 | }); 151 | 152 | describe('Time', () => { 153 | it('should handle a Time', () => { 154 | const input = new Date(); 155 | const output = CleanValue({ type: 'time' }, input); 156 | 157 | expect(output).to.be.an.instanceOf(neo4j.types.Time); 158 | 159 | expect(output.hour).to.equal(input.getHours()); 160 | expect(output.minute).to.equal(input.getMinutes()); 161 | expect(output.second).to.equal(input.getSeconds()); 162 | expect(output.nanosecond).to.equal(input.getMilliseconds() * 1000000); 163 | // expect(output.timeZoneOffsetSeconds).to.equal(Math.abs(input.getTimezoneOffset()) * -60); 164 | }); 165 | 166 | it('should handle a Time as a timestamp', () => { 167 | const input = new Date(); 168 | const output = CleanValue({ type: 'time' }, input.getTime()); 169 | 170 | expect(output).to.be.an.instanceOf(neo4j.types.Time); 171 | 172 | expect(output.hour).to.equal(input.getHours()); 173 | expect(output.minute).to.equal(input.getMinutes()); 174 | expect(output.second).to.equal(input.getSeconds()); 175 | expect(output.nanosecond).to.equal(input.getMilliseconds() * 1000000); 176 | // expect(output.timeZoneOffsetSeconds).to.equal(Math.abs(input.getTimezoneOffset()) * -60); 177 | }); 178 | }); 179 | 180 | describe('LocalTime', () => { 181 | it('should handle a LocalTime', () => { 182 | const input = new Date(); 183 | const output = CleanValue({ type: 'localtime' }, input); 184 | 185 | expect(output).to.be.an.instanceOf(neo4j.types.LocalTime); 186 | 187 | expect(output.hour).to.equal(input.getHours()); 188 | expect(output.minute).to.equal(input.getMinutes()); 189 | expect(output.second).to.equal(input.getSeconds()); 190 | expect(output.nanosecond).to.equal(input.getMilliseconds() * 1000000); 191 | }); 192 | 193 | it('should handle a LocalTime', () => { 194 | const input = new Date(); 195 | const output = CleanValue({ type: 'localtime' }, input.getTime()); 196 | 197 | expect(output).to.be.an.instanceOf(neo4j.types.LocalTime); 198 | 199 | expect(output.hour).to.equal(input.getHours()); 200 | expect(output.minute).to.equal(input.getMinutes()); 201 | expect(output.second).to.equal(input.getSeconds()); 202 | expect(output.nanosecond).to.equal(input.getMilliseconds() * 1000000); 203 | }); 204 | }); 205 | 206 | describe('Points', () => { 207 | const config = { type: 'point' }; 208 | 209 | it('should handle a lat, lng', () => { 210 | const input = {latitude: 51.568535, longitude: -1.772232}; 211 | 212 | const output = CleanValue(config, input); 213 | 214 | expect(output.srid).to.equal(4326); 215 | expect(output.y).to.equal(input.latitude); 216 | expect(output.x).to.equal(input.longitude); 217 | }); 218 | 219 | it('should handle a lat, lng, height', () => { 220 | const input = {latitude: 51.568535, longitude: -1.772232, height: 1000}; 221 | 222 | const output = CleanValue(config, input); 223 | 224 | expect(output.srid).to.equal(4979); 225 | expect(output.y).to.equal(input.latitude); 226 | expect(output.x).to.equal(input.longitude); 227 | expect(output.z).to.equal(input.height); 228 | }); 229 | 230 | it('should handle a x, y', () => { 231 | const input = {x: 1, y: 2}; 232 | 233 | const output = CleanValue(config, input); 234 | 235 | expect(output.srid).to.equal(7203); 236 | expect(output.x).to.equal(input.x); 237 | expect(output.y).to.equal(input.y); 238 | }); 239 | 240 | it('should handle a x, y, z', () => { 241 | const input = {x: 1, y: 2, z: 3}; 242 | 243 | const output = CleanValue(config, input); 244 | 245 | expect(output.srid).to.equal(9157); 246 | expect(output.x).to.equal(input.x); 247 | expect(output.y).to.equal(input.y); 248 | expect(output.z).to.equal(input.z); 249 | }); 250 | }); 251 | 252 | }); -------------------------------------------------------------------------------- /src/Services/WriteUtils.js: -------------------------------------------------------------------------------- 1 | import GenerateDefaultValues from './GenerateDefaultValues'; 2 | import Node from '../Node'; 3 | import { valueToCypher } from '../Entity'; 4 | 5 | export const MAX_CREATE_DEPTH = 99; 6 | export const ORIGINAL_ALIAS = 'this'; 7 | 8 | /** 9 | * Split properties into 10 | * 11 | * @param {String} mode 'create' or 'merge' 12 | * @param {Model} model Model to merge on 13 | * @param {Object} properties Map of properties 14 | * @param {Array} merge_on Array of properties explicitly stated to merge on 15 | * @return {Object} { inline, set, on_create, on_match } 16 | */ 17 | function splitProperties(mode, model, properties, merge_on = []) { 18 | const inline = {}; 19 | const set = {}; 20 | const on_create = {}; 21 | const on_match = {}; 22 | 23 | // Calculate Set Properties 24 | model.properties().forEach(property => { 25 | const name = property.name(); 26 | 27 | // Skip if not set 28 | if ( !properties.hasOwnProperty(name) ) { 29 | return; 30 | } 31 | 32 | const value = valueToCypher( property, properties[ name ] ); 33 | 34 | // If mode is create, go ahead and set everything 35 | if ( mode == 'create' ) { 36 | inline[ name ] = value; 37 | } 38 | 39 | else if ( merge_on.indexOf( name ) > -1 ) { 40 | inline[ name ] = value; 41 | } 42 | 43 | // Only set protected properties on creation 44 | else if ( property.protected() || property.primary() ) { 45 | on_create[ name ] = value; 46 | } 47 | 48 | // Read-only property? 49 | else if ( !property.readonly() ) { 50 | set[ name ] = value; 51 | } 52 | }); 53 | 54 | return { 55 | inline, 56 | on_create, 57 | on_match, 58 | set, 59 | }; 60 | } 61 | 62 | 63 | /** 64 | * Add a node to the current statement 65 | * 66 | * @param {Neode} neode Neode instance 67 | * @param {Builder} builder Query builder 68 | * @param {String} alias Alias 69 | * @param {Model} model Model 70 | * @param {Object} properties Map of properties 71 | * @param {Array} aliases Aliases to carry through in with statement 72 | * @param {String} mode 'create' or 'merge' 73 | * @param {Array} merge_on Which properties should we merge on? 74 | */ 75 | export function addNodeToStatement(neode, builder, alias, model, properties, aliases = [], mode = 'create', merge_on = []) { 76 | // Split Properties 77 | const { inline, on_create, on_match, set } = splitProperties(mode, model, properties, merge_on); 78 | 79 | // Add alias 80 | if ( aliases.indexOf(alias) == -1 ) { 81 | aliases.push(alias); 82 | } 83 | 84 | // Create 85 | builder[mode](alias, model, inline); 86 | 87 | // On create set 88 | if ( Object.keys( on_create ).length ) { 89 | Object.entries( on_create ).forEach(( [key, value] ) => { 90 | builder.onCreateSet(`${alias}.${key}`, value); 91 | }); 92 | } 93 | 94 | // On Match Set 95 | if ( Object.keys( on_match ).length ) { 96 | Object.entries( on_match ).forEach(( [key, value] ) => { 97 | builder.onCreateSet(`${alias}.${key}`, value); 98 | }); 99 | } 100 | 101 | // Set 102 | if ( Object.keys( set ).length ) { 103 | Object.entries( set ).forEach(( [key, value] ) => { 104 | builder.set(`${alias}.${key}`, value); 105 | }); 106 | } 107 | 108 | // Relationships 109 | model.relationships().forEach((relationship, key) => { 110 | if ( properties.hasOwnProperty(key) ) { 111 | let value = properties[ key ]; 112 | 113 | const rel_alias = `${alias}_${key}_rel`; 114 | const target_alias = `${alias}_${key}_node`; 115 | 116 | // Carry alias through 117 | builder.with(...aliases); 118 | 119 | if ( ! relationship.target() ) { 120 | throw new Error(`A target defintion must be defined for ${key} on model ${model.name()}`); 121 | } 122 | else if ( Array.isArray( relationship.target() ) ) { 123 | throw new Error(`You cannot create a node with the ambiguous relationship: ${key} on model ${model.name()}`); 124 | } 125 | 126 | switch ( relationship.type() ) { 127 | // Single Relationship 128 | case 'relationship': 129 | addRelationshipToStatement(neode, builder, alias, rel_alias, target_alias, relationship, value, aliases, mode); 130 | break; 131 | 132 | // Array of Relationships 133 | case 'relationships': 134 | if ( !Array.isArray(value) ) value = [ value ]; 135 | 136 | value.forEach((value, idx) => { 137 | // Carry alias through 138 | addRelationshipToStatement(neode, builder, alias, rel_alias + idx, target_alias + idx, relationship, value, aliases, mode); 139 | }); 140 | break; 141 | 142 | // Single Node 143 | case 'node': 144 | addNodeRelationshipToStatement(neode, builder, alias, rel_alias, target_alias, relationship, value, aliases, mode); 145 | break; 146 | 147 | // Array of Nodes 148 | case 'nodes': 149 | if ( !Array.isArray(value) ) value = [ value ]; 150 | 151 | value.forEach((value, idx) => { 152 | addNodeRelationshipToStatement(neode, builder, alias, rel_alias + idx, target_alias + idx, relationship, value, aliases, mode); 153 | }); 154 | break; 155 | } 156 | } 157 | }); 158 | 159 | return builder; 160 | } 161 | 162 | /** 163 | * Add a relationship to the current statement 164 | * 165 | * @param {Neode} neode Neode instance 166 | * @param {Builder} builder Query builder 167 | * @param {String} alias Current node alias 168 | * @param {String} rel_alias Generated alias for the relationship 169 | * @param {String} target_alias Generated alias for the relationship 170 | * @param {Relationship} relationship Model 171 | * @param {Object} value Value map 172 | * @param {Array} aliases Aliases to carry through in with statement 173 | * @param {String} mode 'create' or 'merge' 174 | */ 175 | export function addRelationshipToStatement(neode, builder, alias, rel_alias, target_alias, relationship, value, aliases, mode) { 176 | if ( aliases.length > MAX_CREATE_DEPTH ) { 177 | return; 178 | } 179 | 180 | // Extract Node 181 | const node_alias = relationship.nodeAlias(); 182 | let node_value = value[ node_alias ]; 183 | 184 | delete value[ node_alias ]; 185 | 186 | // Create Node 187 | 188 | // If Node is passed, attempt to create a relationship to that specific node 189 | if ( node_value instanceof Node ) { 190 | builder.match(target_alias) 191 | .whereId(target_alias, node_value.identity()); 192 | } 193 | 194 | // If Primary key is passed then try to match on that 195 | else if ( typeof node_value == 'string' || typeof node_value == 'number' ) { 196 | const model = neode.model( relationship.target() ); 197 | 198 | builder.merge(target_alias, model, { 199 | [ model.primaryKey() ]: node_value 200 | }); 201 | } 202 | 203 | // If Map is passed, attempt to create that node and then relate 204 | else if ( Object.keys(node_value).length ) { 205 | const model = neode.model( relationship.target() ); 206 | 207 | if ( !model ) { 208 | throw new Error(`Couldn't find a target model for ${relationship.target()} in ${relationship.name()}. Did you use module.exports?`); 209 | } 210 | 211 | node_value = GenerateDefaultValues.async(neode, model, node_value); 212 | 213 | addNodeToStatement(neode, builder, target_alias, model, node_value, aliases, mode, model.mergeFields()); 214 | } 215 | 216 | // Create the Relationship 217 | builder[mode](alias) 218 | .relationship( relationship.relationship(), relationship.direction(), rel_alias ) 219 | .to(target_alias); 220 | 221 | // Set Relationship Properties 222 | relationship.properties().forEach(property => { 223 | const name = property.name(); 224 | 225 | if ( value.hasOwnProperty( name ) ) { 226 | builder.set(`${rel_alias}.${name}`, value[ name ] ); 227 | } 228 | }); 229 | } 230 | 231 | /** 232 | * Add a node relationship to the current statement 233 | * 234 | * @param {Neode} neode Neode instance 235 | * @param {Builder} builder Query builder 236 | * @param {String} alias Current node alias 237 | * @param {String} rel_alias Generated alias for the relationship 238 | * @param {String} target_alias Generated alias for the relationship 239 | * @param {Relationship} relationship Model 240 | * @param {Object} value Value map 241 | * @param {Array} aliases Aliases to carry through in with statement 242 | * @param {String} mode 'create' or 'merge' 243 | */ 244 | export function addNodeRelationshipToStatement(neode, builder, alias, rel_alias, target_alias, relationship, value, aliases, mode) { 245 | if ( aliases.length > MAX_CREATE_DEPTH ) { 246 | return; 247 | } 248 | 249 | // If Node is passed, attempt to create a relationship to that specific node 250 | if ( value instanceof Node ) { 251 | builder.match(target_alias) 252 | .whereId(target_alias, value.identity()); 253 | } 254 | // If Primary key is passed then try to match on that 255 | else if ( typeof value == 'string' || typeof value == 'number' ) { 256 | const model = neode.model( relationship.target() ); 257 | 258 | builder.merge(target_alias, model, { 259 | [ model.primaryKey() ]: value 260 | }); 261 | } 262 | // If Map is passed, attempt to create that node and then relate 263 | // TODO: What happens when we need to validate this? 264 | // TODO: Is mergeFields() the right option here? 265 | else if ( Object.keys(value).length ) { 266 | const model = neode.model( relationship.target() ); 267 | 268 | if ( !model ) { 269 | throw new Error(`Couldn't find a target model for ${relationship.target()} in ${relationship.name()}. Did you use module.exports?`); 270 | } 271 | 272 | value = GenerateDefaultValues.async(neode, model, value); 273 | 274 | addNodeToStatement(neode, builder, target_alias, model, value, aliases, mode, model.mergeFields()); 275 | } 276 | 277 | // Create the Relationship 278 | builder[mode](alias) 279 | .relationship( relationship.relationship(), relationship.direction(), rel_alias ) 280 | .to(target_alias); 281 | } -------------------------------------------------------------------------------- /src/Services/Validator.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-case-declarations */ 2 | import Joi from '@hapi/joi'; 3 | import Model from '../Model'; 4 | import Node from '../Node'; 5 | import RelationshipType, { DEFAULT_ALIAS } from '../RelationshipType'; 6 | import ValidationError from '../ValidationError'; 7 | import neo4j from 'neo4j-driver'; 8 | 9 | const joi_options = { 10 | allowUnknown:true, 11 | abortEarly:false 12 | }; 13 | 14 | // TODO: Move these to constants and validate the model schemas a bit better 15 | const ignore = [ 16 | 'labels', 17 | 'type', 18 | 'default', 19 | 'alias', 20 | 'properties', 21 | 'primary', 22 | 'relationship', 23 | 'target', 24 | 'direction', 25 | 'eager', 26 | 'hidden', 27 | 'readonly', 28 | 'index', 29 | 'unique', 30 | 'cascade', 31 | ]; 32 | const booleans = [ 33 | 'optional', 34 | 'forbidden', 35 | 'strip', 36 | 'positive', 37 | 'negative', 38 | 'port', 39 | 'integer', 40 | 'iso', 41 | 'isoDate', 42 | 'insensitive', 43 | 'required', 44 | 'truncate', 45 | 'creditCard', 46 | 'alphanum', 47 | 'token', 48 | 'hex', 49 | 'hostname', 50 | 'lowercase', 51 | 'uppercase', 52 | ]; 53 | const booleanOrOptions = [ 54 | 'email', 55 | 'ip', 56 | 'uri', 57 | 'base64', 58 | 'normalize', 59 | 'hex', 60 | ]; 61 | 62 | const temporal = Joi.extend({ 63 | base: Joi.object(), 64 | name: 'temporal', 65 | language: { 66 | before: 'Value before minimum expected value', 67 | after: 'Value after minimum expected value', 68 | }, 69 | rules: [ 70 | { 71 | name: 'after', 72 | params: { 73 | after: Joi.alternatives([ 74 | Joi.date(), 75 | Joi.string(), 76 | ]), 77 | }, 78 | validate(params, value, state, options) { 79 | if ( params.after === 'now' ) { 80 | params.after = new Date(); 81 | } 82 | 83 | if ( params.after > new Date( value.toString()) ) { 84 | return this.createError('temporal.after', { v: value }, state, options); 85 | } 86 | 87 | return value; 88 | }, 89 | }, 90 | { 91 | name: 'before', 92 | params: { 93 | after: Joi.alternatives([ 94 | Joi.date(), 95 | Joi.string(), 96 | ]), 97 | }, 98 | validate(params, value, state, options) { 99 | if ( params.after === 'now' ) { 100 | params.after = new Date(); 101 | } 102 | 103 | if ( params.after < new Date( value.toString()) ) { 104 | return this.createError('temporal.after', { v: value }, state, options); 105 | } 106 | 107 | return value; 108 | }, 109 | }, 110 | ], 111 | }); 112 | 113 | // TODO: Ugly 114 | const neoInteger = Joi.extend({ 115 | // base: Joi.number(), 116 | base: Joi.alternatives().try([ Joi.number().integer(), Joi.object().type(neo4j.types.Integer) ]), 117 | name: 'integer', 118 | language: { 119 | before: 'Value before minimum expected value', 120 | after: 'Value after minimum expected value', 121 | }, 122 | rules: [ 123 | { 124 | name: 'min', 125 | params: { 126 | min: Joi.number(), 127 | }, 128 | validate(params, value, state, options) { 129 | const compare = value instanceof neo4j.types.Integer ? value.toNumber() : value; 130 | 131 | if ( params.min > compare ) { 132 | return this.createError('number.min', { limit: params.min, }, state, options); 133 | } 134 | 135 | return value; 136 | } 137 | }, 138 | { 139 | name: 'max', 140 | params: { 141 | max: Joi.number(), 142 | }, 143 | validate(params, value, state, options) { 144 | const compare = value instanceof neo4j.types.Integer ? value.toNumber() : value; 145 | 146 | if ( params.max < compare ) { 147 | return this.createError('number.max', { limit: params.max, }, state, options); 148 | } 149 | 150 | return value; 151 | } 152 | }, 153 | { 154 | name: 'multiple', 155 | params: { 156 | multiple: Joi.number(), 157 | }, 158 | validate(params, value, state, options) { 159 | const compare = value instanceof neo4j.types.Integer ? value.toNumber() : value; 160 | 161 | if ( compare % params.multiple != 0 ) { 162 | return this.createError('number.multiple', { multiple: params.max, }, state, options); 163 | } 164 | 165 | return value; 166 | } 167 | }, 168 | ] 169 | }); 170 | 171 | const point = Joi.extend({ 172 | base: Joi.object().type(neo4j.types.Point), 173 | name: 'point', 174 | }); 175 | 176 | function nodeSchema() { 177 | return Joi.alternatives([ 178 | Joi.object().type(Node), 179 | Joi.string(), 180 | Joi.number(), 181 | Joi.object(), 182 | ]); 183 | } 184 | 185 | function relationshipSchema(alias, properties = {}) { 186 | return Joi.object().keys(Object.assign( 187 | {}, 188 | { 189 | [ alias ]: nodeSchema().required(), 190 | }, 191 | BuildValidationSchema(properties) 192 | )); 193 | } 194 | 195 | function BuildValidationSchema(schema) { 196 | if ( schema instanceof Model || schema instanceof RelationshipType ) { 197 | schema = schema.schema(); 198 | } 199 | 200 | let output = {}; 201 | 202 | Object.keys(schema).forEach(key => { 203 | // Ignore Labels 204 | if ( key == 'labels' ) return; 205 | 206 | const config = typeof schema[ key ] == 'string' ? {type: schema[ key ]} : schema[ key ]; 207 | 208 | let validation = false; 209 | 210 | switch (config.type) { 211 | 212 | // TODO: Recursive creation, validate nodes and relationships 213 | case 'node': 214 | validation = nodeSchema(); 215 | break; 216 | 217 | case 'nodes': 218 | validation = Joi.array().items(nodeSchema()); 219 | break; 220 | 221 | case 'relationship': 222 | // TODO: Clean up... This should probably be an object 223 | validation = relationshipSchema(config.alias || DEFAULT_ALIAS, config.properties); 224 | 225 | break; 226 | 227 | case 'relationships': 228 | validation = Joi.array().items( 229 | relationshipSchema(config.alias || DEFAULT_ALIAS, config.properties) 230 | ); 231 | break; 232 | 233 | case 'uuid': 234 | validation = Joi.string().guid({ version: 'uuidv4' }); 235 | break; 236 | 237 | case 'string': 238 | case 'number': 239 | case 'boolean': 240 | validation = Joi[ config.type ](); 241 | break; 242 | 243 | case 'datetime': 244 | validation = temporal.temporal().type(neo4j.types.DateTime); 245 | break; 246 | 247 | case 'date': 248 | validation = temporal.temporal().type(neo4j.types.Date); 249 | break; 250 | 251 | case 'time': 252 | validation = temporal.temporal().type(neo4j.types.Time); 253 | break; 254 | 255 | case 'localdate': 256 | validation = temporal.temporal().type(neo4j.types.LocalDate); 257 | break; 258 | 259 | case 'localtime': 260 | validation = temporal.temporal().type(neo4j.types.LocalTime); 261 | break; 262 | 263 | case 'point': 264 | validation = point.point().type(neo4j.types.Point); 265 | break; 266 | 267 | case 'int': 268 | case 'integer': 269 | validation = neoInteger.integer(); 270 | break; 271 | 272 | case 'float': 273 | validation = Joi.number(); 274 | break; 275 | 276 | default: 277 | validation = Joi.any(); 278 | break; 279 | } 280 | 281 | if ( ! config.required ) { 282 | validation = validation.allow(null); 283 | } 284 | 285 | // Apply additional Validation 286 | Object.keys(config).forEach(validator => { 287 | const options = config[validator]; 288 | 289 | if ( validator == 'regex' ) { 290 | if ( options instanceof RegExp ) { 291 | validation = validation.regex(options); 292 | } 293 | else { 294 | const pattern = options.pattern; 295 | delete options.pattern; 296 | 297 | validation = validation.regex(pattern, options); 298 | } 299 | } 300 | else if ( validator == 'replace' ) { 301 | validation = validation.replace(options.pattern, options.replace); 302 | } 303 | else if ( booleanOrOptions.indexOf(validator) > -1 ) { 304 | if ( typeof options == 'object' ) { 305 | validation = validation[ validator ](options); 306 | } 307 | else if ( options ) { 308 | validation = validation[ validator ](); 309 | } 310 | } 311 | else if ( booleans.indexOf(validator) > -1 ) { 312 | if ( options === true ) { 313 | validation = validation[ validator ](options); 314 | } 315 | } 316 | else if (ignore.indexOf(validator) == -1 && validation[validator]) { 317 | validation = validation[validator](options); 318 | } 319 | else if (ignore.indexOf(validator) == -1 && booleans.indexOf(validator) == -1 ) { 320 | throw new Error(`Not sure how to validate ${validator} on ${key}`); 321 | } 322 | }); 323 | 324 | output[ key ] = validation; 325 | }); 326 | 327 | return output; 328 | } 329 | 330 | /** 331 | * Run Validation 332 | * 333 | * TODO: Recursive Validation 334 | * 335 | * @param {Neode} neode 336 | * @param {Model} model 337 | * @param {Object} properties 338 | * @return {Promise} 339 | */ 340 | export default function Validator(neode, model, properties) { 341 | const schema = BuildValidationSchema(model, properties); 342 | 343 | return new Promise((resolve, reject) => { 344 | Joi.validate(properties, schema, joi_options, (err, validated) => { 345 | if (err) { 346 | return reject( new ValidationError(err.details, properties, err) ); 347 | } 348 | 349 | return resolve(validated); 350 | }); 351 | }); 352 | } -------------------------------------------------------------------------------- /test/Factory.spec.js: -------------------------------------------------------------------------------- 1 | import {assert, expect} from 'chai'; 2 | import Collection from '../src/Collection'; 3 | import Factory from '../src/Factory'; 4 | import Model from '../src/Model'; 5 | import Node from '../src/Node'; 6 | import Relationship from '../src/Relationship'; 7 | import { EAGER_ID, EAGER_LABELS, EAGER_TYPE, eagerNode, } from '../src/Query/EagerUtils'; 8 | import neo4j from 'neo4j-driver'; 9 | import RelationshipType from '../src/RelationshipType'; 10 | 11 | describe('Factory.js', () => { 12 | let instance; 13 | let factory; 14 | let model; 15 | let alt_model; 16 | 17 | before(done => { 18 | instance = require('./instance')(); 19 | factory = new Factory(instance); 20 | 21 | model = instance.model('FactoryTest', { 22 | id: 'number' 23 | }); 24 | 25 | alt_model = instance.model('AnotherFactoryTest', { 26 | id: 'number', 27 | relationship: { 28 | type: 'relationship', 29 | relationship: 'RELATIONSHIP', 30 | target: 'AnotherFactoryTest', 31 | direction: 'out', 32 | 33 | eager: true, 34 | 35 | properties: { 36 | prop: 'float', 37 | }, 38 | }, 39 | relationships: { 40 | type: 'relationships', 41 | relationship: 'RELATIONSHIPS', 42 | target: 'AnotherFactoryTest', 43 | alias: 'alias', 44 | direction: 'in', 45 | 46 | eager: true, 47 | }, 48 | node: { 49 | type: 'node', 50 | relationship: 'NODE', 51 | target: 'AnotherFactoryTest', 52 | direction: 'out', 53 | 54 | eager: true, 55 | }, 56 | nodes: { 57 | type: 'nodes', 58 | relationship: 'NODES', 59 | target: 'AnotherFactoryTest', 60 | direction: 'in', 61 | 62 | eager: true, 63 | }, 64 | }); 65 | 66 | Promise.all([ 67 | instance.create('FactoryTest', { id: 1 }), 68 | instance.create('FactoryTest', { id: 2 }) 69 | ]) 70 | .then(() => done()) 71 | .catch(e => done(e)); 72 | }); 73 | 74 | after(done => { 75 | instance.deleteAll('FactoryTest') 76 | .then(() => instance.close()) 77 | .then(() => done()) 78 | .catch(e => done(e)); 79 | }); 80 | 81 | describe('::getDefinition', () => { 82 | it('should get a model definition based on an array of labels', () => { 83 | const output = factory.getDefinition(['FactoryTest']); 84 | 85 | expect(output).to.be.an.instanceOf(Model); 86 | }); 87 | 88 | it('should return false when definition not found', () => { 89 | const output = factory.getDefinition(['Unknown']); 90 | 91 | expect(output).to.equal(false); 92 | }); 93 | }); 94 | 95 | describe('::hydrateFirst', () => { 96 | it('should return false on invalid result', () => { 97 | expect( factory.hydrateFirst(false) ).to.equal(false); 98 | }); 99 | 100 | it('should return false on empty result', () => { 101 | expect( factory.hydrateFirst({ records: [] }) ).to.equal(false); 102 | }); 103 | 104 | it('should hydrate alias from first result', done => { 105 | instance.cypher(` 106 | MATCH (n:FactoryTest) 107 | RETURN n { 108 | .*, 109 | ${EAGER_ID}: id(n), 110 | ${EAGER_LABELS}: labels(n) 111 | } ORDER BY n.id ASC LIMIT 1 112 | `) 113 | .then(res => { 114 | return factory.hydrateFirst(res, 'n'); 115 | }) 116 | .then(res => { 117 | expect( res ).to.be.an.instanceOf( Node ); 118 | expect( res._model ).to.equal( model ); 119 | 120 | expect( res.get('id') ).to.equal(1); 121 | 122 | }) 123 | .then(() => done()) 124 | .catch(e => done(e)) 125 | }); 126 | 127 | it('should hydrate alias from first result with specific model definition', done => { 128 | instance.cypher(` 129 | MATCH (n:FactoryTest) 130 | RETURN n { 131 | .*, 132 | ${EAGER_ID}: id(n), 133 | ${EAGER_LABELS}: labels(n) 134 | } ORDER BY n.id ASC LIMIT 1 135 | `) 136 | .then(res => { 137 | return factory.hydrateFirst(res, 'n', alt_model); 138 | }) 139 | .then(res => { 140 | expect( res ).to.be.an.instanceOf( Node ); 141 | expect( res._model ).to.equal( alt_model ); 142 | 143 | expect( res.get('id') ).to.equal(1); 144 | 145 | }) 146 | .then(() => done()) 147 | .catch(e => done(e)) 148 | }); 149 | 150 | }); 151 | 152 | describe('::hydrate', () => { 153 | it('should return false on invalid result', () => { 154 | expect( factory.hydrate(false) ).to.equal(false); 155 | }); 156 | 157 | it('should return an empty node collection', () => { 158 | const output = factory.hydrate({ records: [] }); 159 | 160 | expect( output ).to.be.an.instanceOf(Collection); 161 | expect( output.length ).to.equal(0); 162 | }); 163 | 164 | it('should hydrate alias', done => { 165 | instance.cypher(` 166 | MATCH (n:FactoryTest) 167 | RETURN n { 168 | .*, 169 | ${EAGER_ID}: id(n), 170 | ${EAGER_LABELS}: labels(n) 171 | } ORDER BY n.id ASC 172 | `) 173 | .then(res => { 174 | return factory.hydrate(res, 'n'); 175 | }) 176 | .then(res => { 177 | expect( res ).to.be.an.instanceOf(Collection); 178 | expect( res.length ).to.equal(2); 179 | 180 | expect( res.get(0).get('id') ).to.equal(1); 181 | expect( res.get(1).get('id') ).to.equal(2); 182 | 183 | expect( res.get(0) ).to.be.an.instanceOf( Node ); 184 | 185 | }) 186 | .then(() => done()) 187 | .catch(e => done(e)) 188 | }); 189 | 190 | it('should hydrate alias from first result with specific model definition', done => { 191 | instance.cypher(` 192 | MATCH (n:FactoryTest) 193 | RETURN n { 194 | .*, 195 | ${EAGER_ID}: id(n), 196 | ${EAGER_LABELS}: labels(n) 197 | } ORDER BY n.id ASC 198 | `) 199 | .then(res => { 200 | return factory.hydrate(res, 'n', alt_model); 201 | }) 202 | .then(res => { 203 | expect( res ).to.be.an.instanceOf(Collection); 204 | expect( res.length ).to.equal(2); 205 | 206 | expect( res.get(0).get('id') ).to.equal(1); 207 | expect( res.get(0)._model ).to.equal(alt_model); 208 | expect( res.get(1).get('id') ).to.equal(2); 209 | expect( res.get(1)._model ).to.equal(alt_model); 210 | 211 | }) 212 | .then(() => done()) 213 | .catch(e => done(e)); 214 | }); 215 | 216 | it('should hydrate a node and eager relationships', done => { 217 | instance.cypher(` 218 | CREATE (t:AnotherFactoryTest { id: 3 }) 219 | CREATE (t)-[:RELATIONSHIP { prop: 1.234 }]->(:AnotherFactoryTest {id: 4}) 220 | CREATE (t)<-[:RELATIONSHIPS]-(:AnotherFactoryTest {id: 5}) 221 | CREATE (t)-[:NODE]->(:AnotherFactoryTest {id: 6}) 222 | CREATE (t)<-[:NODES]-(:AnotherFactoryTest {id: 7}) 223 | 224 | RETURN ${eagerNode(instance, 3, 't', alt_model)} 225 | `) 226 | .then(res => { 227 | return factory.hydrate(res, 't') 228 | }) 229 | .then(res => { 230 | expect( res.length ).to.equal(1) 231 | 232 | const node = res.get(0); 233 | 234 | // Correctly hydrated node? 235 | expect( node ).to.be.an.instanceOf(Node); 236 | expect( node.get('id').toNumber() ).to.equal(3); 237 | 238 | // Outgoing Relationship 239 | const relationship = node.get('relationship'); 240 | expect( relationship ).to.be.an.instanceOf(Relationship); 241 | 242 | expect( relationship.type() ).to.equal('RELATIONSHIP'); 243 | expect( relationship.definition() ).to.be.an.instanceOf(RelationshipType); 244 | 245 | expect( relationship.startNode().get('id').toNumber() ).to.equal(3); 246 | expect( relationship.endNode().get('id').toNumber() ).to.equal(4); 247 | expect( relationship.otherNode().get('id').toNumber() ).to.equal(4); 248 | 249 | expect( relationship.get('prop') ).to.equal(1.234); 250 | 251 | // Incoming Relationships 252 | expect( node.get('relationships') ).to.be.an.instanceOf(Collection); 253 | 254 | expect( node.get('relationships').first().startNode().get('id').toNumber() ).to.equal(5); 255 | expect( node.get('relationships').first().endNode().get('id').toNumber() ).to.equal(3); 256 | expect( node.get('relationships').first().otherNode().get('id').toNumber() ).to.equal(5); 257 | 258 | // Outgoing Node 259 | expect( node.get('node') ).to.be.an.instanceOf(Node); 260 | expect( node.get('node').get('id').toNumber() ).to.equal(6); 261 | 262 | // Incoming Nodes 263 | expect( node.get('nodes') ).to.be.an.instanceOf(Collection); 264 | expect( node.get('nodes').first().get('id').toNumber() ).to.equal(7); 265 | 266 | return relationship.toJson(); 267 | }) 268 | .then(json => { 269 | expect(json).to.deep.include({ 270 | _type: 'RELATIONSHIP', 271 | prop: 1.234, 272 | }); 273 | 274 | expect(json.node).to.deep.include({ 275 | id: 4, 276 | }); 277 | }) 278 | .then(() => { 279 | return instance.cypher(`MATCH (n:AnotherFactoryTest) WHERE n.id IN [3, 4, 5, 6, 7] DETACH DELETE n`); 280 | }) 281 | .then(() => done()) 282 | .catch(e => done(e)) 283 | 284 | }); 285 | 286 | it('should convert and hydrate a native node', done => { 287 | instance.cypher(`CREATE (t:AnotherFactoryTest { id: 8 }) RETURN t`) 288 | .then(res => { 289 | return factory.hydrate(res, 't') 290 | }) 291 | .then(output => { 292 | expect( output ).to.be.an.instanceOf(Collection); 293 | expect( output.length ).to.equal(1); 294 | 295 | const first = output.first(); 296 | 297 | expect( first ).to.be.an.instanceOf(Node); 298 | expect( first.model() ).be.an.instanceOf(Model); 299 | expect( first.model().name() ).to.equal('AnotherFactoryTest'); 300 | expect( first.get('id').toNumber() ).to.equal(8); 301 | 302 | return first.delete(); 303 | }) 304 | .then(() => done()) 305 | .catch(e => done(e)); 306 | }); 307 | }); 308 | }); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import neo4j from 'neo4j-driver'; 4 | import Factory from './Factory'; 5 | import Model from './Model'; 6 | import ModelMap from './ModelMap'; 7 | import Schema from './Schema'; 8 | import TransactionError from './TransactionError'; 9 | import Builder from './Query/Builder'; 10 | import Collection from './Collection'; 11 | 12 | export default class Neode { 13 | 14 | /** 15 | * Constructor 16 | * 17 | * @param {String} connection_string 18 | * @param {String} username 19 | * @param {String} password 20 | * @param {Bool} enterprise 21 | * @param {String} database 22 | * @param {Object} config 23 | * @return {Neode} 24 | */ 25 | constructor(connection_string, username, password, enterprise = false, database = undefined, config = {}) { 26 | const auth = username && password ? neo4j.auth.basic(username, password) : null; 27 | this.driver = new neo4j.driver(connection_string, auth, config); 28 | this.models = new ModelMap(this); 29 | this.schema = new Schema(this); 30 | this.factory = new Factory(this); 31 | 32 | this.database = database; 33 | 34 | this.setEnterprise(enterprise); 35 | } 36 | 37 | /** 38 | * @static 39 | * Generate Neode instance using .env configuration 40 | * 41 | * @return {Neode} 42 | */ 43 | static fromEnv() { 44 | require('dotenv').config(); 45 | 46 | const connection_string = `${process.env.NEO4J_PROTOCOL}://${process.env.NEO4J_HOST}:${process.env.NEO4J_PORT}`; 47 | const username = process.env.NEO4J_USERNAME; 48 | const password = process.env.NEO4J_PASSWORD; 49 | const enterprise = process.env.NEO4J_ENTERPRISE === 'true'; 50 | 51 | // Multi-database 52 | const database = process.env.NEO4J_DATABASE || 'neo4j'; 53 | 54 | // Build additional config 55 | const config = {}; 56 | 57 | const settings = { 58 | NEO4J_ENCRYPTION: 'encrypted', 59 | NEO4J_TRUST: 'trust', 60 | NEO4J_TRUSTED_CERTIFICATES: 'trustedCertificates', 61 | NEO4J_KNOWN_HOSTS: 'knownHosts', 62 | 63 | NEO4J_MAX_CONNECTION_POOLSIZE: 'maxConnectionPoolSize', 64 | NEO4J_MAX_TRANSACTION_RETRY_TIME: 'maxTransactionRetryTime', 65 | NEO4J_LOAD_BALANCING_STRATEGY: 'loadBalancingStrategy', 66 | NEO4J_MAX_CONNECTION_LIFETIME: 'maxConnectionLifetime', 67 | NEO4J_CONNECTION_TIMEOUT: 'connectionTimeout', 68 | NEO4J_DISABLE_LOSSLESS_INTEGERS: 'disableLosslessIntegers', 69 | NEO4J_LOGGING_LEVEL: 'logging', 70 | }; 71 | 72 | Object.keys(settings).forEach(setting => { 73 | if ( process.env.hasOwnProperty(setting) ) { 74 | const key = settings[ setting ]; 75 | let value = process.env[ setting ]; 76 | 77 | if ( key == "trustedCertificates" ) { 78 | value = value.split(','); 79 | } 80 | else if ( key == "disableLosslessIntegers" ) { 81 | value = value === 'true'; 82 | } 83 | 84 | config[ key ] = value; 85 | } 86 | }); 87 | 88 | return new Neode(connection_string, username, password, enterprise, database, config); 89 | } 90 | 91 | /** 92 | * Define multiple models 93 | * 94 | * @param {Object} models Map of models with their schema. ie {Movie: {...}} 95 | * @return {Neode} 96 | */ 97 | with(models) { 98 | Object.keys(models).forEach(model => { 99 | this.model(model, models[ model ]); 100 | }); 101 | 102 | return this; 103 | } 104 | 105 | /** 106 | * Scan a directory for Models 107 | * 108 | * @param {String} directory Directory to scan 109 | * @return {Neode} 110 | */ 111 | withDirectory(directory) { 112 | const files = fs.readdirSync(directory); 113 | 114 | files.filter(file => path.extname(file).toLowerCase() === '.js') 115 | .forEach(file => { 116 | const model = file.replace('.js', ''); 117 | const path = directory +'/'+ file; 118 | const schema = require("" + path); 119 | 120 | return this.model(model, schema); 121 | }); 122 | 123 | return this; 124 | } 125 | 126 | /** 127 | * Set the default database for all future connections 128 | * 129 | * @param {String} database 130 | */ 131 | setDatabase(database) { 132 | this.database = database; 133 | } 134 | 135 | /** 136 | * Set Enterprise Mode 137 | * 138 | * @param {Bool} enterprise 139 | */ 140 | setEnterprise(enterprise) { 141 | this._enterprise = enterprise; 142 | } 143 | 144 | /** 145 | * Are we running in enterprise mode? 146 | * 147 | * @return {Bool} 148 | */ 149 | enterprise() { 150 | return this._enterprise; 151 | } 152 | 153 | /** 154 | * Define a new Model 155 | * 156 | * @param {String} name 157 | * @param {Object} schema 158 | * @return {Model} 159 | */ 160 | model(name, schema) { 161 | if ( schema instanceof Object) { 162 | const model = new Model(this, name, schema); 163 | this.models.set(name, model); 164 | } 165 | 166 | if ( !this.models.has(name) ) { 167 | const defined = this.models.keys(); 168 | 169 | let message = `Couldn't find a definition for "${name}".`; 170 | 171 | if ( defined.length == 0 ) { 172 | message += ' It looks like no models have been defined.'; 173 | } 174 | else { 175 | message += ` The models currently defined are [${ defined.join(', ') }]`; 176 | } 177 | 178 | throw new Error(message); 179 | } 180 | 181 | return this.models.get(name); 182 | } 183 | 184 | /** 185 | * Extend a model with extra configuration 186 | * 187 | * @param {String} name Original Model to clone 188 | * @param {String} as New Model name 189 | * @param {Object} using Schema changes 190 | * @return {Model} 191 | */ 192 | extend(model, as, using) { 193 | return this.models.extend(model, as, using); 194 | } 195 | 196 | /** 197 | * Create a new Node of a type 198 | * 199 | * @param {String} model 200 | * @param {Object} properties 201 | * @return {Node} 202 | */ 203 | create(model, properties) { 204 | return this.models.get(model).create(properties); 205 | } 206 | 207 | /** 208 | * Merge a node based on the defined indexes 209 | * 210 | * @param {Object} properties 211 | * @return {Promise} 212 | */ 213 | merge(model, properties) { 214 | return this.model(model).merge(properties); 215 | } 216 | 217 | /** 218 | * Merge a node based on the supplied properties 219 | * 220 | * @param {Object} match Specific properties to merge on 221 | * @param {Object} set Properties to set 222 | * @return {Promise} 223 | */ 224 | mergeOn(model, match, set) { 225 | return this.model(model).mergeOn(match, set); 226 | } 227 | 228 | /** 229 | * Delete a Node from the graph 230 | * 231 | * @param {Node} node 232 | * @return {Promise} 233 | */ 234 | delete(node) { 235 | return node.delete(); 236 | } 237 | 238 | /** 239 | * Delete all node labels 240 | * 241 | * @param {String} label 242 | * @return {Promise} 243 | */ 244 | deleteAll(model) { 245 | return this.models.get(model).deleteAll(); 246 | } 247 | 248 | /** 249 | * Relate two nodes based on the type 250 | * 251 | * @param {Node} from Origin node 252 | * @param {Node} to Target node 253 | * @param {String} type Type of Relationship definition 254 | * @param {Object} properties Properties to set against the relationships 255 | * @param {Boolean} force_create Force the creation a new relationship? If false, the relationship will be merged 256 | * @return {Promise} 257 | */ 258 | relate(from, to, type, properties, force_create = false) { 259 | return from.relateTo(to, type, properties, force_create); 260 | } 261 | 262 | /** 263 | * Run an explicitly defined Read query 264 | * 265 | * @param {String} query 266 | * @param {Object} params 267 | * @return {Promise} 268 | */ 269 | readCypher(query, params) { 270 | const session = this.readSession(); 271 | 272 | return this.cypher(query, params, session); 273 | } 274 | 275 | /** 276 | * Run an explicitly defined Write query 277 | * 278 | * @param {String} query 279 | * @param {Object} params 280 | * @return {Promise} 281 | */ 282 | writeCypher(query, params) { 283 | const session = this.writeSession(); 284 | 285 | return this.cypher(query, params, session); 286 | } 287 | 288 | /** 289 | * Run a Cypher query 290 | * 291 | * @param {String} query 292 | * @param {Object} params 293 | * @return {Promise} 294 | */ 295 | cypher(query, params, session = false) { 296 | // If single run, open a new session 297 | const single = !session; 298 | if ( single ) { 299 | session = this.session(); 300 | } 301 | 302 | return session.run(query, params) 303 | .then(res => { 304 | if ( single ) { 305 | session.close(); 306 | } 307 | 308 | return res; 309 | }) 310 | .catch(err => { 311 | if ( single ) { 312 | session.close(); 313 | } 314 | 315 | err.query = query; 316 | err.params = params; 317 | 318 | throw err; 319 | }); 320 | } 321 | 322 | /** 323 | * Create a new Session in the Neo4j Driver. 324 | * 325 | * @param {String} database 326 | * @return {Session} 327 | */ 328 | session(database = this.database) { 329 | return this.readSession(database); 330 | } 331 | 332 | /** 333 | * Create an explicit Read Session 334 | * 335 | * @param {String} database 336 | * @return {Session} 337 | */ 338 | readSession(database = this.database) { 339 | return this.driver.session({ 340 | database, 341 | defaultAccessMode: neo4j.session.READ, 342 | }); 343 | } 344 | 345 | /** 346 | * Create an explicit Write Session 347 | * 348 | * @param {String} database 349 | * @return {Session} 350 | */ 351 | writeSession(database = this.database) { 352 | return this.driver.session({ 353 | database, 354 | defaultAccessMode: neo4j.session.WRITE, 355 | }); 356 | } 357 | 358 | /** 359 | * Create a new Transaction 360 | * 361 | * @return {Transaction} 362 | */ 363 | transaction(mode = neo4j.WRITE, database = this.database) { 364 | const session = this.driver.session(database); 365 | const tx = session.beginTransaction(mode); 366 | 367 | // Create an 'end' function to commit & close the session 368 | // TODO: Clean up 369 | tx.success = () => { 370 | return tx.commit() 371 | .then(() => { 372 | session.close(); 373 | }); 374 | }; 375 | 376 | return tx; 377 | } 378 | 379 | /** 380 | * Run a batch of queries within a transaction 381 | * 382 | * @type {Array} 383 | * @return {Promise} 384 | */ 385 | batch(queries) { 386 | const tx = this.transaction(); 387 | const output = []; 388 | const errors = []; 389 | 390 | return Promise.all(queries.map(query => { 391 | const params = typeof query == 'object' ? query.params : {}; 392 | query = typeof query == 'object' ? query.query : query; 393 | 394 | try { 395 | return tx.run(query, params) 396 | .then(res => { 397 | output.push(res); 398 | }) 399 | .catch(error => { 400 | errors.push({query, params, error}); 401 | }); 402 | } 403 | catch (error) { 404 | errors.push({query, params, error}); 405 | } 406 | })) 407 | .then(() => { 408 | if (errors.length) { 409 | tx.rollback(); 410 | 411 | const error = new TransactionError(errors); 412 | 413 | throw error; 414 | } 415 | 416 | return tx.success() 417 | .then(() => { 418 | return output; 419 | }); 420 | }); 421 | } 422 | 423 | /** 424 | * Close Driver 425 | * 426 | * @return {void} 427 | */ 428 | close() { 429 | this.driver.close(); 430 | } 431 | 432 | /** 433 | * Return a new Query Builder 434 | * 435 | * @return {Builder} 436 | */ 437 | query() { 438 | return new Builder(this); 439 | } 440 | 441 | /** 442 | * Get a collection of nodes` 443 | * 444 | * @param {String} label 445 | * @param {Object} properties 446 | * @param {String|Array|Object} order 447 | * @param {Int} limit 448 | * @param {Int} skip 449 | * @return {Promise} 450 | */ 451 | all(label, properties, order, limit, skip) { 452 | return this.models.get(label).all(properties, order, limit, skip); 453 | } 454 | 455 | /** 456 | * Find a Node by it's label and primary key 457 | * 458 | * @param {String} label 459 | * @param {mixed} id 460 | * @return {Promise} 461 | */ 462 | find(label, id) { 463 | return this.models.get(label).find(id); 464 | } 465 | 466 | /** 467 | * Find a Node by it's internal node ID 468 | * 469 | * @param {String} model 470 | * @param {int} id 471 | * @return {Promise} 472 | */ 473 | findById(label, id) { 474 | return this.models.get(label).findById(id); 475 | } 476 | 477 | /** 478 | * Find a Node by properties 479 | * 480 | * @param {String} label 481 | * @param {mixed} key Either a string for the property name or an object of values 482 | * @param {mixed} value Value 483 | * @return {Promise} 484 | */ 485 | first(label, key, value) { 486 | return this.models.get(label).first(key, value); 487 | } 488 | 489 | /** 490 | * Hydrate a set of nodes and return a Collection 491 | * 492 | * @param {Object} res Neo4j result set 493 | * @param {String} alias Alias of node to pluck 494 | * @param {Definition|null} definition Force Definition 495 | * @return {Collection} 496 | */ 497 | hydrate(res, alias, definition) { 498 | return this.factory.hydrate(res, alias, definition); 499 | } 500 | 501 | /** 502 | * Hydrate the first record in a result set 503 | * 504 | * @param {Object} res Neo4j Result 505 | * @param {String} alias Alias of Node to pluck 506 | * @return {Node} 507 | */ 508 | hydrateFirst(res, alias, definition) { 509 | return this.factory.hydrateFirst(res, alias, definition); 510 | } 511 | 512 | /** 513 | * Turn an array into a Collection 514 | * 515 | * @param {Array} array An array 516 | * @return {Collection} 517 | */ 518 | toCollection(array) { 519 | return new Collection(this, array); 520 | } 521 | 522 | } 523 | 524 | module.exports = Neode; 525 | -------------------------------------------------------------------------------- /src/Query/Builder.js: -------------------------------------------------------------------------------- 1 | import Match from './Match'; 2 | import Order from './Order'; 3 | // import Return from './Return'; 4 | import Statement from './Statement'; 5 | import Property from './Property'; 6 | import WhereStatement from './WhereStatement'; 7 | import Where, {OPERATOR_EQUALS} from './Where'; 8 | import WhereBetween from './WhereBetween'; 9 | import WhereId from './WhereId'; 10 | import WhereRaw from './WhereRaw'; 11 | import WithStatement from './WithStatement'; 12 | import WithDistinctStatement from './WithDistinctStatement'; 13 | import neo4j from 'neo4j-driver'; 14 | 15 | export const mode = { 16 | READ: "READ", 17 | WRITE: "WRITE" 18 | }; 19 | 20 | 21 | export default class Builder { 22 | 23 | constructor(neode) { 24 | this._neode = neode; 25 | 26 | this._params = {}; 27 | this._statements = []; 28 | this._current; 29 | this._where; 30 | this._set_count = 0; 31 | } 32 | 33 | /** 34 | * Start a new Query segment and set the current statement 35 | * 36 | * @return {Builder} 37 | */ 38 | statement(prefix) { 39 | if (this._current) { 40 | this._statements.push(this._current); 41 | } 42 | 43 | this._current = new Statement(prefix); 44 | 45 | return this; 46 | } 47 | 48 | /** 49 | * Start a new Where Segment 50 | * 51 | * @return {Builder} 52 | */ 53 | whereStatement(prefix) { 54 | if (this._where) { 55 | this._current.where(this._where); 56 | } 57 | 58 | this._where = new WhereStatement(prefix); 59 | 60 | return this; 61 | } 62 | 63 | /** 64 | * Match a Node by a definition 65 | * 66 | * @param {String} alias Alias in query 67 | * @param {Model|String} model Model definition 68 | * @param {Object|null} properties Inline Properties 69 | * @return {Builder} Builder 70 | */ 71 | match(alias, model, properties) { 72 | this.whereStatement('WHERE'); 73 | this.statement(); 74 | 75 | this._current.match( new Match(alias, model, this._convertPropertyMap( alias, properties ) ) ); 76 | 77 | return this; 78 | } 79 | 80 | optionalMatch(alias, model) { 81 | this.whereStatement('WHERE'); 82 | this.statement('OPTIONAL MATCH'); 83 | 84 | this._current.match(new Match(alias, model)); 85 | 86 | return this; 87 | } 88 | 89 | /** 90 | * Add a 'with' statement to the query 91 | * 92 | * @param {...String} args Variables/aliases to carry through 93 | * @return {Builder} 94 | */ 95 | with(...args) { 96 | this.whereStatement('WHERE'); 97 | this.statement(); 98 | 99 | this._statements.push(new WithStatement(...args)); 100 | 101 | return this; 102 | } 103 | 104 | /** 105 | * Add a 'with distinct' statement to the query 106 | * 107 | * @param {...String} args Variables/aliases to carry through 108 | * @return {Builder} 109 | */ 110 | withDistinct(...args) { 111 | this.whereStatement('WHERE'); 112 | this.statement(); 113 | 114 | this._statements.push(new WithDistinctStatement(...args)); 115 | 116 | return this; 117 | } 118 | 119 | /** 120 | * Create a new WhereSegment 121 | * @param {...mixed} args 122 | * @return {Builder} 123 | */ 124 | or(...args) { 125 | this.whereStatement('OR'); 126 | 127 | return this.where(...args); 128 | } 129 | 130 | /** 131 | * Generate a unique key and add the value to the params object 132 | * 133 | * @param {String} key 134 | * @param {Mixed} value 135 | */ 136 | _addWhereParameter(key, value) { 137 | let attempt = 1; 138 | let base = `where_${key.replace(/[^a-z0-9]+/g, '_')}`; 139 | 140 | // Try to create a unique key 141 | let variable = base; 142 | 143 | while ( typeof this._params[ variable ] != "undefined" ) { 144 | attempt++; 145 | 146 | variable = `${base}_${attempt}`; 147 | } 148 | 149 | this._params[ variable ] = value; 150 | 151 | return variable; 152 | } 153 | 154 | /** 155 | * Add a where condition to the current statement. 156 | * 157 | * @param {...mixed} args Arguments 158 | * @return {Builder} 159 | */ 160 | where(...args) { 161 | if (!args.length || !args[0]) return this; 162 | 163 | // If 2 character length, it should be straight forward where 164 | if (args.length == 2) { 165 | args = [args[0], OPERATOR_EQUALS, args[1]]; 166 | } 167 | 168 | // If only one argument, treat it as a single string 169 | if ( args.length == 1) { 170 | const [arg] = args; 171 | 172 | if (Array.isArray(arg)) { 173 | arg.forEach(inner => { 174 | this.where(...inner); 175 | }); 176 | } 177 | else if (typeof arg == 'object') { 178 | Object.keys(arg).forEach(key => { 179 | this.where(key, arg[key]); 180 | }); 181 | } 182 | else { 183 | this._where.append(new WhereRaw(args[0])); 184 | } 185 | } 186 | else { 187 | const [left, operator, value] = args; 188 | const right = this._addWhereParameter(left, value); 189 | 190 | this._params[ right ] = value; 191 | this._where.append(new Where(left, operator, `$${right}`)); 192 | } 193 | 194 | return this; 195 | } 196 | 197 | /** 198 | * Query on Internal ID 199 | * 200 | * @param {String} alias 201 | * @param {Int} value 202 | * @return {Builder} 203 | */ 204 | whereId(alias, value) { 205 | const param = this._addWhereParameter(`${alias}_id`, neo4j.int(value)); 206 | 207 | this._where.append(new WhereId(alias, param)); 208 | 209 | return this; 210 | } 211 | 212 | /** 213 | * Add a raw where clause 214 | * 215 | * @param {String} clause 216 | * @return {Builder} 217 | */ 218 | whereRaw(clause) { 219 | this._where.append(new WhereRaw(clause)); 220 | 221 | return this; 222 | } 223 | 224 | /** 225 | * A negative where clause 226 | * 227 | * @param {*} args 228 | * @return {Builder} 229 | */ 230 | whereNot(...args) { 231 | this.where(...args); 232 | 233 | this._where.last().setNegative(); 234 | 235 | return this; 236 | } 237 | 238 | /** 239 | * Between clause 240 | * 241 | * @param {String} alias 242 | * @param {Mixed} floor 243 | * @param {Mixed} ceiling 244 | * @return {Builder} 245 | */ 246 | whereBetween(alias, floor, ceiling) { 247 | const floor_alias = this._addWhereParameter(`${alias}_floor`, floor); 248 | const ceiling_alias = this._addWhereParameter(`${alias}_ceiling`, ceiling); 249 | 250 | this._where.append(new WhereBetween(alias, floor_alias, ceiling_alias)); 251 | 252 | return this; 253 | } 254 | 255 | /** 256 | * Negative Between clause 257 | * 258 | * @param {String} alias 259 | * @param {Mixed} floor 260 | * @param {Mixed} ceiling 261 | * @return {Builder} 262 | */ 263 | whereNotBetween(alias, floor, ceiling) { 264 | this.whereBetween(alias, floor, ceiling); 265 | 266 | this._where.last().setNegative(); 267 | 268 | return this; 269 | } 270 | 271 | /** 272 | * Set Delete fields 273 | * 274 | * @param {...mixed} args 275 | * @return {Builder} 276 | */ 277 | delete(...args) { 278 | this._current.delete(...args); 279 | 280 | return this; 281 | } 282 | 283 | /** 284 | * Set Detach Delete fields 285 | * 286 | * @param {...mixed} args 287 | * @return {Builder} 288 | */ 289 | detachDelete(...args) { 290 | this._current.detachDelete(...args); 291 | 292 | return this; 293 | } 294 | 295 | /** 296 | * Start a Create Statement by alias/definition 297 | * 298 | * @param {String} alias Alias in query 299 | * @param {Model|String} model Model definition 300 | * @param {Object|null} properties Inline Properties 301 | * @return {Builder} Builder 302 | */ 303 | create(alias, model, properties) { 304 | this.whereStatement('WHERE'); 305 | this.statement('CREATE'); 306 | 307 | this._current.match( new Match(alias, model, this._convertPropertyMap( alias, properties ) ) ); 308 | 309 | return this; 310 | } 311 | 312 | /** 313 | * Convert a map of properties into an Array of 314 | * 315 | * @param {Object|null} properties 316 | */ 317 | _convertPropertyMap(alias, properties) { 318 | if ( properties ) { 319 | return Object.keys(properties).map(key => { 320 | const property_alias = `${alias}_${key}`; 321 | 322 | this._params[ property_alias ] = properties[ key ]; 323 | 324 | return new Property( key, property_alias ); 325 | }); 326 | } 327 | 328 | return []; 329 | } 330 | 331 | /** 332 | * Start a Merge Statement by alias/definition 333 | * 334 | * @param {String} alias Alias in query 335 | * @param {Model|String} model Model definition 336 | * @param {Object|null} properties Inline Properties 337 | * @return {Builder} Builder 338 | */ 339 | merge(alias, model, properties) { 340 | this.whereStatement('WHERE'); 341 | this.statement('MERGE'); 342 | 343 | this._current.match( new Match(alias, model, this._convertPropertyMap( alias, properties ) ) ); 344 | 345 | return this; 346 | } 347 | 348 | /** 349 | * Set a property 350 | * 351 | * @param {String|Object} property Property in {alias}.{property} format 352 | * @param {Mixed} value Value 353 | * @param {String} operator Operator 354 | */ 355 | set(property, value, operator = '=') { 356 | // Support a map of properties 357 | if ( !value && property instanceof Object ) { 358 | Object.keys(property).forEach(key => { 359 | this.set(key, property[ key ]); 360 | }); 361 | } 362 | else { 363 | if ( value !== undefined ) { 364 | const alias = `set_${this._set_count}`; 365 | this._params[ alias ] = value; 366 | 367 | this._set_count++; 368 | 369 | this._current.set(property, alias, operator); 370 | } else { 371 | this._current.setRaw(property); 372 | } 373 | } 374 | 375 | return this; 376 | } 377 | 378 | 379 | /** 380 | * Set a property 381 | * 382 | * @param {String|Object} property Property in {alias}.{property} format 383 | * @param {Mixed} value Value 384 | * @param {String} operator Operator 385 | */ 386 | onCreateSet(property, value, operator = '=') { 387 | // Support a map of properties 388 | if ( value === undefined && property instanceof Object ) { 389 | Object.keys(property).forEach(key => { 390 | this.onCreateSet(key, property[ key ]); 391 | }); 392 | } 393 | else { 394 | const alias = `set_${this._set_count}`; 395 | this._params[ alias ] = value; 396 | 397 | this._set_count++; 398 | 399 | this._current.onCreateSet(property, alias, operator); 400 | } 401 | 402 | return this; 403 | } 404 | 405 | 406 | /** 407 | * Set a property 408 | * 409 | * @param {String|Object} property Property in {alias}.{property} format 410 | * @param {Mixed} value Value 411 | * @param {String} operator Operator 412 | */ 413 | onMatchSet(property, value, operator = '=') { 414 | // Support a map of properties 415 | if ( value === undefined && property instanceof Object ) { 416 | Object.keys(property).forEach(key => { 417 | this.onMatchSet(key, property[ key ]); 418 | }); 419 | } 420 | else { 421 | const alias = `set_${this._set_count}`; 422 | this._params[ alias ] = value; 423 | 424 | this._set_count++; 425 | 426 | this._current.onMatchSet(property, alias, operator); 427 | } 428 | 429 | return this; 430 | } 431 | 432 | /** 433 | * Remove properties or labels in {alias}.{property} 434 | * or {alias}:{Label} format 435 | * 436 | * @param {[String]} items 437 | */ 438 | remove(...items) { 439 | this._current.remove(items); 440 | 441 | return this; 442 | } 443 | 444 | /** 445 | * Set Return fields 446 | * 447 | * @param {...mixed} args 448 | * @return {Builder} 449 | */ 450 | return(...args) { 451 | this._current.return(...args); 452 | 453 | return this; 454 | } 455 | 456 | /** 457 | * Set Record Limit 458 | * 459 | * @param {Int} limit 460 | * @return {Builder} 461 | */ 462 | limit(limit) { 463 | this._current.limit(limit); 464 | 465 | return this; 466 | } 467 | 468 | /** 469 | * Set Records to Skip 470 | * 471 | * @param {Int} skip 472 | * @return {Builder} 473 | */ 474 | skip(skip) { 475 | this._current.skip(skip); 476 | 477 | return this; 478 | } 479 | 480 | /** 481 | * Add an order by statement 482 | * 483 | * @param {...String|object} args Order by statements 484 | * @return {Builder} 485 | */ 486 | orderBy(...args) { 487 | let order_by; 488 | 489 | if (args.length == 2) { 490 | // Assume orderBy(what, how) 491 | order_by = new Order(args[0], args[1]); 492 | 493 | } 494 | else if (Array.isArray(args[0])) { 495 | // Handle array of where's 496 | args[0].forEach(arg => { 497 | this.orderBy(arg); 498 | }); 499 | } 500 | // TODO: Ugly, stop supporting this 501 | else if (typeof args[0] == 'object' && args[0].field) { 502 | // Assume orderBy(args[0].field, args[0].order) 503 | order_by = new Order(args[0].field, args[0].order); 504 | } 505 | else if (typeof args[0] == 'object') { 506 | // Assume {key: order} 507 | Object.keys(args[0]).forEach(key => { 508 | this.orderBy(key, args[0][key]); 509 | }); 510 | } 511 | else if (args[0]) { 512 | // Assume orderBy(what, 'ASC') 513 | order_by = new Order(args[0]); 514 | } 515 | 516 | if (order_by) { 517 | this._current.order(order_by); 518 | } 519 | 520 | return this; 521 | } 522 | 523 | /** 524 | * Add a relationship to the query 525 | * 526 | * @param {String|RelationshipType} relationship Relationship name or RelationshipType object 527 | * @param {String} direction Direction of relationship DIRECTION_IN, DIRECTION_OUT 528 | * @param {String|null} alias Relationship alias 529 | * @param {Int|String} degrees Number of traversdegreesals (1, "1..2", "0..2", "..3") 530 | * @return {Builder} 531 | */ 532 | relationship(relationship, direction, alias, degrees) { 533 | this._current.relationship(relationship, direction, alias, degrees); 534 | 535 | return this; 536 | } 537 | 538 | /** 539 | * Complete a relationship 540 | * @param {String} alias Alias 541 | * @param {Model} model Model definition 542 | * @param {Object} properties Properties 543 | * @return {Builder} 544 | */ 545 | to(alias, model, properties) { 546 | this._current.match( new Match(alias, model, this._convertPropertyMap(alias, properties) ) ); 547 | 548 | return this; 549 | } 550 | 551 | /** 552 | * Complete the relationship statement to point to anything 553 | * 554 | * @return {Builder} 555 | */ 556 | toAnything() { 557 | this._current.match(new Match()); 558 | 559 | return this; 560 | } 561 | 562 | /** 563 | * Build the pattern without any keywords 564 | * 565 | * @return {String} 566 | */ 567 | pattern() { 568 | this.whereStatement(); 569 | this.statement(); 570 | 571 | return this._statements.map(statement => { 572 | return statement.toString(false); 573 | }).join('\n'); 574 | } 575 | 576 | /** 577 | * Build the Query 578 | * 579 | * @param {...String} output References to output 580 | * @return {Object} Object containing `query` and `params` property 581 | */ 582 | build() { 583 | // Append Statement to Statements 584 | this.whereStatement(); 585 | this.statement(); 586 | 587 | const query = this._statements.map(statement => { 588 | return statement.toString(); 589 | }).join('\n'); 590 | 591 | return { 592 | query, 593 | params: this._params 594 | }; 595 | } 596 | 597 | /** 598 | * Execute the query 599 | * 600 | * @param {String} query_mode 601 | * @return {Promise} 602 | */ 603 | execute(query_mode = mode.WRITE) { 604 | const { query, params } = this.build(); 605 | 606 | let session 607 | 608 | switch (query_mode) { 609 | case mode.WRITE: 610 | session = this._neode.writeSession() 611 | 612 | return session.writeTransaction(tx => tx.run(query, params)) 613 | .then(res => { 614 | session.close() 615 | 616 | return res 617 | }) 618 | 619 | 620 | default: 621 | session = this._neode.readSession() 622 | 623 | return session.readTransaction(tx => tx.run(query, params)) 624 | .then(res => { 625 | session.close() 626 | 627 | return res 628 | }) 629 | } 630 | } 631 | 632 | } --------------------------------------------------------------------------------