├── .gitignore ├── test ├── mocha.opts ├── neo4j_services.rb └── tests.coffee ├── .npmignore ├── .travis.yml ├── src ├── index.coffee ├── extendPath.coffee ├── extendRelationship.coffee ├── mongraph.coffee ├── mongraphMongoosePlugin.coffee ├── extendNode.coffee ├── processtools.coffee └── extendDocument.coffee ├── LICENSE ├── benchmark ├── init.coffee ├── creating_records.coffee ├── finding_records.coffee ├── deleting_records.coffee └── benchmark.coffee ├── package.json ├── examples ├── shortestPath.coffee └── example.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/* 3 | *.map 4 | src/*.js 5 | test/*.js 6 | docs/* 7 | lib/* 8 | npm-debug.log -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | --reporter spec 3 | --ui bdd 4 | --timeout 20000 5 | --compilers coffee:coffee-script/register -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/* 3 | src/*.js 4 | src/*.map 5 | test/*.js 6 | test/*.map 7 | docs/* 8 | npm-debug.log 9 | .git 10 | .git/* -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "0.11" 5 | - "0.10" 6 | services: 7 | - mongodb 8 | - neo4j 9 | rvm: 10 | - 1.9.3 11 | before_install: 12 | - ruby test/neo4j_services.rb install && ruby test/neo4j_services.rb start -------------------------------------------------------------------------------- /src/index.coffee: -------------------------------------------------------------------------------- 1 | # # Mongraph 2 | # 3 | # Mongraph combines documentstorage database (mongodb) with graph-database relationships (neo4j) by creating a corresponding node for each document. 4 | # 5 | # This module links directly to the mongraph library. 6 | 7 | module.exports = require('./mongraph') -------------------------------------------------------------------------------- /src/extendPath.coffee: -------------------------------------------------------------------------------- 1 | _ = require('underscore') 2 | processtools = require('./processtools') 3 | 4 | # same issues as `extendRelationship` 5 | Path = 6 | 7 | toObject: -> 8 | { nodes: @_nodes || null, relationships: @_relationships, data: @_data?.data, id: @id, getParent: -> @ } 9 | 10 | exports.extend = (pathObject) -> 11 | 12 | return pathObject unless typeof pathObject is 'object' 13 | 14 | _.extend(pathObject, Path) 15 | 16 | -------------------------------------------------------------------------------- /src/extendRelationship.coffee: -------------------------------------------------------------------------------- 1 | _ = require('underscore') 2 | processtools = require('./processtools') 3 | 4 | # ## Helper as workaround for missing prototyp availibility of neo4j module Relationship object 5 | # TODO: implement load Documents/Nodes from processtools 6 | 7 | # extends neo4j::Relationship 8 | Relationship = 9 | 10 | toObject: -> 11 | { from: @from || null, to: @to || null, data: @_data?.data, id: @id, getParent: -> @ } 12 | 13 | exports.extend = (relationshipObject) -> 14 | 15 | # TODO: maybe it would better to check constructor for 'Relationship' ?! 16 | return relationshipObject unless typeof relationshipObject is 'object' 17 | 18 | _.extend(relationshipObject, Relationship) 19 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mongraph combines documentstorage-database with graph-database relationships 2 | Copyright (C) 2013-2015 Philipp Staender 3 | 4 | This program is free software: you can redistribute it and/or modify 5 | it under the terms of the GNU General Public License as published by 6 | the Free Software Foundation, either version 3 of the License, or 7 | (at your option) any later version. 8 | 9 | This program is distributed in the hope that it will be useful, 10 | but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | GNU General Public License for more details. 13 | 14 | You should have received a copy of the GNU General Public License 15 | along with this program. If not, see . -------------------------------------------------------------------------------- /benchmark/init.coffee: -------------------------------------------------------------------------------- 1 | Benchmark = require('benchmark') 2 | # Suite = Benchmark.Suite 3 | # suite = new Benchmark.Suite 4 | 5 | # mongoose 6 | mongoose = require('mongoose') 7 | mongoose.connect("mongodb://localhost/testdb") 8 | Person = mongoose.model "Person", value: Number 9 | 10 | # "native" 11 | mongoskin = require("mongoskin") 12 | mongodb = mongoskin.db("localhost:27017/testdb", {safe:false}) 13 | 14 | # neo4j 15 | neo4j = require('neo4j') 16 | graph = new neo4j.GraphDatabase('http://localhost:7474') 17 | 18 | # mongraph 19 | mongraph = require '../src/mongraph' 20 | mongraph.init { neo4j: graph, mongoose: mongoose } 21 | # Location is not with mongraph hooks 22 | Location = mongoose.model "Location", value: Number 23 | 24 | randomInteger = (floor = 0, ceiling = 1) -> Math.round(Math.random()*(ceiling-floor))+floor 25 | 26 | exports = module.exports = {mongraph,graph,mongodb,randomInteger,Benchmark,Person,Location} -------------------------------------------------------------------------------- /benchmark/creating_records.coffee: -------------------------------------------------------------------------------- 1 | {mongraph,graph,mongodb,randomInteger,Benchmark,Person,Location} = require './init' 2 | 3 | suite = new Benchmark.Suite 4 | 5 | suite.add "creating native mongodb documents", (deferred) -> 6 | mongodb.collection("people").insert value: Math.random(), (err, document) -> 7 | deferred.resolve() 8 | , defer: true 9 | 10 | suite.add "creating mongoose documents", (deferred) -> 11 | foo = new Person(value: Math.random()) 12 | foo.save (err, document) -> 13 | deferred.resolve() 14 | , defer: true 15 | 16 | suite.add "creating neo4j nodes", (deferred) -> 17 | node = graph.createNode value: Math.random() 18 | node.save -> 19 | deferred.resolve() 20 | , defer: true 21 | 22 | suite.add "creating mongraph documents", (deferred) -> 23 | bar = new Location(value: Math.random()) 24 | bar.save (err, document) -> 25 | deferred.resolve() 26 | , defer: true 27 | 28 | suite.on "cycle", (event) -> 29 | console.log "* "+String(event.target) 30 | 31 | exports = module.exports = {suite} -------------------------------------------------------------------------------- /benchmark/finding_records.coffee: -------------------------------------------------------------------------------- 1 | {mongraph,graph,mongodb,randomInteger,Benchmark,Person,Location} = require './init' 2 | 3 | suite = new Benchmark.Suite 4 | 5 | suite.add 'selecting node', (deferred) -> 6 | # skip = randomNumber(10) 7 | # limit = randomNumber(10) 8 | # RETURN t SKIP #{skip} LIMIT #{limit} 9 | graph.query "START t=node(*) LIMIT 1 RETURN t;", (err, found) -> 10 | deferred.resolve() 11 | , defer: true 12 | 13 | suite.add 'selecting native document', (deferred) -> 14 | mongodb.collection("people").findOne {}, (err, found) -> 15 | deferred.resolve() 16 | , defer: true 17 | 18 | suite.add 'selecting mongoosse document', (deferred) -> 19 | Person.findOne {}, (err, found) -> 20 | deferred.resolve() 21 | , defer: true 22 | 23 | suite.add 'selecting document with corresponding node', (deferred) -> 24 | Location.findOne {}, (err, found) -> 25 | found.getNode -> 26 | deferred.resolve() 27 | , defer: true 28 | 29 | suite.on "cycle", (event) -> 30 | console.log "* "+String(event.target) 31 | 32 | exports = module.exports = {suite} -------------------------------------------------------------------------------- /benchmark/deleting_records.coffee: -------------------------------------------------------------------------------- 1 | {mongraph,graph,mongodb,randomInteger,Benchmark,Person,Location} = require './init' 2 | 3 | suite = new Benchmark.Suite 4 | 5 | suite.add "deleting native mongodb documents", (deferred) -> 6 | mongodb.collection("people").insert value: Math.random(), (err, document) -> 7 | mongodb.collection("people").removeById document._id, (err) -> 8 | deferred.resolve() 9 | , defer: true 10 | 11 | suite.add "deleting mongoose documents", (deferred) -> 12 | foo = new Person(value: Math.random()) 13 | foo.save (err, document) -> foo.remove (err) -> 14 | deferred.resolve() 15 | , defer: true 16 | 17 | suite.add "deleting neo4j nodes", (deferred) -> 18 | node = graph.createNode value: Math.random() 19 | node.save -> node.delete (err) -> 20 | deferred.resolve() 21 | , defer: true 22 | 23 | suite.add "deleting mongraph documents", (deferred) -> 24 | bar = new Location(value: Math.random()) 25 | bar.save (err, document) -> bar.remove (err) -> 26 | deferred.resolve() 27 | , defer: true 28 | 29 | suite.on "cycle", (event) -> 30 | console.log "* "+String(event.target) 31 | 32 | exports = module.exports = {suite} -------------------------------------------------------------------------------- /benchmark/benchmark.coffee: -------------------------------------------------------------------------------- 1 | sequence = require('futures').sequence.create() 2 | 3 | sequence 4 | 5 | .then (next) -> 6 | 7 | {suite} = require('./creating_records') 8 | 9 | suite.on "complete", -> 10 | console.log "\n**Fastest** is " + @filter("fastest").pluck("name") 11 | console.log "\n**Slowest** is " + @filter("slowest").pluck("name") 12 | console.log "\n" 13 | next() 14 | 15 | console.log "\n### CREATING RECORDS\n" 16 | suite.run async: true 17 | 18 | .then (next) -> 19 | 20 | {suite} = require('./finding_records') 21 | 22 | suite.on "complete", -> 23 | console.log "\n**Fastest** is " + @filter("fastest").pluck("name") 24 | console.log "\n**Slowest** is " + @filter("slowest").pluck("name") 25 | console.log "\n" 26 | next() 27 | 28 | console.log "\n### FINDING RECORDS\n" 29 | suite.run async: true 30 | 31 | .then (next) -> 32 | 33 | {suite} = require('./deleting_records') 34 | 35 | suite.on "complete", -> 36 | console.log "\n**Fastest** is " + @filter("fastest").pluck("name") 37 | console.log "\n**Slowest** is " + @filter("slowest").pluck("name") 38 | console.log "\n" 39 | next() 40 | 41 | console.log "\n### DELETING RECORDS\n" 42 | suite.run async: true 43 | 44 | .then (next) -> 45 | 46 | console.log 'done... exiting' 47 | process.exit(0) 48 | -------------------------------------------------------------------------------- /test/neo4j_services.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | path = "#{Dir.home}/neo4j_instances/" 4 | 5 | `mkdir -p #{path}` 6 | 7 | versions = { 8 | "neo4j-community-2.0.4" => 7010, 9 | "neo4j-community-2.1.8" => 7012, 10 | "neo4j-community-2.2.2" => 7014, 11 | } 12 | 13 | if ARGV[0] == 'start' 14 | puts "starting..." 15 | versions.each { |version, port| 16 | puts "#{version} on port #{port}" 17 | altPort = port+1 18 | # ensuring we are using the right for ports 19 | filename = "#{path}/#{version}/conf/neo4j-server.properties" 20 | text = File.read(filename) 21 | text = text.gsub(/^(\s*org\.neo4j\.server\.webserver\.port\s*)(=\s*)(.+)$/, '\1='+port.to_s) 22 | text = text.gsub(/^(\s*org\.neo4j\.server\.webserver\.https\.port\s*)(=\s*)(.+)$/, '\1='+altPort.to_s) 23 | File.open(filename, "w") {|file| file.puts text } 24 | `#{path}/#{version}/bin/neo4j start` 25 | } 26 | elsif ARGV[0] == 'stop' 27 | puts "stoppping..." 28 | versions.each { |version, port| 29 | puts "#{version} on port #{port}" 30 | `#{path}/#{version}/bin/neo4j stop` 31 | } 32 | elsif ARGV[0] == 'install' 33 | versions.each { |version, port| 34 | puts "Installing #{version}" 35 | `cd #{path} && wget http://neo4j.com/artifact.php?name=#{version}-unix.tar.gz` 36 | `cd #{path} && tar zxvf artifact.php?name=#{version}-unix.tar.gz #{version}` 37 | } 38 | else 39 | puts "Possible arguments: start|stop|install" 40 | end -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongraph", 3 | "description": "Mongraph combines documentstorage-database with graph-database relationships", 4 | "version": "0.2.0", 5 | "author": "Philipp Staender ", 6 | "homepage": "https://github.com/pstaender/mongraph/", 7 | "main": "./lib/mongraph", 8 | "repository": { 9 | "type": "git", 10 | "url": "git://github.com:pstaender/mongraph.git" 11 | }, 12 | "keywords": [ 13 | "mongodb", 14 | "neo4j", 15 | "mongoose", 16 | "graphdatabase" 17 | ], 18 | "scripts": { 19 | "test": "node_modules/mocha/bin/mocha --globals=7010; node_modules/mocha/bin/mocha --globals=7012", 20 | "build": "node_modules/coffee-script/bin/coffee --map -cbo lib src", 21 | "example": "npm run build && node examples/example.js", 22 | "documentation": "node_modules/docco/bin/docco ./src/*.coffee", 23 | "prepare": "npm test && npm run build && npm run documentation" 24 | }, 25 | "devDependencies": { 26 | "mocha": "~2.2.5", 27 | "expect.js": "~0.2", 28 | "should": "~6.0", 29 | "mongoose": "~4.0", 30 | "neo4j": "~1.1.1", 31 | "docco": "~0.7", 32 | "coveralls": "latest", 33 | "mongoskin": "latest", 34 | "benchmark": "~1.0", 35 | "microtime": "*", 36 | "futures": "~2.3.1", 37 | "request": "~2.55.0", 38 | "coffee-script": "~1.9.2" 39 | }, 40 | "dependencies": { 41 | "bson": "~0.3.2", 42 | "join": "~2.3", 43 | "minimist": "^1.1.1", 44 | "underscore": "~1.4", 45 | "underscore.string": "~2.3" 46 | }, 47 | "licenses": { 48 | "type": "GPL" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/mongraph.coffee: -------------------------------------------------------------------------------- 1 | processtools = require('./processtools') 2 | mongraphMongoosePlugin = require('./mongraphMongoosePlugin') 3 | _ = require('underscore') 4 | 5 | # bare config 6 | config = { options: {} } 7 | alreadyInitialized = false 8 | 9 | init = (options) -> 10 | 11 | options = {} if typeof options isnt 'object' 12 | 13 | # set default options 14 | _.extend(config.options, options) 15 | config.mongoose = options.mongoose 16 | config.graphdb = options.neo4j 17 | config.options.overrideProtypeFunctions ?= false 18 | config.options.storeDocumentInGraphDatabase = false # TODO: implement 19 | config.options.cacheNodes ?= true 20 | config.options.loadMongoDBRecords ?= true 21 | config.options.extendSchemaWithMongoosePlugin ?= true 22 | config.options.relationships ?= {} 23 | config.options.relationships.storeTimestamp ?= true 24 | config.options.relationships.storeIDsInRelationship = true # must be true as long it's needed for mongraph to work as expected 25 | config.options.relationships.bidirectional ?= false 26 | config.options.relationships.storeInDocument ?= false # will produce redundant data (stored in relationships + document) 27 | config.options.cacheAttachedNodes ?= true # recommend to decrease requests to neo4j 28 | 29 | # Allow overriding if mongrapg already was inizialized 30 | config.options.overrideProtoypeFunctions = true if alreadyInitialized 31 | 32 | # used for extendDocument + extendNode 33 | config.options.mongoose = options.mongoose 34 | config.options.graphdb = options.neo4j 35 | 36 | throw new Error("mongraph needs a mongoose reference as parameter") unless processtools.constructorNameOf(config.mongoose) is 'Mongoose' 37 | throw new Error("mongraph needs a neo4j graphdatabase reference as paramater") unless processtools.constructorNameOf(config.graphdb) is 'GraphDatabase' 38 | 39 | # extend Document(s) with Node/GraphDB interoperability 40 | require('./extendDocument')(config.options) 41 | # extend Node(s) with DocumentDB interoperability 42 | require('./extendNode')(config.options) 43 | 44 | # Load plugin and extend schemas with middleware 45 | # -> http://mongoosejs.com/docs/plugins.html 46 | config.mongoose.plugin(mongraphMongoosePlugin, config.options) if config.options.extendSchemaWithMongoosePlugin 47 | 48 | alreadyInitialized = true 49 | 50 | 51 | module.exports = {init,config,processtools} 52 | 53 | -------------------------------------------------------------------------------- /examples/shortestPath.coffee: -------------------------------------------------------------------------------- 1 | # Load required modules 2 | mongoose = require("mongoose") 3 | mongoose.connect("mongodb://localhost/mongraph_example") 4 | neo4j = require("neo4j") 5 | mongraph = require("../src/mongraph") 6 | graphdb = new neo4j.GraphDatabase("http://localhost:7474") 7 | print = console.log 8 | 9 | # Init mongraph 10 | # 11 | # Hint: Always init mongraph **before** defining Schemas 12 | # Otherwise the mograph mongoose-plugin will not be affected: 13 | # 14 | # * no storage of the Node id in the Document 15 | # * no automatic deletion of relationships and corresponding nodes in graphdb 16 | # 17 | # Alternatively you can apply the plugin by yourself: 18 | # 19 | # `mongoose.plugin require('./node_modules/mongraph/src/mongraphMongoosePlugin')` 20 | 21 | mongraph.init 22 | neo4j: graphdb 23 | mongoose: mongoose 24 | 25 | # Define model 26 | Person = mongoose.model("Person", name: String) 27 | 28 | # Example data 29 | alice = new Person(name: "Alice") 30 | bob = new Person(name: "Bob") 31 | charles = new Person(name: "Charles") 32 | zoe = new Person(name: "Zoe") 33 | 34 | # The following shall demonstrate how to work with Documents and it's corresponding Nodes 35 | # Best practice would be to manage this with joins or streamlines instead of seperate callbacks 36 | # But here we go through callback hell ;) 37 | alice.save -> bob.save -> charles.save -> zoe.save -> 38 | # stored 39 | alice.getNode (err, aliceNode) -> bob.getNode (err, bobNode) -> charles.getNode (err, charlesNode) -> zoe.getNode (err, zoeNode) -> 40 | alice.createRelationshipTo bob, 'knows', (err, relation) -> 41 | print "#{alice.name} -> #{bob.name}" 42 | bob.createRelationshipTo charles, 'knows', (err, relation) -> 43 | print "#{bob.name} -> #{charles.name}" 44 | bob.createRelationshipTo zoe, 'knows', (err, relation) -> 45 | print "#{bob.name} -> #{zoe.name}" 46 | charles.createRelationshipTo zoe, 'knows', (err, relation) -> 47 | print "#{charles.name} -> #{zoe.name}" 48 | print "#{alice.name} -> #{bob.name} -> #{charles.name} -> #{zoe.name}" 49 | print "#{alice.name} -> #{bob.name} -> #{zoe.name}" 50 | query = """ 51 | START a = node(#{aliceNode.id}), b = node(#{zoeNode.id}) 52 | MATCH p = shortestPath( a-[*..15]->b ) 53 | RETURN p; 54 | """ 55 | alice.queryGraph query, (err, docs) -> 56 | print "\nShortest path is #{docs.length-1} nodes long: #{docs[0].name} knows #{docs[2].name} through #{docs[1].name}" -------------------------------------------------------------------------------- /src/mongraphMongoosePlugin.coffee: -------------------------------------------------------------------------------- 1 | _ = require('underscore') 2 | 3 | module.exports = exports = mongraphMongoosePlugin = (schema, options = {}) -> 4 | 5 | schemaOptions = schema.options 6 | 7 | # skip if is set explizit to false 8 | return null if schemaOptions.graphability is false 9 | 10 | # set default option values for graphability 11 | schemaOptions.graphability ?= {} 12 | schemaOptions.graphability.schema ?= true 13 | schemaOptions.graphability.middleware ?= true 14 | 15 | # set default values, both hooks 16 | schemaOptions.graphability.middleware = {} if schemaOptions.graphability.middleware and typeof schemaOptions.graphability.middleware isnt 'object' 17 | schemaOptions.graphability.middleware.preRemove ?= true 18 | schemaOptions.graphability.middleware.preSave ?= true 19 | schemaOptions.graphability.middleware.postInit ?= true 20 | 21 | schemaOptions.graphability.relationships ?= {} 22 | schemaOptions.graphability.relationships.removeAllOutgoing ?= true 23 | schemaOptions.graphability.relationships.removeAllIncoming ?= true 24 | 25 | if schemaOptions.graphability.schema 26 | # node id of corresponding node 27 | schema.add 28 | _node_id: Number 29 | # add an empty object as placeholder for relationships, use is optional 30 | schema.add _relationships: {} 31 | 32 | # Extend middleware for graph use 33 | 34 | if schemaOptions.graphability.middleware.preRemove 35 | schema.pre 'remove', (next) -> 36 | # skip remove node if no node id is set 37 | return next(null) unless @._node_id >= 0 38 | # Remove also all relationships 39 | opts = 40 | includeRelationships: schemaOptions.graphability.relationships.removeAllOutgoing and schemaOptions.graphability.relationships.removeAllOutgoing 41 | @removeNode opts, next 42 | 43 | if schemaOptions.graphability.middleware.preSave 44 | schema.pre 'save', true, (next, done) -> 45 | # Attach/Save corresponding node 46 | doc = @ 47 | next() 48 | doc.getNode { forceCreation: true }, (err, node) -> 49 | # if we have fields to store in node and they have to be inde 50 | dataForNode = doc.dataForNode() 51 | index = doc.dataForNode(index: true) 52 | doc.indexGraph { node: node }, -> # TODO: implement exception handler 53 | if dataForNode 54 | # console.log dataForNode, node.id 55 | node.data = _.extend(node.data, dataForNode) 56 | for path of dataForNode 57 | # delete a key/value if it has an undefined value 58 | delete(node.data[path]) if typeof dataForNode[path] is 'undefined' 59 | node.save -> 60 | # TODO: implement exception handler 61 | done(err, node) 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /examples/example.js: -------------------------------------------------------------------------------- 1 | // required modules 2 | var mongoose = require('mongoose') 3 | , neo4j = require('neo4j') 4 | , mongraph = require('../lib/mongraph') 5 | , graphdb = new neo4j.GraphDatabase('http://localhost:7474'); 6 | 7 | mongoose.connect("mongodb://localhost/mongraph_example"); 8 | 9 | // apply mongraph functionalities 10 | mongraph.init({ 11 | neo4j: graphdb, 12 | mongoose: mongoose 13 | }); 14 | 15 | // Define a schema with mongoose as usual 16 | var Message = mongoose.model("Message", { 17 | name: String, 18 | content: String 19 | }); 20 | 21 | // message a 22 | var monicaMessage = new Message({ 23 | name: 'monica', 24 | content: 'hello graphdatabase world.\nregards\nmonica' 25 | }); 26 | 27 | // message b 28 | var neoMessage = new Message({ 29 | name: 'neo', 30 | }); 31 | 32 | var print = console.log; 33 | 34 | // we have two documents 35 | print('-> monica'); 36 | print('<- neo\n'); 37 | 38 | // to work with both nodes we have to persist the documents first 39 | neoMessage.save(function(){ 40 | monicaMessage.save(function(err) { 41 | // after Message is stored send message 42 | monicaMessage.createRelationshipTo( 43 | neoMessage, 44 | 'sends', 45 | { sendWith: 'love' }, // define some attributes on the relationship (optional) 46 | function(err, relationship) { 47 | // relationship created / message sended 48 | if (!err) 49 | print('-> '+monicaMessage.name+' sended a message to neo'); 50 | } 51 | ); 52 | }); 53 | }); 54 | 55 | 56 | setInterval(function(){ 57 | // check for new messages 58 | neoMessage.incomingRelationships('sends',function(err, relationships){ 59 | if ((relationships) && (relationships.length > 0)) { 60 | var message = relationships[0]; 61 | print('<- neo received '+relationships.length+' message(s)'); 62 | print('<- sended by '+message.from.name+' with '+message.data.sendWith+' ~'+((Math.round(new Date().getTime()/1000)) - message.data._created_at)+' secs ago'); 63 | // display message 64 | print(String("\n"+message.from.content).split("\n").join("\n>> ")+"\n"); 65 | // delete send relation from monica 66 | neoMessage.removeRelationshipsFrom(monicaMessage, 'sends', function() { 67 | print('<- neo read the message'); 68 | }); 69 | // mark as read 70 | neoMessage.createRelationshipTo( 71 | monicaMessage, 72 | 'read', 73 | { readWith: 'care' } 74 | ); 75 | } else { 76 | neoMessage.outgoingRelationships('read', function(err, readMessages) { 77 | var readWith = ''; 78 | for (var i=0; i 10 | 11 | mongoose = globalOptions.mongoose 12 | graphdb = globalOptions.neo4j 13 | 14 | processtools.setNeo4j graphdb 15 | 16 | #### Adding document methods on node(s) 17 | 18 | # Is needed for prototyping 19 | node = graphdb.createNode() 20 | Node = node.constructor 21 | 22 | # Check that we don't override existing functions 23 | if globalOptions.overrideProtoypeFunctions isnt true 24 | for functionName in [ 'getDocument', 'getMongoId', 'getCollectionName' ] 25 | throw new Error("Will not override neo4j::Node.prototype.#{functionName}") unless typeof node.constructor::[functionName] is 'undefined' 26 | 27 | #### Loads corresponding document from given node object 28 | _loadDocumentFromNode = (node, cb) -> 29 | return cb("No node object given", cb) unless node?._data?.data 30 | _id = new processtools.getObjectIdFromString(node.getMongoId()) 31 | collectionName = node.getCollectionName() 32 | cb(new Error("No cb given", null)) if typeof cb isnt 'function' 33 | # we need to query the collection natively here 34 | # TODO: find a more elegant way to access models instead of needing the "registerModels" way... 35 | collection = processtools.getCollectionByCollectionName(collectionName, mongoose) 36 | collection.findOne { _id: _id }, cb 37 | 38 | #### Loads corresponding document from given neo4j url 39 | _loadDocumentFromNodeUrl = (url, cb) -> 40 | graphdb.getNode url, (err, node) -> 41 | return cb(err, node) if err 42 | _loadDocumentFromNode(node, cb) 43 | 44 | #### Returns the name of the collection from indexed url or from stored key/value 45 | Node::getCollectionName = -> 46 | # try to extract collection from url (indexed namespace) 47 | # TODO: better could be via parent document if exists 48 | # indexed: 'http://localhost:7474/db/data/index/node/people/_id/516123bcc86e28485e000007/755' } 49 | @_data?.indexed?.match(/\/data\/index\/node\/(.+?)\//)?[1] || @_data?.data._collection 50 | 51 | #### Returns the mongodb document _id from stored key/value 52 | Node::getMongoId = -> 53 | # TODO: sometimes node doen't include the data -> would need extra call 54 | # e.g.: _data: { self: 'http://localhost:7474/db/data/node/X' } } 55 | @_data?.data?._id# or null 56 | 57 | #### Loads the node's corresponding document from mongodb 58 | Node::getDocument = (cb) -> 59 | return cb(null, @document) if @document and typeof cb is 'function' 60 | # Native mongodb call, so we need the objectid as object 61 | if @_data?.data?._id 62 | _loadDocumentFromNode @, cb 63 | else 64 | _loadDocumentFromNodeUrl @_data?.self, cb 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Mongraph [mɔ̃ ˈɡrɑːf] 2 | ======== 3 | 4 | [![Build Status](https://api.travis-ci.org/pstaender/mongraph.png)](https://travis-ci.org/pstaender/mongraph) 5 | 6 | Mongraph combines documentstorage database with graph-database relationships by creating a corresponding node for each document. 7 | 8 | **Mongraph is experimental** 9 | 10 | **It's working with Neo4j v2.0.x - v2.1.x and MongoDB v2.x - v3.0** 11 | 12 | ### Installation 13 | 14 | ```sh 15 | $ npm install mongraph 16 | ``` 17 | 18 | or clone the repository to your project and install dependencies with npm: 19 | 20 | ```sh 21 | $ git clone git@github.com:pstaender/mongraph.git 22 | $ cd mongraph && npm install 23 | ``` 24 | 25 | ### What is it good for? 26 | 27 | MongoDB is great for a lot of things but a bit weak at relationships. However Neo4j is very powerful at this point but not the best solution for document storage. So why not using the best of both worlds? 28 | 29 | ### What does it take? 30 | 31 | Every document which is created in MongoDB will have a corresponding node in Neo4j: 32 | 33 | ``` 34 | [{ _id: 5169…2, _node_id: 1 }] -> document in MongoDB 35 | / \ 36 | | 37 | | 38 | \ / 39 | ({ id: 1, data: { _id: 5169…2, _collection: 'people'} }) -> node in Neo4j 40 | ``` 41 | 42 | Each document has an extra attribute: 43 | 44 | * `_node_id` (id of the corresponding node) 45 | 46 | Each node has extra attributes: 47 | 48 | * `_id` (id of the corresponding document) 49 | * `_collection` (name of the collection of the corresponding document) 50 | 51 | Each relationship will store informations about the start- and end-point-document and it's collection: 52 | 53 | ``` 54 | (node#a) - { _from: "people:516…2", _to: "locations:516…3" … } - (node#b) 55 | ``` 56 | 57 | ### What can it do? 58 | 59 | To access the corresponding node: 60 | 61 | ```js 62 | // We can work with relationship after the document is stored in MongoDB 63 | document = new Document({ title: 'Document Title'}); 64 | document.save(function(err, savedDocument){ 65 | savedDocument.log(savedDocument._node_id); // prints the id of the corresponding node 66 | savedDocument.getNode(function(err, correspondingNode){ 67 | console.log(correspondingNode); // prints the node 68 | }); 69 | }); 70 | ``` 71 | 72 | To access the corresponding document: 73 | 74 | ```js 75 | console.log(node.data._id); // prints the id of the corresponding document 76 | console.log(node.data._collection); // prints the collection name of the corresponding 77 | node.getDocument(function(err, correspondingDocument){ 78 | console.log(correspondingDocument); // prints the document 79 | }); 80 | ``` 81 | 82 | You can create relationships between documents like you can do in Neo4j with nodes: 83 | 84 | ```js 85 | // create an outgoing relationship to another document 86 | // please remember that we have to work here always with callbacks... 87 | // that's why we are having the streamline placeholder here `_` (better to read) 88 | document.createRelationshipTo( 89 | otherDocument, 'similar', { category: 'article' }, _ 90 | ); 91 | // create an incoming relationship from another document 92 | document.createRelationshipFrom( 93 | otherDocument, 'similar', { category: 'article' }, _ 94 | ); 95 | // create a relationship between documents (bidirectional) 96 | document.createRelationshipBetween( 97 | otherDocument, 'similar', { category: 'article'}, _ 98 | ); 99 | ``` 100 | 101 | You can get and remove relationships from documents like you can do in Neo4j: 102 | 103 | ```js 104 | // get all documents which are pointing via 'view' 105 | document.incomingRelationships('view', _); 106 | // get all documents that are connected with 'view' (bidirectional) 107 | document.allRelationships('view', _); 108 | // same between documents 109 | document.allRelationshipsBetween(otherDocument, 'view', _); 110 | document.incomingRelationshipsFrom(otherDocument, 'view', _); 111 | document.outgoingRelationshipsTo(otherDocument, 'view', _); 112 | ``` 113 | 114 | You can filter the documents (mongodb) **and** the relationships (neo4j): 115 | 116 | ```js 117 | // get all similar documents where title starts with an uppercase 118 | // and that are connected with the attribute `scientific report` 119 | document.incomingRelationships( 120 | 'similar', 121 | { 122 | where: { 123 | document: { 124 | // we can query with the familiar mongodb query 125 | title: /^[A-Z]/ 126 | }, 127 | // queries on graph are strings, because they are passed trough the cypher query directly for now 128 | // here: relationship objects are accessible as `r` by default, start node as `a` and end node (if is queried) as `b` 129 | relationship: "r.category! = 'scientific report'" 130 | } 131 | }, _ 132 | ); 133 | ``` 134 | 135 | You can also make your custom graph queries: 136 | 137 | ```js 138 | document.queryGraph( 139 | "START a = node(1), b = node(2) MATCH path = shortestPath( a-[*..5]->b ) RETURN path;", 140 | { processPart: 'path' }, 141 | function(err, path, options) { … } 142 | ); 143 | ``` 144 | 145 | To get more informations about made queries (and finally used options) inspect the passed through options argument (`debug: true` enables logging of queries): 146 | 147 | ```js 148 | document.incomingRelationships( 149 | 'similar', { debug: true }, function(err, found, options) { 150 | // prints out finally used options and - if set to `true` - additional debug informations 151 | console.log(options.debug); 152 | // { cypher: [ "START … MATCH …" , …] … }} 153 | } 154 | ); 155 | ``` 156 | 157 | ### Store in mongodb and neo4j simultaneously 158 | 159 | Since v0.1.15 you can store defined properties from mongodb documents in the corresponding nodes in neo4j. It might be a matter of opinion whether it's a good idea to store data redundant in two database system, anyway mongraph provides a tool to automate this process. 160 | 161 | You need to provide the requested fields in your mongoose schemas with a `graph = true` option. Please note: If the property includes the `index = true` option (used in mongoose to index property in mongodb) this field will be also indexed in the graphdatabase. 162 | 163 | Since neo4j nodes store only non nested objects, your object will be flatten; e.g.: 164 | 165 | ```js 166 | 167 | data = { 168 | property: { 169 | subproperty: true 170 | } 171 | }; 172 | // will become 173 | // data['property.subproperty'] = true 174 | ``` 175 | 176 | ```js 177 | messageSchema = new mongoose.Schema({ 178 | text: { 179 | title: { 180 | type: String, 181 | graph: true // field / value will be stored in neo4j 182 | index: true, // will be an indexed in neo4j as well 183 | }, 184 | content: String 185 | }, 186 | from: { 187 | type: String, 188 | graph: true // field / value will be stored in neo4j, but not be indexed 189 | } 190 | }); 191 | ``` 192 | 193 | ### Your documents + nodes on neo4j 194 | 195 | By default all corresponding nodes are created indexed with the collection-name and the _id, so that you can easily access them through neo4j, e.g.: 196 | 197 | ``` 198 | http://localhost:7474/db/data/index/node/people/_id/5178fc1f6955993a25004711 199 | ``` 200 | 201 | ### Requirements 202 | 203 | #### Databases: 204 | 205 | * MongoDB (>2) 206 | * Neo4j (>2 and <2.2) 207 | 208 | #### NPM modules: 209 | 210 | * mongoose ORM `npm install mongoose` 211 | * Neo4j REST API client by thingdom `npm install neo4j` 212 | 213 | ### Changelogs 214 | 215 | #### 0.1.14 216 | 217 | * **API Change:** the collection of the corresponding document will be stored from now on as `_collection` instead of `collection` in each node. e.g.: `node -> { data: { _id: 5ef6…, _collection: 'people' } }`, reason: continious name conventions in node-, document-, relationship- + path objects 218 | 219 | #### 0.2.0 220 | 221 | * removed legacy node modules (source mapping for instance) 222 | * ignoring neo4j v<2 223 | * tested sucessfully against mongodb 2.6.x and Neo4J v2.0.x and v2.1.x 224 | 225 | ### Testing 226 | 227 | Not ready for Neo4j v2.2 since the [neo4j module](https://github.com/thingdom/node-neo4j) for the latest Neo4j version is still under development. 228 | 229 | Older Neo4j version than 2.x are not supported anymore. 230 | 231 | To run tests: 232 | 233 | ```sh 234 | $ mocha 235 | ``` 236 | 237 | Or specify a port for neo4j (default is set to `7474`): 238 | 239 | ```sh 240 | $ mocha --globals=7010 241 | ``` 242 | 243 | This will run the tests against neo4j db on port 7010 - `globals` is a mocha specific argument which is used abusively as a workaround here ;) 244 | 245 | ### License 246 | 247 | See [License file](https://github.com/pstaender/mongraph/blob/master/LICENSE). 248 | 249 | ### Contributors 250 | 251 | * [Marian C Moldovan](https://github.com/beeva-marianmoldovan) 252 | * [Robert Klep](https://github.com/robertklep) 253 | * [Joaquin Navarro](https://github.com/beeva-joaquinnavarro) 254 | 255 | ### Known issues and upcoming features 256 | 257 | * process tools should avoid loading all documents on specific mongodb queries -> more effective queries 258 | * using document `_id` as primary key in neo4j as well (in other words, drop support for `node_id` / `id`) 259 | * using labels-feature for nodes (neo4j 2.0+) instead of `_collection` property 260 | * dump and restore of relationships 261 | * real-life benchmarks 262 | -------------------------------------------------------------------------------- /src/processtools.coffee: -------------------------------------------------------------------------------- 1 | ObjectId = require('bson').ObjectID 2 | Join = require('join') 3 | extendRelationship = require('./extendRelationship').extend 4 | extendPath = require('./extendPath').extend 5 | 6 | # private 7 | # dbhandler 8 | mongoose = null 9 | neo4j = null 10 | 11 | setMongoose = (mongooseHandler) -> mongoose = mongooseHandler 12 | getMongoose = -> mongoose 13 | 14 | setNeo4j = (neo4jHandler) -> neo4j = neo4jHandler 15 | getNeo4j = -> neo4j 16 | 17 | sortOptionsAndCallback = (options, cb) -> 18 | if typeof options is 'function' 19 | { options: {}, cb: options } 20 | else 21 | { options: options or {}, cb: cb } 22 | 23 | sortAttributesAndCallback = (attributes, cb) -> 24 | {options,cb} = sortOptionsAndCallback(attributes, cb) 25 | { attributes: options, cb: cb} 26 | 27 | sortJoins = (args) -> 28 | args = Array.prototype.slice.call(args) 29 | returns = { errors: [] , result: [] } 30 | for arg in args 31 | returns.errors.push(arg[0]) if arg[0] 32 | returns.errors.push(arg[1]) if arg[1] 33 | returns.errors = if returns.errors.length > 0 then Error(returns.errors.join(", ")) else null 34 | returns.result = if returns.result.length > 0 then returns.result else null 35 | returns 36 | 37 | sortTypeOfRelationshipAndOptionsAndCallback = (r, o, c) -> 38 | returns = { typeOfRelationship: '*', options: {}, cb: null } 39 | if typeof r is 'string' 40 | returns.typeOfRelationship = r 41 | {options,cb} = sortOptionsAndCallback(o,c) 42 | returns.options = options 43 | returns.cb = cb 44 | else if typeof r is 'object' 45 | {options,cb} = sortOptionsAndCallback(r,o) 46 | returns.options = options 47 | returns.cb = cb 48 | else 49 | returns.cb = r 50 | returns 51 | 52 | # extract the constructor name as string 53 | constructorNameOf = (f) -> 54 | f?.constructor?.toString().match(/function\s+(.+?)\(/)?[1]?.trim() || null 55 | 56 | extractCollectionAndId = (s) -> 57 | { collectionName: parts[0], _id: parts[1] } if (parts = s.split(":")) 58 | 59 | _buildQueryFromIdAndCondition = (_id_s, condition) -> 60 | if _id_s?.constructor is Array 61 | idCondition = { _id: { $in: _id } } 62 | else if _id_s 63 | idCondition = { _id: String(_id_s) } 64 | else 65 | return {} 66 | if typeof condition is 'object' and condition and Object.keys(condition)?.length > 0 then { $and: [ idCondition, condition ] } else idCondition 67 | 68 | # extract id as string from a mixed argument 69 | getObjectIDAsString = (obj) -> 70 | if typeof obj is 'string' 71 | obj 72 | else if typeof obj is 'object' 73 | (String) obj._id or obj 74 | else 75 | '' 76 | 77 | getObjectIdFromString = (s) -> 78 | new ObjectId(s) 79 | 80 | # extract id's from a mixed type 81 | getObjectIDsAsArray = (mixed) -> 82 | ids = [] 83 | if mixed?.constructor == Array 84 | for item in mixed 85 | ids.push(id) if id = getObjectIDAsString(item) 86 | else 87 | ids = [ getObjectIDAsString(mixed) ] 88 | ids 89 | 90 | getModelByCollectionName = (collectionName, mongooseHandler = mongoose) -> 91 | if constructorNameOf(mongooseHandler) is 'Mongoose' 92 | models = mongooseHandler.models 93 | else unless mongooseHandler 94 | return null 95 | else 96 | # we assume that we have mongoose.models here 97 | models = mongoose 98 | name = null 99 | for nameOfModel, i of models 100 | # iterate through models and find the corresponding collection and modelname 101 | if collectionName is models[nameOfModel].collection.name 102 | name = models[nameOfModel]#.modelName 103 | name 104 | 105 | getModelNameByCollectionName = (collectionName, mongooseHandler = mongoose) -> 106 | getModelByCollectionName(collectionName, mongooseHandler)?.modelName 107 | 108 | getCollectionByCollectionName = (collectionName, mongooseHandler = mongoose) -> 109 | modelName = getModelNameByCollectionName(collectionName, mongooseHandler) 110 | mongooseHandler.models[modelName] or mongooseHandler.connections[0]?.collection(collectionName) or mongooseHandler.collection(collectionName) 111 | 112 | # Iterates through the neo4j's resultset and attach documents from mongodb 113 | # ===== 114 | # 115 | # Currently we having three different of expected Objects: Node, Relationship and Path 116 | # TODO: maybe split up to submethods for each object type 117 | # TODO: reduce mongodb queries by sorting ids to collection(s) and query them once per collection with $in : [ ids... ] ... 118 | 119 | populateResultWithDocuments = (results, options, cb) -> 120 | {options, cb} = sortOptionsAndCallback(options,cb) 121 | 122 | options.count ?= false 123 | options.restructure ?= true # do some useful restructure 124 | options.referenceDocumentID ?= null # document which is our base document, import for where queries 125 | options.referenceDocumentID = String(options.referenceDocumentID) if options.referenceDocumentID 126 | options.relationships ?= {} 127 | options.collection ?= null # distinct collection 128 | options.where?.document ?= null # query documents 129 | options.debug?.where ?= [] 130 | options.stripEmptyItems ?= true 131 | 132 | 133 | unless results instanceof Object 134 | return cb(new Error('Object is needed for processing'), null, options) 135 | else unless results instanceof Array 136 | # put in array to iterate 137 | results = [ results ] 138 | 139 | # Finally called when *all* documents are loaded and we can pass the result to cb 140 | final = (err) -> 141 | # [ null, {...}, null, ..., {...}, {...} ] -> [ {...}, ..., {...}, {...} ] 142 | # return only path if we have a path here and the option is set to restructre 143 | # TODO: find a more elegant solution than this 144 | if options.restructure and path?.length > 0 145 | results = path 146 | if options.stripEmptyItems and results?.length > 0 147 | cleanedResults = [] 148 | for result in results 149 | cleanedResults.push(result) if result? 150 | cb(null, cleanedResults, options) if typeof cb is 'function' 151 | else 152 | cb(null, results, options) if typeof cb is 'function' 153 | 154 | # TODO: if distinct collection 155 | 156 | mongoose = getMongoose() # get mongoose handler 157 | graphdb = getNeo4j() # get neo4j handler 158 | 159 | 160 | # TODO: extend Path and Relationship objects (nit possible with prototyping here) 161 | 162 | path = null 163 | 164 | join = Join.create() 165 | for result, i in results 166 | do (result, i) -> 167 | 168 | # ### NODE 169 | if constructorNameOf(result) is 'Node' and result.data?._collection and result.data?._id 170 | callback = join.add() 171 | isReferenceDocument = options.referenceDocumentID is result.data._id 172 | # skip if distinct collection if differ 173 | if options.collection and options.collection isnt result.data._collection 174 | callback(err, results) 175 | else 176 | conditions = _buildQueryFromIdAndCondition(result.data._id, unless isReferenceDocument then options.where?.document) 177 | options.debug?.where.push(conditions) 178 | collection = getCollectionByCollectionName(result.data._collection, mongoose) 179 | collection.findOne conditions, (err, foundDocument) -> 180 | results[i].document = foundDocument 181 | callback(err, results) 182 | 183 | # ### RELATIONSHIP 184 | else if constructorNameOf(result) is 'Relationship' and result.data?._from and result.data?._to 185 | # TODO: trigger updateRelationships for both sides if query was about and option is set to 186 | callback = join.add() 187 | fromAndToJoin = Join.create() 188 | # Extend out Relationship object with additional methods 189 | extendRelationship(result) 190 | for point in [ 'from', 'to'] 191 | intermediateCallback = fromAndToJoin.add() 192 | do (point, intermediateCallback) -> 193 | {collectionName,_id} = extractCollectionAndId(result.data["_#{point}"]) 194 | isReferenceDocument = options.referenceDocumentID is _id 195 | # do we have a distinct collection and this records is from another collection? skip if so 196 | if options.collection and options.collection isnt collectionName and not isReferenceDocument 197 | # remove relationship from result 198 | results[i] = null 199 | intermediateCallback(null,null) # results will be taken directly from results[i] 200 | else 201 | conditions = _buildQueryFromIdAndCondition(_id, unless isReferenceDocument then options.where?.document) 202 | options.debug?.where?.push(conditions) 203 | collection = getCollectionByCollectionName(collectionName, mongoose) 204 | collection.findOne conditions, (err, foundDocument) -> 205 | if foundDocument and results[i] 206 | results[i][point] = foundDocument 207 | else 208 | # remove relationship from result 209 | results[i] = null 210 | intermediateCallback(null,null) # results will be taken directly from results[i] 211 | fromAndToJoin.when -> 212 | callback(null, null) 213 | 214 | # ### PATH 215 | else if constructorNameOf(result) is 'Path' or constructorNameOf(result[options.processPart]) is 'Path' or constructorNameOf(result.path) is 'Path' 216 | # Define an object identifier for processPart 217 | _p = result[options.processPart] || result.path || result 218 | extendPath(_p) 219 | results[i].path = Array(_p._nodes.length) 220 | path = if options.restructure then Array(_p._nodes.length) 221 | for node, k in _p._nodes 222 | if node._data?.self 223 | callback = join.add() 224 | do (k, callback) -> 225 | graphdb.getNode node._data.self, (err, foundNode) -> 226 | if foundNode?.data?._id 227 | isReferenceDocument = options.referenceDocumentID is foundNode.data._id 228 | collectionName = foundNode.data._collection 229 | _id = foundNode.data._id 230 | if options.collection and options.collection isnt collectionName and not isReferenceDocument 231 | callback(null, path || results) 232 | else 233 | conditions = _buildQueryFromIdAndCondition(_id, options.where?.document) 234 | options.debug?.where?.push(conditions) 235 | collection = getCollectionByCollectionName(collectionName, mongoose) 236 | collection.findOne conditions, (err, foundDocument) -> 237 | if options.restructure 238 | # just push the documents to the result and leave everything else away 239 | path[k] = foundDocument 240 | else 241 | results[i].path[k] = foundDocument 242 | callback(null, path || results) 243 | else 244 | if options.restructure 245 | path[k] = null 246 | else 247 | results[i].path[k] = null 248 | callback(null, path || results) 249 | else 250 | final(new Error("Could not detect given result type"),null) 251 | 252 | # ### If all callbacks are fulfilled 253 | 254 | join.when -> 255 | {error,result} = sortJoins(arguments) 256 | final(error, null) 257 | 258 | module.exports = { 259 | populateResultWithDocuments, 260 | getObjectIDAsString, 261 | getObjectIDsAsArray, 262 | constructorNameOf, 263 | getObjectIdFromString, 264 | sortOptionsAndCallback, 265 | sortAttributesAndCallback, 266 | sortTypeOfRelationshipAndOptionsAndCallback, 267 | getModelByCollectionName, 268 | getModelNameByCollectionName, 269 | getCollectionByCollectionName, 270 | setMongoose, 271 | setNeo4j, 272 | extractCollectionAndId, 273 | ObjectId } 274 | 275 | -------------------------------------------------------------------------------- /src/extendDocument.coffee: -------------------------------------------------------------------------------- 1 | # ### Extend Document 2 | # 3 | # This models extends the mongodb/mongoose Document with: 4 | # * allows creating, deleting and querying all kind of incoming and outgoing relationships 5 | # * native queries on neo4j with option to load Documents by default 6 | # * connects each Document with corresponding Node in neo4j 7 | # 8 | # TODO: check that we always get Documents as mongoose models 9 | 10 | _s = require('underscore.string') 11 | processtools = require('./processtools') 12 | Join = require('join') 13 | 14 | module.exports = (globalOptions) -> 15 | 16 | mongoose = globalOptions.mongoose 17 | graphdb = globalOptions.neo4j 18 | 19 | # Check that we don't override existing functions 20 | if globalOptions.overrideProtoypeFunctions isnt true 21 | for functionName in [ 22 | 'applyGraphRelationships', 23 | 'removeNode', 24 | 'shortestPathTo', 25 | 'allRelationshipsBetween', 26 | 'incomingRelationshipsFrom', 27 | 'outgoingRelationshipsTo', 28 | 'removeRelationships', 29 | 'removeRelationshipsBetween', 30 | 'removeRelationshipsFrom', 31 | 'removeRelationshipsTo', 32 | 'outgoingRelationships', 33 | 'incomingRelationships', 34 | 'allRelationships', 35 | 'queryRelationships', 36 | 'queryGraph', 37 | 'createRelationshipBetween', 38 | 'createRelationshipFrom', 39 | 'createRelationshipTo', 40 | 'getNodeId', 41 | 'findOrCreateCorrespondingNode', 42 | 'findCorrespondingNode', 43 | 'dataForNode', 44 | 'indexGraph' 45 | ] 46 | throw new Error("Will not override mongoose::Document.prototype.#{functionName}") unless typeof mongoose.Document::[functionName] is 'undefined' 47 | 48 | Document = mongoose.Document 49 | 50 | processtools.setMongoose mongoose 51 | 52 | node = graphdb.createNode() 53 | 54 | #### Allows extended querying to the graphdb and loads found Documents 55 | #### (is used by many methods for loading incoming + outgoing relationships) 56 | # @param typeOfRelationship = '*' (any relationship you can query with cypher, e.g. KNOW, LOVE|KNOW ...) 57 | # @param options = {} 58 | # (first value is default) 59 | # * direction (both|incoming|outgoing) 60 | # * action: (RETURN|DELETE|...) (all other actions wich can be used in cypher) 61 | # * processPart: (relationship|path|...) (depends on the result you expect from our query) 62 | # * loadDocuments: (true|false) 63 | # * endNode: '' (can be a node object or a nodeID) 64 | Document::queryRelationships = (typeOfRelationship, options, cb) -> 65 | return cb(Error('No graphability enabled'), null) unless @schema.get('graphability') 66 | # REMOVED: options can be a cypher query as string 67 | # options = { query: options } if typeof options is 'string' 68 | {typeOfRelationship,options, cb} = processtools.sortTypeOfRelationshipAndOptionsAndCallback(typeOfRelationship,options,cb) 69 | # build query from options 70 | typeOfRelationship ?= '*' 71 | typeOfRelationship = if /^[*:]{1}$/.test(typeOfRelationship) or not typeOfRelationship then '' else ':'+typeOfRelationship 72 | options.direction ?= 'both' 73 | options.action ?= 'RETURN' 74 | if options.count or options.countDistinct 75 | options.count = 'distinct '+options.countDistinct if options.countDistinct 76 | options.returnStatement = 'count('+options.count+')' 77 | options.processPart = 'count('+options.count+')' 78 | options.processPart ?= 'r' 79 | options.returnStatement ?= options.processPart 80 | options.referenceDocumentID ?= @_id 81 | # endNode can be string or node object 82 | options.endNodeId = endNode.id if typeof endNode is 'object' 83 | options.debug = {} if options.debug is true 84 | doc = @ 85 | id = processtools.getObjectIDAsString(doc) 86 | @getNode (nodeErr, fromNode) -> 87 | # if no node is found 88 | return cb(nodeErr, null, options) if nodeErr 89 | 90 | 91 | 92 | cypher = """ 93 | START a = node(%(id)s)%(endNodeId)s 94 | MATCH (a)%(incoming)s[r%(relation)s]%(outgoing)s(b) 95 | %(whereRelationship)s 96 | %(action)s %(returnStatement)s; 97 | """ 98 | 99 | 100 | 101 | cypher = _s.sprintf cypher, 102 | id: fromNode.id 103 | incoming: if options.direction is 'incoming' then '<-' else '-' 104 | outgoing: if options.direction is 'outgoing' then '->' else '-' 105 | relation: typeOfRelationship 106 | action: options.action.toUpperCase() 107 | returnStatement: options.returnStatement 108 | whereRelationship: if options.where?.relationship then "WHERE #{options.where.relationship}" else '' 109 | endNodeId: if options.endNodeId? then ", b = node(#{options.endNodeId})" else '' 110 | options.startNode ?= fromNode.id # for logging 111 | 112 | 113 | # take query from options and discard build query 114 | cypher = options.cypher if options.cypher 115 | options.debug?.cypher ?= [] 116 | options.debug?.cypher?.push(cypher) 117 | if options.dontExecute 118 | cb(Error("`options.dontExecute` is set to true..."), null, options) 119 | else 120 | _queryGraphDB(cypher, options, cb) 121 | 122 | 123 | #### Loads the equivalent node to this Document 124 | Document::findCorrespondingNode = (options, cb) -> 125 | {options, cb} = processtools.sortOptionsAndCallback(options,cb) 126 | return cb(Error('No graphability enabled'), null) unless @schema.get('graphability') 127 | doc = @ 128 | 129 | # you can force a reloading of a node 130 | # so you can ensure to get the latest existing node directly from db 131 | options.forceReload ?= false 132 | 133 | return cb(null, doc._cached_node, options) if globalOptions.cacheAttachedNodes and doc._cached_node and options.forceReload isnt true 134 | 135 | collectionName = doc.constructor.collection.name 136 | id = processtools.getObjectIDAsString(doc) 137 | 138 | # Difference between doCreateIfNotExists and forceCreation: 139 | # 140 | # * doCreateIfNotExists -> persist the node if no corresponding node exists 141 | # * forceCreation -> forces to create a node 142 | # 143 | # @forceCreation: this is needed because mongoose marks each document as 144 | # doc.new = true (which is checked to prevent accidently creating orphaned nodes). 145 | # As long it is 'init' doc.new stays true, but we need that to complete the 'pre' 'save' hook 146 | # (see -> mongraphMongoosePlugin) 147 | 148 | options.doCreateIfNotExists ?= false 149 | options.forceCreation ?= false 150 | 151 | # Find equivalent node in graphdb 152 | 153 | # TODO: cache existing node 154 | 155 | _processNode = (node, doc, cb) -> 156 | # store document data also als in node -> untested and not recommend 157 | # known issue: neo4j doesn't store deeper levels of nested objects... 158 | if globalOptions.storeDocumentInGraphDatabase 159 | node.data = doc.toObject(globalOptions.storeDocumentInGraphDatabase) 160 | node.save() 161 | # store node_id on document 162 | doc._node_id = node.id 163 | doc._cached_node = node if globalOptions.cacheAttachedNodes 164 | cb(null, node, options) 165 | 166 | if doc.isNew is true and options.forceCreation isnt true 167 | cb(new Error("Can't return a 'corresponding' node of an unpersisted document"), null, options) 168 | else if doc._node_id? 169 | graphdb.getNodeById doc._node_id, (errFound, node) -> 170 | if errFound 171 | cb(errFound, node, options) 172 | else 173 | _processNode(node,doc,cb) 174 | else if options.doCreateIfNotExists or options.forceCreation is true 175 | # create a new one 176 | node = graphdb.createNode( _id: id, _collection: collectionName ) 177 | node.save (errSave, node) -> 178 | if errSave 179 | cb(errSave, node, options) 180 | else 181 | # do index for better queries outside mongraph 182 | # e.g. people/_id/5178fb1b48c7a4ae24000001 183 | node.index collectionName, '_id', id, -> 184 | _processNode(node, doc, cb) 185 | else 186 | cb(null, null, options) 187 | 188 | #### Finds or create equivalent Node to this Document 189 | Document::findOrCreateCorrespondingNode = (options, cb) -> 190 | {options, cb} = processtools.sortOptionsAndCallback(options,cb) 191 | @findCorrespondingNode(options, cb) 192 | 193 | # Recommend to use this method instead of `findOrCreateCorrespondingNode` 194 | # shortcutmethod -> findOrCreateCorrespondingNode 195 | Document::getNode = Document::findOrCreateCorrespondingNode 196 | 197 | 198 | #### Finds and returns id of corresponding Node 199 | # Faster, because it returns directly from document if stored (see -> mongraphMongoosePlugin) 200 | Document::getNodeId = (cb) -> 201 | if @_node_id? 202 | cb(null, @_node_id) 203 | else 204 | @getNode (err, node) -> 205 | cb(err, node?.id || null) 206 | 207 | #### Creates a relationship from this Document to a given document 208 | Document::createRelationshipTo = (doc, typeOfRelationship, attributes = {}, cb) -> 209 | {attributes,cb} = processtools.sortAttributesAndCallback(attributes,cb) 210 | return cb(Error('No graphability enabled'), null) unless @schema.get('graphability') 211 | # assign cb + attribute arguments 212 | if typeof attributes is 'function' 213 | cb = attributes 214 | attributes = {} 215 | # Is needed to load the records from mongodb 216 | # TODO: Currently we have to store these information redundant because 217 | # otherwise we would have to request each side for it's represantive node 218 | # seperately to get the information wich namespace/collection the mongodb records is stored 219 | # --> would increase requests to neo4j 220 | if globalOptions.relationships.storeIDsInRelationship 221 | attributes._to ?= doc.constructor.collection.name + ":" + (String) doc._id 222 | attributes._from ?= @constructor.collection.name + ":" + (String) @._id 223 | 224 | if globalOptions.relationships.storeTimestamp 225 | attributes._created_at ?= Math.floor(Date.now()/1000) 226 | 227 | # Get both nodes: "from" node (this document) and "to" node (given as 1st argument) 228 | @findOrCreateCorrespondingNode (fromErr, from) -> 229 | doc.findOrCreateCorrespondingNode (toErr, to) -> 230 | if from and to 231 | from.createRelationshipTo to, typeOfRelationship, attributes, (err, result) -> 232 | return cb(err, result) if err 233 | processtools.populateResultWithDocuments result, {}, cb 234 | else 235 | cb(fromErr or toErr, null) if typeof cb is 'function' 236 | 237 | #### Creates an incoming relationship from a given Documents to this Document 238 | Document::createRelationshipFrom = (doc, typeOfRelationship, attributes = {}, cb) -> 239 | {attributes,cb} = processtools.sortAttributesAndCallback(attributes,cb) 240 | # alternate directions: doc -> this 241 | doc.createRelationshipTo(@, typeOfRelationship, attributes, cb) 242 | 243 | #### Creates a bidrectional relationship between two Documents 244 | Document::createRelationshipBetween = (doc, typeOfRelationship, attributes = {}, cb) -> 245 | # both directions 246 | {attributes,cb} = processtools.sortAttributesAndCallback(attributes,cb) 247 | from = @ 248 | to = doc 249 | from.createRelationshipTo to, typeOfRelationship, (err1) -> to.createRelationshipTo from, typeOfRelationship, (err2) -> 250 | cb(err1 || err2, null) 251 | 252 | #### Query the graphdb with cypher, current Document is not relevant for the query 253 | Document::queryGraph = (chypherQuery, options, cb) -> 254 | {options, cb} = processtools.sortOptionsAndCallback(options,cb) 255 | doc = @ 256 | _queryGraphDB(chypherQuery, options, cb) 257 | 258 | #### Loads incoming and outgoing relationships 259 | Document::allRelationships = (typeOfRelationship, options, cb) -> 260 | {typeOfRelationship, options, cb} = processtools.sortTypeOfRelationshipAndOptionsAndCallback(typeOfRelationship, options, cb) 261 | options.direction = 'both' 262 | options.referenceDocumentID = @_id 263 | @queryRelationships(typeOfRelationship, options, cb) 264 | 265 | #### Loads in+outgoing relationships between to documents 266 | Document::allRelationshipsBetween = (to, typeOfRelationship, options, cb) -> 267 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 268 | from = @ 269 | options.referenceDocumentID ?= from._id 270 | options.direction ?= 'both' 271 | to.getNode (err, endNode) -> 272 | return cb(Error('-> toDocument has no corresponding node',null)) unless endNode 273 | options.endNodeId = endNode.id 274 | from.queryRelationships(typeOfRelationship, options, cb) 275 | 276 | #### Loads incoming relationships between to documents 277 | Document::incomingRelationshipsFrom = (to, typeOfRelationship, options, cb) -> 278 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 279 | options.direction = 'incoming' 280 | @allRelationshipsBetween(to, typeOfRelationship, options, cb) 281 | 282 | #### Loads outgoin relationships between to documents 283 | Document::outgoingRelationshipsTo = (to, typeOfRelationship, options, cb) -> 284 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 285 | options.direction = 'outgoing' 286 | @allRelationshipsBetween(to, typeOfRelationship, options, cb) 287 | 288 | #### Loads incoming relationships 289 | Document::incomingRelationships = (typeOfRelationship, options, cb) -> 290 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 291 | options.direction = 'incoming' 292 | options.referenceDocumentID = @_id 293 | @queryRelationships(typeOfRelationship, options, cb) 294 | 295 | #### Loads outgoing relationships 296 | Document::outgoingRelationships = (typeOfRelationship, options, cb) -> 297 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 298 | options.direction = 'outgoing' 299 | options.referenceDocumentID = @_id 300 | @queryRelationships(typeOfRelationship, options, cb) 301 | 302 | #### Remove outgoing relationships to a specific Document 303 | Document::removeRelationshipsTo = (doc, typeOfRelationship, options, cb) -> 304 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 305 | options.direction ?= 'outgoing' 306 | options.action = 'DELETE' 307 | from = @ 308 | doc.getNode (nodeErr, endNode) -> 309 | return cb(nodeErr, endNode) if nodeErr 310 | options.endNodeId = endNode.id 311 | from.queryRelationships typeOfRelationship, options, cb 312 | 313 | #### Removes incoming relationships to a specific Document 314 | Document::removeRelationshipsFrom = (doc, typeOfRelationship, options, cb) -> 315 | to = @ 316 | doc.removeRelationshipsTo to, typeOfRelationship, options, cb 317 | 318 | #### Removes incoming ad outgoing relationships between two Documents 319 | Document::removeRelationshipsBetween = (doc, typeOfRelationship, options, cb) -> 320 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 321 | options.direction = 'both' 322 | @removeRelationshipsTo(doc, typeOfRelationship, options, cb) 323 | 324 | #### Removes incoming and outgoing relationships to all Documents (useful bevor deleting a node/document) 325 | Document::removeRelationships = (typeOfRelationship, options, cb) -> 326 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 327 | options.direction = 'both' 328 | options.action = 'DELETE' 329 | @queryRelationships typeOfRelationship, options, cb 330 | 331 | #### Delete node including all incoming and outgoing relationships 332 | Document::removeNode = (options, cb) -> 333 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 334 | return cb(Error('No graphability enabled'), null) unless @schema.get('graphability') 335 | # we don't distinguish between incoming and outgoing relationships here 336 | # would it make sense?! not sure... 337 | options.includeRelationships ?= true 338 | doc = @ 339 | doc.getNode (err, node) -> 340 | # if we have an error or no node found (as expected) 341 | if err or typeof node isnt 'object' 342 | return cb(err || new Error('No corresponding node found to document #'+doc._id), node) if typeof cb is 'function' 343 | else 344 | cypher = """ 345 | START n = node(#{node.id}) 346 | OPTIONAL MATCH n-[r]-() 347 | DELETE n#{if options.includeRelationships then ', r' else ''} 348 | """ 349 | _queryGraphDB(cypher, options, cb) 350 | 351 | #### Returns the shortest path between this and another document 352 | Document::shortestPathTo = (doc, typeOfRelationship = '', options, cb) -> 353 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 354 | return cb(Error('No graphability enabled'), null) unless @schema.get('graphability') 355 | from = @ 356 | to = doc 357 | from.getNode (errFrom, fromNode) -> to.getNode (errTo, toNode) -> 358 | return cb(new Error("Problem(s) getting from and/or to node")) if errFrom or errTo or not fromNode or not toNode 359 | levelDeepness = 15 360 | query = """ 361 | START a = node(#{fromNode.id}), b = node(#{toNode.id}) 362 | MATCH path = shortestPath( a-[#{if typeOfRelationship then ':'+typeOfRelationship else ''}*..#{levelDeepness}]->b ) 363 | RETURN path; 364 | """ 365 | options.processPart = 'path' 366 | from.queryGraph(query, options, cb) 367 | 368 | Document::dataForNode = (options = {}) -> 369 | self = @ 370 | {index} = options 371 | index ?= false # returns fields for indexing if set to true; maybe as own method later 372 | paths = self.schema.paths 373 | flattenSeperator = '.' # make it configurable?! 374 | values = {} 375 | indexes = [] 376 | for path of paths 377 | definition = paths[path] 378 | if index 379 | indexes.push(path.split('.').join(flattenSeperator)) if definition.options?.graph is true and definition.options?.index is true 380 | else if definition.options?.graph is true 381 | values[path.split('.').join(flattenSeperator)] = self.get(path) 382 | if index 383 | indexes 384 | else if Object.keys(values).length > 0 385 | values 386 | else 387 | null 388 | 389 | Document::indexGraph = (options, cb) -> 390 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 391 | doc = @ 392 | node = options.node or doc._cached_node 393 | index = doc.dataForNode(index: true) 394 | 395 | return cb(Error('No node attached'), null) unless node 396 | return cb(Error('No field(s) to index'), null) unless index.length > 0 397 | 398 | join = Join.create() 399 | collectionName = doc.constructor.collection.name 400 | 401 | for pathToIndex in index 402 | value = doc.get(pathToIndex) 403 | # index if have a value 404 | node.index(collectionName, pathToIndex, value, join.add()) if typeof value isnt 'undefined' 405 | 406 | join.when -> 407 | cb(arguments[0], arguments[1]) if typeof cb is 'function' 408 | 409 | 410 | # TODO: refactor -> split into more methods 411 | 412 | Document::applyGraphRelationships = (options, cb) -> 413 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 414 | return cb(Error('No graphability enabled'), null) unless @schema.get('graphability') 415 | # relationships will be stored permanently on this document 416 | # not for productive usage 417 | # -> it's deactivated by default, because I'm not sure that it'a good idea 418 | # to store informations redundant (CAP/syncing) 419 | options.doPersist ?= false 420 | sortedRelationships = {} 421 | typeOfRelationship = '*' # TODO: make optional 422 | doc = @ 423 | 424 | _finally = (err, result, options) -> 425 | doc._relationships = sortedRelationships # attach to current document 426 | cb(err, doc._relationships, options) if typeof cb is 'function' 427 | 428 | doc.getNode options, (err, node, options) -> 429 | return _finally(err, node, options) if err 430 | doc.allRelationships typeOfRelationship, options, (err, relationships, options) -> 431 | return _finally(err, relationships, options) if err 432 | if relationships?.length > 0 433 | # add relationships to object, sorted by type (see above for schema) 434 | for relation in relationships 435 | if relation._data?.type 436 | data = {} 437 | for part in [ 'from', 'to' ] 438 | {collectionName,_id} = processtools.extractCollectionAndId(relation.data["_#{part}"]) 439 | data[part] = 440 | collection: collectionName 441 | _id: processtools.ObjectId(_id) 442 | sortedRelationships[relation._data.type] ?= [] 443 | sortedRelationships[relation._data.type].push(data) 444 | doc._relationships = sortedRelationships 445 | if typeOfRelationship is '*' 446 | conditions = { _relationships: sortedRelationships } 447 | # update all -> slower 448 | if options.doPersist 449 | options?.debug?.where.push(conditions) 450 | doc.update conditions, (err, result) -> _finally(err,result,options) 451 | else 452 | _finally(err,null,options) 453 | else 454 | key = '_relationships.'+typeOfRelationship 455 | update = {} 456 | update[key] = sortedRelationships[typeOfRelationship] 457 | conditions = update 458 | options?.debug?.where.push(conditions) 459 | if sortedRelationships[typeOfRelationship]? 460 | doc.update conditions, (err, result) -> _finally(err,result,options) 461 | else 462 | # remove/unset attribute 463 | update[key] = 1 # used to get mongodb query like -> { $unset: { key: 1 } } 464 | conditions = { $unset: update } 465 | 466 | if options.doPersist 467 | options?.debug?.where.push(conditions) 468 | doc.update conditions, (err, result) -> _finally(err,result,options) 469 | else 470 | _finally(err,null,options) 471 | 472 | 473 | #### Private method to query neo4j directly 474 | #### options -> see Document::queryRelationships 475 | _queryGraphDB = (cypher, options, cb) -> 476 | {options,cb} = processtools.sortOptionsAndCallback(options,cb) 477 | # TODO: type check 478 | # try to "guess" process part from last statement 479 | # TODO: nice or bad feature?! ... maybe too much magic 480 | if not options.processPart? and cypher.trim().match(/(RETURN|DELETE)\s+([a-zA-Z]+?)[;]*$/)?[2] 481 | options.processPart = cypher.trim().match(/(RETURN|DELETE)\s+([a-zA-Z]+?)[;]*$/)[2] 482 | graphdb.query cypher, null, (errGraph, map) -> 483 | # Adding cypher query for better debugging 484 | options.debug = {} if options.debug is true 485 | options.debug?.cypher ?= [] 486 | options.debug?.cypher?.push(cypher) 487 | options.loadDocuments ?= true # load documents from mongodb 488 | # TODO: would it be helpful to have also the `native` result? 489 | # options.graphResult = map 490 | if options.loadDocuments and map?.length > 0 491 | # extract from result 492 | data = for result in map 493 | if options.processPart 494 | result[options.processPart] 495 | else 496 | # return first first property otherwise 497 | result[Object.keys(result)[0]] 498 | if processtools.constructorNameOf(data[0]) is 'Relationship' 499 | processtools.populateResultWithDocuments data, options, cb 500 | # TODO: distinguish between 'Path', 'Node' etc ... 501 | else 502 | processtools.populateResultWithDocuments data, options, cb 503 | else 504 | # prevent `undefined is not a function` if no cb is given 505 | cb(errGraph, map || null, options) if typeof cb is 'function' 506 | 507 | #### Cache node 508 | if globalOptions.cacheAttachedNodes 509 | Document::_cached_node = null 510 | -------------------------------------------------------------------------------- /test/tests.coffee: -------------------------------------------------------------------------------- 1 | args = require('minimist')(process.argv.slice(2)) 2 | 3 | neo4jPort = args.globals || 7474 4 | neo4jPort = (Number) neo4jPort 5 | 6 | neo4jURL = "http://localhost:#{neo4jPort}" 7 | mongodbURL = 'mongodb://localhost/mongraph_test' 8 | 9 | expect = require('expect.js') 10 | mongoose = require('mongoose') 11 | neo4j = require('neo4j') 12 | mongraph = require("../src/mongraph") 13 | # remove all test-created nodes on every test run 14 | cleanupNodes = false 15 | nodesCount = nodesCountBefore = 0 # used to check that we have deleted all created nodes during tests 16 | Join = require('join') 17 | request = require('request') 18 | 19 | describe "Mongraph", -> 20 | 21 | _countNodes = (cb) -> graph.query "START n=node(*) RETURN count(n)", (err, count) -> 22 | cb(err, Number(count?[0]?['count(n)']) || null) 23 | 24 | # schemas and data objects 25 | Person = Location = Message = alice = bob = charles = dave = elton = frank = zoe = bar = pub = null 26 | # handler for connections 27 | mongo = graph = null 28 | # regex for validating objectid 29 | regexID = /^[a-f0-9]{24}$/ 30 | 31 | before (done) -> 32 | 33 | console.log " -> Testing against '#{neo4jURL}' (neo4j) and '#{mongodbURL}' (mongodb)" 34 | 35 | # Establish connections to mongodb + neo4j 36 | graph = new neo4j.GraphDatabase(neo4jURL) 37 | mongoose.connect(mongodbURL) 38 | 39 | # initialize mongraph 40 | mongraph.init { 41 | neo4j: graph 42 | mongoose: mongoose 43 | } 44 | 45 | # Define model 46 | personSchema = new mongoose.Schema(name: String) 47 | # for testing nesting and node storage 48 | messageSchema = new mongoose.Schema 49 | message: 50 | title: 51 | type: String 52 | index: true 53 | graph: true 54 | content: String 55 | from: 56 | type: String 57 | graph: true 58 | my_id: 59 | type: Number 60 | index: true 61 | graph: true 62 | 63 | # is used for checking that we are working with the mongoose model and not with native mongodb objects 64 | personSchema.virtual('fullname').get -> @name+" "+@name[0]+"." if @name 65 | 66 | Person = mongoose.model "Person", personSchema 67 | Location = mongoose.model "Location", mongoose.Schema(name: String, lon: Number, lat: Number) 68 | Message = mongoose.model "Message", messageSchema 69 | 70 | alice = new Person(name: "alice") 71 | bob = new Person(name: "bob") 72 | charles = new Person(name: "charles") 73 | zoe = new Person(name: "zoe") 74 | 75 | bar = new Location(name: "Bar", lon: 52.51, lat: 13.49) 76 | pub = new Location(name: "Pub", lon: 40, lat: 10) 77 | 78 | createExampleDocuments = (cb) -> 79 | # create + store documents 80 | alice.save -> bob.save -> charles.save -> zoe.save -> 81 | bar.save -> pub.save -> 82 | cb() 83 | 84 | if cleanupNodes 85 | # remove all records 86 | _countNodes (err, count) -> 87 | nodesCountBefore = count 88 | Person.remove -> Location.remove -> createExampleDocuments -> 89 | done() 90 | else 91 | Person.remove -> Location.remove -> createExampleDocuments -> 92 | createExampleDocuments -> 93 | done() 94 | 95 | beforeEach (done) -> 96 | # remove all relationships 97 | alice.removeRelationships '*', -> bob.removeRelationships '*', -> zoe.removeRelationships '*', -> 98 | bar.removeRelationships '*', -> pub.removeRelationships '*', -> 99 | # **knows** 100 | # alice -> bob -> charles -> zoe 101 | # bob -> zoe 102 | # alice <- zoe 103 | # **visits* 104 | # alice -> bar 105 | # alice -> pub 106 | alice.createRelationshipTo bob, 'knows', { since: 'years' }, -> 107 | alice.createRelationshipFrom zoe, 'knows', { since: 'months' }, -> 108 | bob.createRelationshipTo charles, 'knows', -> 109 | charles.createRelationshipTo zoe, 'knows', -> 110 | bob.createRelationshipTo zoe, 'knows', -> 111 | alice.createRelationshipTo bar, 'visits', -> 112 | alice.createRelationshipTo pub, 'visits', -> 113 | done() 114 | after (done) -> 115 | return done() unless cleanupNodes 116 | # Remove all persons and locations with documents + nodes 117 | join = Join.create() 118 | for record in [ alice, bob, charles, dave, elton, zoe, bar, pub ] 119 | do (record) -> 120 | callback = join.add() 121 | if typeof record?.remove is 'function' 122 | record.remove callback 123 | else 124 | callback() 125 | join.when (a, b) -> 126 | _countNodes (err, count) -> 127 | if nodesCountBefore isnt count 128 | done(new Error("Mismatch on nodes counted before (#{nodesCountBefore}) and after (#{count}) tests")) 129 | else 130 | done() 131 | 132 | describe 'processtools', -> 133 | 134 | describe '#getObjectIDAsString()', -> 135 | 136 | it 'expect to extract the id from various kind of argument types', -> 137 | expect(mongraph.processtools.getObjectIDAsString(alice)).to.match(regexID) 138 | expect(mongraph.processtools.getObjectIDAsString(alice._id)).to.match(regexID) 139 | expect(mongraph.processtools.getObjectIDAsString(String(alice._id))).to.match(regexID) 140 | 141 | describe '#getCollectionByCollectionName()', -> 142 | 143 | it 'expect to get the collection object by collection name', -> 144 | collection = mongraph.processtools.getCollectionByCollectionName('people') 145 | expect(collection.constructor).to.be.an Object 146 | 147 | describe '#getModelByCollectionName()', -> 148 | 149 | it 'expect to get the model object by collection name', -> 150 | model = mongraph.processtools.getModelByCollectionName('people') 151 | expect(model).to.be.an Object 152 | 153 | describe '#getModelNameByCollectionName()', -> 154 | 155 | it 'expect to get the model object by collection name', -> 156 | modelName = mongraph.processtools.getModelNameByCollectionName('people') 157 | expect(modelName).to.be.equal 'Person' 158 | 159 | describe '#sortTypeOfRelationshipAndOptionsAndCallback()', -> 160 | 161 | it 'expect to sort arguments', -> 162 | fn = mongraph.processtools.sortTypeOfRelationshipAndOptionsAndCallback 163 | cb = -> 164 | result = fn() 165 | expect(result).be.eql { typeOfRelationship: '*', options: {}, cb: undefined } 166 | result = fn(cb) 167 | expect(result).be.eql { typeOfRelationship: '*', options: {}, cb: cb } 168 | result = fn('knows', cb) 169 | expect(result).be.eql { typeOfRelationship: 'knows', options: {}, cb: cb } 170 | result = fn({debug: true}, cb) 171 | expect(result).be.eql { typeOfRelationship: '*', options: { debug: true }, cb: cb } 172 | result = fn('knows', {debug: true}, cb) 173 | expect(result).be.eql { typeOfRelationship: 'knows', options: { debug: true }, cb: cb } 174 | 175 | describe '#populateResultWithDocuments()', -> 176 | 177 | it 'expect to get an error and null with options as result if the data is not usable', (done) -> 178 | mongraph.processtools.populateResultWithDocuments null, { test: true }, (err, data, options) -> 179 | expect(err).to.be.an Error 180 | expect(data).to.be.null 181 | expect(options).to.be.an Object 182 | expect(options).to.have.keys 'test' 183 | done() 184 | 185 | it 'expect to get a node populated with the corresponding document', (done) -> 186 | _id = String(alice._id) 187 | node = graph.createNode { _collection: 'people', _id: _id } 188 | node.save (err, storedNode) -> 189 | expect(err).to.be null 190 | expect(storedNode).to.be.a node.constructor 191 | mongraph.processtools.populateResultWithDocuments storedNode, { referenceDocumentId: _id }, (err, populatedNodes, options) -> 192 | expect(err).to.be null 193 | expect(populatedNodes).to.have.length 1 194 | expect(populatedNodes[0].document).to.be.a 'object' 195 | expect(String(populatedNodes[0].document._id)).to.be.equal _id 196 | storedNode.delete -> 197 | done() 198 | , true 199 | 200 | it 'expect to get relationships populated with the corresponding documents', (done) -> 201 | _fromID = String(alice._id) 202 | _toID = String(bob._id) 203 | collectionName = alice.constructor.collection.name 204 | from = graph.createNode { _collection: 'people', _id: _fromID } 205 | to = graph.createNode { _collection: 'people', _id: _toID } 206 | from.save (err, fromNode) -> to.save (err, toNode) -> 207 | fromNode.createRelationshipTo toNode, 'connected', { _from: collectionName+":"+_fromID, _to: collectionName+":"+_toID }, (err) -> 208 | expect(err).to.be null 209 | toNode.incoming 'connected', (err, foundRelationships) -> 210 | expect(foundRelationships).to.have.length 1 211 | mongraph.processtools.populateResultWithDocuments foundRelationships, (err, populatedRelationships) -> 212 | expect(err).to.be null 213 | expect(populatedRelationships).to.have.length 1 214 | expect(populatedRelationships[0].from).to.be.an Object 215 | expect(populatedRelationships[0].start).to.be.an Object 216 | expect(String(populatedRelationships[0].from._id)).to.be.equal _fromID 217 | expect(String(populatedRelationships[0].to._id)).to.be.equal _toID 218 | fromNode.delete -> 219 | toNode.delete -> 220 | done() 221 | , true 222 | , true 223 | 224 | _createExamplePath = (cb) -> 225 | _fromID = String(alice._id) 226 | _throughID = String(bob._id) 227 | _toID = String(pub._id) 228 | people = alice.constructor.collection.name 229 | locations = pub.constructor.collection.name 230 | from = graph.createNode { _collection: 'people', _id: _fromID } 231 | through = graph.createNode { _collection: 'people', _id: _throughID } 232 | to = graph.createNode { _collection: 'locations', _id: _toID } 233 | from.save (err, fromNode) -> through.save (err, throughNode) -> to.save (err, toNode) -> 234 | fromNode.createRelationshipTo throughNode, 'connected', { _from: people+':'+_fromID, _to: people+':'+_throughID }, (err) -> 235 | throughNode.createRelationshipTo toNode, 'connected', { _from: people+':'+_throughID, _to: locations+':'+_toID }, (err) -> 236 | query = """ 237 | START a = node(#{fromNode.id}), b = node(#{toNode.id}) 238 | MATCH p = shortestPath( a-[:connected*..3]->b ) 239 | RETURN p; 240 | """ 241 | graph.query query, (err, result) -> 242 | cb(err, result, [ fromNode, toNode, throughNode ]) 243 | 244 | _removeExampleNodes = (nodes, cb) -> 245 | join = Join.create() 246 | ids = for node in nodes 247 | node.id 248 | graph.query "START n = node(#{ids.join(",")}) MATCH n-[r?]-() DELETE n, r", (err) -> 249 | cb(null, null) 250 | 251 | it 'expect to get path populated w/ corresponding documents', (done) -> 252 | _createExamplePath (err, result, exampleNodes) -> 253 | expect(err).to.be null 254 | expect(result).to.have.length 1 255 | options = { debug: true, processPart: 'p' } 256 | mongraph.processtools.populateResultWithDocuments result, options, (err, populatedPath, options) -> 257 | expect(populatedPath).to.have.length 3 258 | _removeExampleNodes exampleNodes, -> 259 | done() 260 | 261 | it 'expect to get path populated w/ corresponding documents with query', (done) -> 262 | _createExamplePath (err, result, exampleNodes) -> 263 | options = 264 | debug: true 265 | processPart: 'p' 266 | where: 267 | document: { name: /^[A-Z]/ } 268 | mongraph.processtools.populateResultWithDocuments result, options, (err, populatedPath, options) -> 269 | expect(populatedPath).to.have.length 1 270 | expect(populatedPath[0].name).match /^[A-Z]/ 271 | _removeExampleNodes exampleNodes, -> 272 | done() 273 | 274 | it 'expect to get path populated w/ corresponding documents with distinct collection', (done) -> 275 | _createExamplePath (err, result, exampleNodes) -> 276 | options = 277 | debug: true 278 | processPart: 'p' 279 | collection: 'locations' 280 | mongraph.processtools.populateResultWithDocuments result, options, (err, populatedPath, options) -> 281 | expect(populatedPath).to.have.length 1 282 | expect(populatedPath[0].name).to.be.equal 'Pub' 283 | _removeExampleNodes exampleNodes, -> 284 | done() 285 | 286 | 287 | describe 'mongraph', -> 288 | 289 | describe '#init()', -> 290 | 291 | it 'expect that we have the all needed records in mongodb', (done) -> 292 | persons = [] 293 | Person.count (err, count) -> 294 | expect(count).to.be.equal 4 295 | Location.count (err, count) -> 296 | expect(count).to.be.equal 2 297 | done() 298 | 299 | describe 'mongraphMongoosePlugin', -> 300 | 301 | describe '#schema', -> 302 | 303 | it 'expect to have extra attributes reserved for use with neo4j', (done) -> 304 | p = new Person name: 'Person' 305 | p.save (err, doc) -> 306 | expect(doc._node_id).to.be.above 0 307 | # checks that we can set s.th. 308 | doc._relationships = id: 1 309 | expect(doc._relationships.id).to.be.equal 1 310 | p.remove -> 311 | done() 312 | 313 | it 'expect that schema extensions and hooks can be optional', (done) -> 314 | calledPreSave = false 315 | 316 | join = Join.create() 317 | doneDisabled = join.add() 318 | 319 | schema = new mongoose.Schema name: String 320 | schema.set 'graphability', false 321 | Guitar = mongoose.model "Guitar", schema 322 | guitar = new Guitar name: 'Fender' 323 | guitar.save (err, doc) -> 324 | expect(err).to.be null 325 | expect(doc._node_id).to.be undefined 326 | doc.getNode (err, node) -> 327 | expect(err).not.to.be null 328 | expect(node).to.be null 329 | doc.remove -> 330 | doneDisabled() 331 | 332 | doneNoDeleteHook = join.add() 333 | schema = new mongoose.Schema name: String 334 | schema.set 'graphability', middleware: preRemove: false 335 | Keyboard = mongoose.model "Keyboard", schema 336 | keyboard = new Keyboard name: 'DX7' 337 | keyboard.save (err, doc) -> 338 | doc.getNode (err, node) -> 339 | # we have to delete the node manually becaud we missed out the hook 340 | doc.remove -> 341 | graph.getNodeById node.id, (err, foundNode) -> 342 | expect(node).to.be.an 'object' 343 | node.delete -> 344 | return doneNoDeleteHook() 345 | 346 | # doneNoSaveHook = join.add() 347 | # schema = new mongoose.Schema name: String 348 | # schema.set 'graphability', middleware: preSave: false 349 | # # explicit overriding middleware 350 | # schema.pre 'save', (next) -> 351 | # calledPreSave = true 352 | # next() 353 | 354 | # Drumkit = mongoose.model "Drumkit", schema 355 | # drums = new Drumkit name: 'Tama' 356 | # drums.save (err, doc) -> 357 | # expect(err).to.be null 358 | # expect(calledPreSave).to.be true 359 | # expect(doc._cached_node).not.be.an 'object' 360 | # drums.remove -> 361 | # doneNoSaveHook() 362 | 363 | join.when -> 364 | done() 365 | 366 | 367 | describe 'mongoose::Document', -> 368 | 369 | describe '#getNode()', -> 370 | 371 | it 'expect not to get a corresponding node for an unstored document in graphdb', (done) -> 372 | elton = Person(name: "elton") 373 | expect(elton._node_id).not.to.be.above 0 374 | elton.getNode (err, found) -> 375 | expect(err).not.to.be null 376 | expect(found).to.be null 377 | done() 378 | 379 | it 'expect to find always the same corresponding node to a stored document', (done) -> 380 | elton = Person(name: "elton") 381 | elton.save (err, elton) -> 382 | expect(err).to.be null 383 | nodeID = elton._node_id 384 | expect(nodeID).to.be.above 0 385 | elton.getNode (err, node) -> 386 | expect(err).to.be null 387 | expect(node.id).to.be.equal node.id 388 | elton.remove() if cleanupNodes 389 | done() 390 | 391 | it 'expect to find a node by collection and _id through index on neo4j', (done) -> 392 | graph.getIndexedNode 'people', '_id', alice._id, (err, found) -> 393 | expect(found.id).to.be.equal alice._node_id 394 | done() 395 | 396 | describe '#createRelationshipTo()', -> 397 | 398 | it 'expect to create an outgoing relationship from this document to another document', (done) -> 399 | alice.createRelationshipTo bob, 'knows', { since: 'years' }, (err, relationship) -> 400 | expect(relationship[0].start.data._id).to.be.equal (String) alice._id 401 | expect(relationship[0].end.data._id).to.be.equal (String) bob._id 402 | expect(relationship[0]._data.type).to.be.equal 'knows' 403 | alice.createRelationshipTo zoe, 'knows', { since: 'years' }, (err, relationship) -> 404 | expect(relationship[0].start.data._id).to.be.equal (String) alice._id 405 | expect(relationship[0].end.data._id).to.be.equal (String) zoe._id 406 | expect(relationship[0]._data.type).to.be.equal 'knows' 407 | done() 408 | 409 | describe '#createRelationshipFrom()', -> 410 | 411 | it 'expect to create an incoming relationship from another document to this document' , (done) -> 412 | bob.createRelationshipFrom zoe, 'knows', { since: 'years' }, (err, relationship) -> 413 | expect(relationship[0].start.data._id).to.be.equal (String) zoe._id 414 | expect(relationship[0].end.data._id).to.be.equal (String) bob._id 415 | done() 416 | 417 | describe '#createRelationshipBetween()', -> 418 | 419 | it 'expect to create a relationship between two documents (bidirectional)', (done) -> 420 | alice.createRelationshipBetween bob, 'follows', -> 421 | bob.allRelationships 'follows', (err, bobsRelationships) -> 422 | value = null 423 | hasIncoming = false 424 | hasOutgoing = false 425 | for relationship in bobsRelationships 426 | hasOutgoing = relationship.from.name is 'bob' and relationship.to.name is 'alice' unless hasOutgoing 427 | hasIncoming = relationship.to.name is 'bob' and relationship.from.name is 'alice' unless hasIncoming 428 | expect(hasOutgoing).to.be true 429 | expect(hasIncoming).to.be true 430 | done() 431 | 432 | 433 | describe '#removeRelationshipsTo', -> 434 | 435 | it 'expect to remove outgoing relationships to a document', (done) -> 436 | # zoe gets to follow bob 437 | zoe.createRelationshipTo bob, 'follows', (err, relationship) -> 438 | expect(err).to.be null 439 | # zoe follows bob 440 | zoe.outgoingRelationships 'follows', (err, follows) -> 441 | expect(err).to.be null 442 | expect(follows).to.have.length 1 443 | expect(follows[0].to.name).to.be.equal 'bob' 444 | # zoe stops all 'follow' activities 445 | zoe.removeRelationshipsTo bob, 'follows', (err, a) -> 446 | expect(err).to.be null 447 | zoe.outgoingRelationships 'follows', (err, follows) -> 448 | expect(err).to.be null 449 | expect(follows).to.have.length 0 450 | done() 451 | 452 | describe '#removeRelationshipsFrom', -> 453 | 454 | it 'expects to remove incoming relationships from a document', (done) -> 455 | alice.incomingRelationships 'knows', (err, relationships) -> 456 | countBefore = relationships.length 457 | expect(relationships.length).to.be.equal 1 458 | expect(relationships[0].from.name).to.be.equal 'zoe' 459 | alice.removeRelationshipsFrom zoe, 'knows', (err, query, options) -> 460 | expect(err).to.be null 461 | alice.incomingRelationships 'knows',(err, relationships) -> 462 | expect(relationships.length).to.be.equal 0 463 | done() 464 | 465 | describe '#removeRelationshipsBetween', -> 466 | 467 | it 'expects to remove incoming and outgoing relationships between two documents', (done) -> 468 | # alice <-knows-> zoe 469 | alice.removeRelationships 'knows', -> zoe.removeRelationships 'knows', -> 470 | alice.createRelationshipTo zoe, 'knows', -> zoe.createRelationshipTo alice, 'knows', (err) -> 471 | alice.incomingRelationships 'knows', (err, relationships) -> 472 | aliceCountBefore = relationships.length 473 | zoe.incomingRelationships 'knows', (err, relationships) -> 474 | zoeCountBefore = relationships.length 475 | expect(relationships[0].from.name).to.be.equal 'alice' 476 | zoe.removeRelationshipsBetween alice, 'knows', (err) -> 477 | expect(err).to.be null 478 | alice.incomingRelationships 'knows', (err, aliceRelationships) -> 479 | expect(aliceRelationships.length).to.be.below aliceCountBefore 480 | zoe.incomingRelationships 'knows', (err, zoeRelationships) -> 481 | expect(zoeRelationships.length).to.be.below zoeCountBefore 482 | done() 483 | 484 | describe '#removeRelationships', -> 485 | 486 | it 'expects to remove all incoming and outgoing relationships', (done) -> 487 | alice.allRelationships 'knows', (err, relationships) -> 488 | expect(relationships.length).to.be.above 0 489 | alice.removeRelationships 'knows', (err) -> 490 | expect(err).to.be null 491 | alice.allRelationships 'knows', (err, relationships) -> 492 | expect(relationships).to.have.length 0 493 | done() 494 | 495 | it 'expect to remove all relationship of a specific type', (done) -> 496 | alice.allRelationships 'knows', (err, relationships) -> 497 | expect(relationships?.length).be.above 0 498 | alice.removeRelationships 'knows', (err, relationships) -> 499 | expect(relationships).to.have.length 0 500 | done() 501 | 502 | describe '#allRelationships()', -> 503 | 504 | it 'expect to get incoming and outgoing relationships as relationship object', (done) -> 505 | alice.allRelationships 'knows', (err, relationships) -> 506 | expect(relationships).to.be.an 'array' 507 | expect(relationships).to.have.length 2 508 | expect(relationships[0].data.since).to.be.equal 'years' 509 | done() 510 | 511 | it 'expect to get all related documents attached to relationships', (done) -> 512 | alice.allRelationships 'knows', (err, relationships) -> 513 | expect(relationships).to.be.an 'array' 514 | expect(relationships).to.have.length 2 515 | expect(relationships[0].from).to.be.an 'object' 516 | expect(relationships[0].to).to.be.an 'object' 517 | data = {} 518 | for relationship in relationships 519 | data[relationship.to.name] = true 520 | expect(data).to.only.have.keys( 'alice', 'bob' ) 521 | done() 522 | 523 | it 'expect to count all matched relationships, nodes or both', (done) -> 524 | alice.allRelationships { countDistinct: 'a', debug: true }, (err, res, options) -> 525 | count = res[0] 526 | expect(count).to.be.above 0 527 | alice.allRelationships { count: 'a', debug: true }, (err, res, options) -> 528 | expect(res[0]).to.be.above count 529 | alice.allRelationships { count: '*' }, (err, resnew, options) -> 530 | expect(resnew >= res[0]).to.be true 531 | done() 532 | 533 | describe '#allRelationshipsBetween()', -> 534 | 535 | it 'expect to get all relationships between two documents', (done) -> 536 | # create bidirectional relationship 537 | bob.createRelationshipTo alice, 'knows', { since: 'years' }, -> 538 | alice.allRelationshipsBetween bob, 'knows', (err, found) -> 539 | expect(found).to.have.length 2 540 | from_a = found[0].from.name 541 | from_b = found[1].from.name 542 | expect(from_a isnt from_b).to.be true 543 | done() 544 | 545 | it 'expect to get outgoing relationships between two documents', (done) -> 546 | # create bidirectional relationship 547 | bob.createRelationshipTo alice, 'knows', { since: 'years' }, -> 548 | alice.allRelationshipsBetween bob, 'knows', (err, found) -> 549 | alice.outgoingRelationshipsTo bob, 'knows', (err, found) -> 550 | expect(found).to.have.length 1 551 | bob.outgoingRelationshipsTo alice, 'knows', (err, found) -> 552 | expect(found).to.have.length 1 553 | done() 554 | 555 | it 'expect to get incoming relationships between two documents', (done) -> 556 | bob.createRelationshipTo alice, 'knows', { since: 'years' }, -> 557 | alice.allRelationshipsBetween bob, 'knows', (err, found) -> 558 | alice.incomingRelationshipsFrom bob, 'knows', (err, found) -> 559 | expect(found).to.have.length 1 560 | bob.incomingRelationshipsFrom alice, 'knows', (err, found) -> 561 | expect(found).to.have.length 1 562 | done() 563 | 564 | describe '#outgoingRelationships()', -> 565 | 566 | it 'expect to get outgoing relationships+documents from a specific collection', (done) -> 567 | alice.outgoingRelationships '*', { collection: 'locations' }, (err, relationships, options) -> 568 | data = {} 569 | for relationship in relationships 570 | data[relationship.to.name] = true 571 | expect(data).to.only.have.keys( 'Bar', 'Pub' ) 572 | expect(relationships).to.have.length 2 573 | expect(err).to.be null 574 | done() 575 | 576 | it 'expect to get incoming relationships+documents with a condition', (done) -> 577 | alice.outgoingRelationships '*', { where: { document: { name: /^[A-Z]/ } } }, (err, relationships) -> 578 | expect(relationships).to.have.length 2 579 | data = {} 580 | for relationship in relationships 581 | data[relationship.to.name] = true 582 | expect(data).to.only.have.keys( 'Bar', 'Pub' ) 583 | done() 584 | 585 | it 'expect to get only outgoing relationships', (done) -> 586 | alice.outgoingRelationships 'visits', (err, result) -> 587 | expect(err).to.be(null) 588 | expect(result).to.have.length 2 589 | done() 590 | 591 | describe '#incomingRelationships()', -> 592 | 593 | it 'expect to get only incoming relationships', (done) -> 594 | alice.incomingRelationships 'knows', (err, result) -> 595 | expect(err).to.be(null) 596 | expect(result).to.have.length 1 597 | expect(result[0].data.since).be.equal 'months' 598 | done() 599 | 600 | it 'expect to get incoming relationships+documents from a specific collection', (done) -> 601 | alice.incomingRelationships '*', { collection: 'people' }, (err, relationships) -> 602 | expect(relationships).to.have.length 1 603 | expect(relationships[0].from.name).to.be 'zoe' 604 | done() 605 | 606 | describe '#removeNode()', -> 607 | 608 | it 'expect to remove a node including all incoming and outgoing relationships', (done) -> 609 | frank = new Person name: 'frank' 610 | frank.save (err, frank) -> frank.getNode (err, node) -> 611 | nodeId = node.id 612 | expect(nodeId).to.be.above 0 613 | frank.createRelationshipTo zoe, 'likes', -> zoe.createRelationshipTo frank, 'likes', -> frank.allRelationships 'likes', (err, likes) -> 614 | expect(likes).to.have.length 2 615 | frank.removeNode (err, result) -> 616 | expect(err).to.be null 617 | graph.getNodeById nodeId, (err, found) -> 618 | expect(found).to.be undefined 619 | frank.allRelationships 'likes', (err, likes) -> 620 | expect(likes).to.be null 621 | frank.remove() if cleanupNodes 622 | done() 623 | 624 | describe '#shortestPath()', -> 625 | 626 | it 'expect to get the shortest path between two documents', (done) -> 627 | alice.shortestPathTo zoe, 'knows', (err, path) -> 628 | expect(path).to.be.an 'object' 629 | expect(err).to.be null 630 | expectedPath = [ alice._id, bob._id, zoe._id ] 631 | for node, i in path 632 | expect(String(node._id)).be.equal String(expectedPath[i]) 633 | done() 634 | 635 | it 'expect to get a mongoose document instead of a native mongodb document', (done) -> 636 | alice.shortestPathTo zoe, 'knows', (err, path) -> 637 | expect(path).to.have.length 3 638 | expect(path[0].fullname).to.be.equal 'alice a.' 639 | done() 640 | 641 | it 'expect to get a mongoose document with conditions', (done) -> 642 | alice.shortestPathTo zoe, 'knows', { where: { document: { name: /o/ } } }, (err, path) -> 643 | bob = path[0] 644 | zoe = path[1] 645 | expect(bob.name).to.be.equal 'bob' 646 | expect(zoe.name).to.be.equal 'zoe' 647 | expect(path).to.have.length 2 648 | done() 649 | 650 | describe '#dataForNode()', -> 651 | 652 | it 'expect to get null by default', (done) -> 653 | expect(alice.dataForNode()).to.be null 654 | message = new Message() 655 | message.message = 'how are you?' 656 | message.save -> 657 | data = message.dataForNode() 658 | expect(data).to.have.property('message.title') 659 | expect(data).to.have.property('from') 660 | expect(data['from']).to.be undefined 661 | expect(data['message.title']).to.be undefined 662 | expect(Object.keys(data)).to.have.length 3 663 | message.remove -> 664 | done() 665 | 666 | it 'expect to get attributes for index', (done) -> 667 | message = new Message() 668 | index = message.dataForNode(index: true) 669 | expect(index).to.have.length 2 670 | expect(index[0]).to.be.equal 'message.title' 671 | expect(index[1]).to.be.equal 'my_id' 672 | done() 673 | 674 | it 'expect to delete values in document and on node', (done) -> 675 | message = new Message() 676 | message.from = 'me' 677 | message.save -> 678 | message.getNode (err, node) -> 679 | expect(node.data.from).to.be.equal 'me' 680 | message.from = undefined 681 | message.save -> 682 | message.getNode (err, node) -> 683 | expect(node.data.from).to.be undefined 684 | message.remove -> 685 | done() 686 | 687 | it 'expect to get node with indexed fields from mongoose schema', (done) -> 688 | # TODO: use `graph.getIndexedNode` from neo4j module instead of manual request 689 | # Problem: currently getting no results from graph.getIndexedNode at all... maybe a bug in neo4j lib?! 690 | # first check didn't bring any progress... GraphDatabase._coffee @getIndexedNodes, response.body.map 691 | value = new Date().getTime() # generate 'unique' value for this test 692 | graph.getIndexedNode 'messages', 'my', value, (err) -> 693 | message = new Message() 694 | message.message.title = '_'+value+'_' 695 | message.my_id = value 696 | message.save -> 697 | graph.getIndexedNode 'messages', 'my_id', value, (err, found) -> 698 | request.get neo4jURL+"/db/data/index/node/messages/my_id/#{value}", (err, res) -> 699 | expect(err).to.be null 700 | expect(res.body).to.be.a 'string' 701 | result = JSON.parse(res.body) 702 | expect(result[0].data['my_id']).to.be.equal value 703 | message.remove -> 704 | done() 705 | 706 | it 'expect to store values from document in corresponding node if defined in mongoose schema', (done) -> 707 | message = new Message() 708 | message.message.content = 'how are you?' 709 | message.message.title = 'hello' 710 | message.from = 'me' 711 | message.save -> 712 | message.getNode (err, node) -> 713 | expect(node).to.be.an 'object' 714 | expect(node.data['message.title']).to.be.equal message.message.title 715 | expect(node.data.from).to.be.equal message.from 716 | expect(node.data['message.content']).to.be undefined 717 | message.remove -> 718 | done() 719 | 720 | describe '#init() with specific options', -> 721 | 722 | it 'expect to store relationships (redundant) in document', (done) -> 723 | alice.applyGraphRelationships { doPersist: true }, (err, relationships) -> 724 | expect(err).to.be null 725 | expect(relationships).to.only.have.keys 'knows', 'visits' 726 | expect(relationships.knows).to.have.length 2 727 | # remove all 'visits' relationships and check the effect on the record 728 | alice.removeRelationships 'visits', { debug: true }, (err, result, options) -> 729 | alice.applyGraphRelationships { doPersist: true }, (err, relationships) -> 730 | expect(err).to.be null 731 | expect(relationships).to.only.have.keys 'knows' 732 | expect(relationships.knows).to.have.length 2 733 | Person.findById alice._id, (err, aliceReloaded) -> 734 | expect(aliceReloaded._relationships).to.only.have.keys 'knows' 735 | expect(aliceReloaded._relationships.knows).to.have.length 2 736 | done() 737 | 738 | describe 'mongraph daily-use-test', (done) -> 739 | 740 | it 'expect to count relationships correctly (incoming, outgoing and both)', (done) -> 741 | dave = new Person name: 'dave' 742 | elton = new Person name: 'elton' 743 | elton.save -> dave.save -> elton.allRelationships (err, eltonsRelationships) -> 744 | expect(err).to.be null 745 | expect(eltonsRelationships).to.have.length 0 746 | elton.createRelationshipTo dave, 'rocks', { instrument: 'piano' }, -> 747 | elton.outgoingRelationships 'rocks', (err, playsWith) -> 748 | expect(err).to.be null 749 | expect(playsWith).to.have.length 1 750 | expect(playsWith[0].data.instrument).to.be 'piano' 751 | elton.incomingRelationships 'rocks', (err, playsWith) -> 752 | expect(playsWith).to.have.length 0 753 | dave.createRelationshipTo elton, 'rocks', { instrument: 'guitar' }, -> 754 | elton.incomingRelationships 'rocks', (err, playsWith) -> 755 | expect(playsWith).to.have.length 1 756 | dave.createRelationshipTo elton, 'rocks', { song: 'Everlong' }, -> 757 | elton.incomingRelationships 'rocks', (err, plays) -> 758 | expect(plays).to.have.length 2 759 | expect(plays[0].data.instrument).to.be 'guitar' 760 | expect(plays[1].data.song).to.be 'Everlong' 761 | dave.allRelationships '*', (err, relations) -> 762 | dave.allRelationships '*', { where: { relationship: "r.instrument = 'guitar'" }, debug: true }, (err, relations, options) -> 763 | expect(relations).to.have.length 1 764 | expect(relations[0].data.instrument).to.be.equal 'guitar' 765 | if cleanupNodes 766 | elton.remove -> dave.remove -> done() 767 | else 768 | done() 769 | 770 | describe 'Neo4j::Node', -> 771 | 772 | describe '#getCollectionName()', -> 773 | 774 | it 'expect to get the collection name from a node', (done) -> 775 | # create also a new node 776 | emptyNode = graph.createNode() 777 | alice.getNode (err, node) -> 778 | expect(node.getCollectionName()).to.be.equal('people') 779 | expect(emptyNode.getCollectionName()).to.be(undefined) 780 | done() 781 | 782 | describe '#getMongoId()', -> 783 | 784 | it 'expect to get the id of the corresponding document from a node', (done) -> 785 | alice.getNode (err, node) -> 786 | expect(node.getMongoId()).to.be.equal (String) alice._id 787 | done() 788 | 789 | describe '#getDocument()', -> 790 | 791 | it 'expect to get equivalent document from a node', (done) -> 792 | alice.getNode (err, node) -> 793 | expect(node).to.be.an 'object' 794 | node.getDocument (err, doc) -> 795 | expect(doc).to.be.an 'object' 796 | expect(String(doc._id)).to.be.equal (String) alice._id 797 | done() 798 | 799 | 800 | 801 | 802 | 803 | 804 | --------------------------------------------------------------------------------