├── .gitignore ├── .jshintrc ├── README.md ├── index.js ├── package.json └── test └── relationshipSpec.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // JSHint Default Configuration File (as on JSHint website) 3 | // See http://jshint.com/docs/ for more details 4 | 5 | "maxerr" : 50, // {int} Maximum error before stopping 6 | 7 | // Enforcing 8 | "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.) 9 | "camelcase" : false, // true: Identifiers must be in camelCase 10 | "curly" : true, // true: Require {} for every new block or scope 11 | "eqeqeq" : true, // true: Require triple equals (===) for comparison 12 | "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty() 13 | "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());` 14 | "indent" : 4, // {int} Number of spaces to use for indentation 15 | "latedef" : false, // true: Require variables/functions to be defined before being used 16 | "newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()` 17 | "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee` 18 | "noempty" : true, // true: Prohibit use of empty blocks 19 | "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment) 20 | "plusplus" : false, // true: Prohibit use of `++` & `--` 21 | "quotmark" : false, // Quotation mark consistency: 22 | // false : do nothing (default) 23 | // true : ensure whatever is used is consistent 24 | // "single" : require single quotes 25 | // "double" : require double quotes 26 | "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks) 27 | "unused" : "vars", // true: Require all defined variables be used 28 | "strict" : true, // true: Requires all functions run in ES5 Strict Mode 29 | "maxparams" : false, // {int} Max number of formal params allowed per function 30 | "maxdepth" : false, // {int} Max depth of nested blocks (within functions) 31 | "maxstatements" : false, // {int} Max number statements per function 32 | "maxcomplexity" : false, // {int} Max cyclomatic complexity per function 33 | "maxlen" : false, // {int} Max number of characters per line 34 | 35 | // Relaxing 36 | "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons) 37 | "boss" : false, // true: Tolerate assignments where comparisons would be expected 38 | "debug" : false, // true: Allow debugger statements e.g. browser breakpoints. 39 | "eqnull" : false, // true: Tolerate use of `== null` 40 | "es5" : false, // true: Allow ES5 syntax (ex: getters and setters) 41 | "esnext" : false, // true: Allow ES.next (ES6) syntax (ex: `const`) 42 | "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features) 43 | // (ex: `for each`, multiple try/catch, function expression…) 44 | "evil" : false, // true: Tolerate use of `eval` and `new Function()` 45 | "expr" : false, // true: Tolerate `ExpressionStatement` as Programs 46 | "funcscope" : false, // true: Tolerate defining variables inside control statements 47 | "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict') 48 | "iterator" : false, // true: Tolerate using the `__iterator__` property 49 | "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block 50 | "laxbreak" : false, // true: Tolerate possibly unsafe line breakings 51 | "laxcomma" : false, // true: Tolerate comma-first style coding 52 | "loopfunc" : false, // true: Tolerate functions being defined in loops 53 | "multistr" : false, // true: Tolerate multi-line strings 54 | "proto" : false, // true: Tolerate using the `__proto__` property 55 | "scripturl" : false, // true: Tolerate script-targeted URLs 56 | "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;` 57 | "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation 58 | "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;` 59 | "validthis" : false, // true: Tolerate using this in a non-constructor function 60 | 61 | // Environments 62 | "browser" : true, // Web Browser (window, document, etc) 63 | "couch" : false, // CouchDB 64 | "devel" : true, // Development/debugging (alert, confirm, etc) 65 | "dojo" : false, // Dojo Toolkit 66 | "jquery" : false, // jQuery 67 | "mootools" : false, // MooTools 68 | "node" : true, // Node.js 69 | "nonstandard" : false, // Widely adopted globals (escape, unescape, etc) 70 | "prototypejs" : false, // Prototype and Scriptaculous 71 | "rhino" : false, // Rhino 72 | "worker" : false, // Web Workers 73 | "wsh" : false, // Windows Scripting Host 74 | "yui" : false, // Yahoo User Interface 75 | 76 | // Custom Globals 77 | "globals": { 78 | "describe" : false, 79 | "it" : false, 80 | "before" : false, 81 | "beforeEach" : false, 82 | "after" : false, 83 | "afterEach" : false 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## mongoose-relationship 2 | 3 | _**Note**: Maintenence on this module is deprecated. I had personally been using it for a project and over time have realized that bi-directional relationships in mongoose can become extremely complex and hinder performance as a project grows. The more I learn about mongo and designing data for it, the less likely these relationships make sense._ 4 | 5 | A mongoose plugin that creates and manages relationships between two separate models. These relationships can be One-To-One, One-To-Many, or Many-To-Many. These changes are currently one-direction. If you manipulate a parents "child" property or collection, the child values will not be updated. Only changes made to the child model will update its parent. 6 | 7 | # Install 8 | Install via NPM 9 | 10 | npm install mongoose-relationship 11 | 12 | # Usage 13 | 14 | ## One-To-Many 15 | ```js 16 | var mongoose = require("mongoose"), 17 | Schema = mongoose.Schema, 18 | relationship = require("mongoose-relationship"); 19 | 20 | var ParentSchema = new Schema({ 21 | children:[{ type:Schema.ObjectId, ref:"Child" }] 22 | }); 23 | var Parent = mongoose.model("Parent", ParentSchema); 24 | 25 | var ChildSchema = new Schema({ 26 | parent: { type:Schema.ObjectId, ref:"Parent", childPath:"children" } 27 | }); 28 | ChildSchema.plugin(relationship, { relationshipPathName:'parent' }); 29 | var Child = mongoose.model("Child", ChildSchema) 30 | 31 | var parent = new Parent({}); 32 | parent.save(); 33 | var child = new Child({parent:parent._id}); 34 | child.save() //the parent children property will now contain child's id 35 | child.remove() //the parent children property will no longer contain the child's id 36 | ``` 37 | 38 | ## Many-To-Many 39 | ```js 40 | var mongoose = require("mongoose"), 41 | Schema = mongoose.Schema, 42 | relationship = require("mongoose-relationship"); 43 | 44 | var ParentSchema = new Schema({ 45 | children:[{ type:Schema.ObjectId, ref:"Child" }] 46 | }); 47 | var Parent = mongoose.model("Parent", ParentSchema); 48 | 49 | var ChildSchema = new Schema({ 50 | parents: [{ type:Schema.ObjectId, ref:"Parent", childPath:"children" }] 51 | }); 52 | ChildSchema.plugin(relationship, { relationshipPathName:'parents' }); 53 | var Child = mongoose.model("Child", ChildSchema) 54 | 55 | var parent = new Parent({}); 56 | parent.save(); 57 | var parentTwo = new Parent({}); 58 | parentTwo.save(); 59 | 60 | var child = new Child({}); 61 | child.parents.push(parent); 62 | child.parents.push(parentTwo); 63 | child.save() //both parent and parentTwo children property will now contain the child's id 64 | child.remove() //both parent and parentTwo children property will no longer contain the child's id 65 | ``` 66 | 67 | ## Many-To-Many with Multiple paths 68 | ```js 69 | var mongoose = require("mongoose"), 70 | Schema = mongoose.Schema, 71 | relationship = require("mongoose-relationship"); 72 | 73 | var ParentSchema = new Schema({ 74 | children:[{ type:Schema.ObjectId, ref:"Child" }] 75 | }); 76 | var Parent = mongoose.model("Parent", ParentSchema); 77 | 78 | var OtherParentSchema = new Schema({ 79 | children:[{ type:Schema.ObjectId, ref:"Child" }] 80 | }); 81 | var OtherParent = mongoose.model("OtherParent", OtherParentSchema); 82 | 83 | var ChildSchema = new Schema({ 84 | parents: [{ type:Schema.ObjectId, ref:"Parent", childPath:"children" }] 85 | otherParents: [{ type:Schema.ObjectId, ref:"OtherParent", childPath:"children" }] 86 | }); 87 | ChildSchema.plugin(relationship, { relationshipPathName:['parents', 'otherParents'] }); 88 | var Child = mongoose.model("Child", ChildSchema) 89 | 90 | var parent = new Parent({}); 91 | parent.save(); 92 | var otherParent = new OtherParent({}); 93 | otherParent.save(); 94 | 95 | var child = new Child({}); 96 | child.parents.push(parent); 97 | child.otherParents.push(otherParent); 98 | child.save() //both parent and otherParent children property will now contain the child's id 99 | child.remove() //both parent and otherParent children property will no longer contain the child's id 100 | ``` 101 | 102 | ## One-To-One 103 | **This usage scenario will overwrite the parent's field of multiple children are assigned the same parent. The use case for this operation seems to be limited and only included for a sense of completion.** 104 | 105 | ```js 106 | var mongoose = require("mongoose"), 107 | Schema = mongoose.Schema, 108 | relationship = require("mongoose-relationship"); 109 | 110 | var ParentSchema = new Schema({ 111 | child:{ type:Schema.ObjectId, ref:"Child" } 112 | }); 113 | var Parent = mongoose.model("Parent", ParentSchema); 114 | 115 | var ChildSchema = new Schema({ 116 | parent: { type:Schema.ObjectId, ref:"Parent", childPath:"child" } 117 | }); 118 | ChildSchema.plugin(relationship, { relationshipPathName:'parent' }); 119 | var Child = mongoose.model("Child", ChildSchema) 120 | 121 | var parent = new Parent({}); 122 | parent.save(); 123 | var child = new Child({parent:parent._id}); 124 | child.save() // The parent's child property will now be set to the child's _id; 125 | child.remove() // The parent's child property will now be unset 126 | ``` 127 | 128 | ### Options 129 | 130 | #### Plugin 131 | The plugin currently has the following options 132 | 133 | - **relationshipPathName** 134 | 135 | A string or array to let the plugin know which path(s) on your schema the relationship will be created. Defaults to **relationship** 136 | 137 | - **triggerMiddleware** 138 | 139 | Boolean value which, if set to true, will explicitly save any parents entities when a relationship is updated causing save middleware to execute. Defaults to **false** 140 | 141 | #### Path Value 142 | When creating a path on a schema that will represent the relationship, the childPath option is required 143 | 144 | - **childPath** 145 | 146 | A string which should match an existing path in target ref schema. If this path does not exist the plugin will have no affect on the target ref. 147 | 148 | - **validateExistence** 149 | 150 | Boolean value that tells mongoose-relationship to ensure that the parent exists before setting the relationship for the child. Defaults to **false** 151 | 152 | - **upsert** 153 | 154 | Boolean value that controls whether a parent should be created if it does not exist upon child save. Defaults to **false** 155 | 156 | # Tests 157 | Test can be run simply by installing and running mocha 158 | 159 | npm install -g mocha 160 | mocha 161 | 162 | # Authors 163 | Mike Sabatini [@mikesabatini](https://twitter.com/mikesabatini) 164 | 165 | # License 166 | Copyright Mike Sabatini 2014 167 | Licensed under the MIT License. Enjoy 168 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _ = require("lodash"); 4 | var async = require('async'); 5 | 6 | var defaults = { 7 | relationshipPathName: "relationship", 8 | triggerMiddleware: false 9 | }; 10 | 11 | var operationMap = { 12 | func: { 13 | add: '$set', 14 | remove: '$unset' 15 | }, 16 | obj: { 17 | add: '$addToSet', 18 | remove: '$pull' 19 | } 20 | }; 21 | 22 | function optionsForRelationship(relationship) { 23 | var relationshipPathOptions; 24 | var relationshipRefType = relationship.options.type; 25 | // One-to-One or One-To-Many 26 | if (_.isFunction(relationshipRefType)) { 27 | relationshipPathOptions = relationship.options; 28 | } 29 | // Many-to-Many 30 | else if (_.isObject(relationshipRefType)) { 31 | relationshipPathOptions = relationship.options.type[0]; 32 | } 33 | return relationshipPathOptions; 34 | } 35 | 36 | function validatePath(relationshipPath) { 37 | var relationshipPathOptions = optionsForRelationship(relationshipPath); 38 | if (!_.isUndefined(relationshipPathOptions)) { 39 | if (_.isUndefined(relationshipPathOptions.ref)) { 40 | return new Error("Relationship " + relationshipPath.path + " requires a ref"); 41 | } 42 | 43 | if (_.isUndefined(relationshipPathOptions.childPath)) { 44 | return new Error("Relationship " + relationshipPath.path + " requires a childPath for its parent"); 45 | } 46 | } else { 47 | return new Error("Mission options for relationship " + relationshipPath.path); 48 | } 49 | } 50 | 51 | function updateRemovedParents(id, relationshipTargetModel, childPath, pathValue, done) { 52 | // guarantee that no other elements has this one as child 53 | var query = {}; 54 | if (pathValue && pathValue.length) { 55 | query._id = { 56 | $nin: pathValue 57 | }; 58 | } 59 | 60 | query[childPath] = { 61 | $in: [id] 62 | }; 63 | var updateVal = { 64 | $pull: {} 65 | }; 66 | updateVal.$pull[childPath] = id; 67 | 68 | relationshipTargetModel.update( 69 | query, 70 | updateVal, { 71 | multi: true 72 | }, 73 | function(err, result) { 74 | done(err); 75 | } 76 | ); 77 | } 78 | 79 | module.exports = exports = function relationship(schema, options) { 80 | options = _.extend(defaults, options); 81 | 82 | var relationshipPaths = options.relationshipPathName; 83 | if (_.isString(relationshipPaths)) { 84 | relationshipPaths = [relationshipPaths]; 85 | } 86 | 87 | _.each(relationshipPaths, function(relationshipPathName) { 88 | if (_.isString(relationshipPathName)) { 89 | var relationshipPath = schema.paths[relationshipPathName]; 90 | } else if (_.isObject(relationshipPathName)) { 91 | var relationshipPath = relationshipPathName; 92 | } 93 | if (!relationshipPath) { 94 | throw new Error("No relationship path defined"); 95 | } 96 | var validationError = validatePath(relationshipPath); 97 | if (validationError) { 98 | throw validationError; 99 | } 100 | 101 | var opts = optionsForRelationship(relationshipPath); 102 | if (opts.validateExistence || opts.upsert) { 103 | if (_.isFunction(relationshipPath.options.type)) { 104 | schema.path(relationshipPathName).validate(function(value, response) { 105 | var relationshipTargetModel = this.db.model(opts.ref); 106 | relationshipTargetModel.findById(value, function(err, result) { 107 | if (err) { 108 | response(false); 109 | } else if (!result) { 110 | if (opts.upsert) { 111 | var targetModel = new relationshipTargetModel({ 112 | _id: value 113 | }); 114 | 115 | targetModel.save(function(err, model) { 116 | response(!err && model); 117 | }); 118 | } else { 119 | response(false); 120 | } 121 | } else { 122 | response(true); 123 | } 124 | }); 125 | }, "Relationship entity " + opts.ref + " does not exist"); 126 | } else if (_.isObject(relationshipPath.options.type)) { 127 | schema.path(relationshipPathName).validate(function(value, response) { 128 | var relationshipTargetModel = this.db.model(opts.ref); 129 | relationshipTargetModel.find({ 130 | _id: { 131 | $in: value 132 | } 133 | }, function(err, result) { 134 | if (err || !result) { 135 | response(false); 136 | } else if (result.length !== value.length) { 137 | if (opts.upsert) { 138 | var existingModels = result.map(function(o) { 139 | return o._id.toString(); 140 | }); 141 | value = value.map(function(id) { 142 | return id.toString(); 143 | }); 144 | var modelsToCreate = _.difference(value, existingModels); 145 | async.each( 146 | modelsToCreate, 147 | function(id, cb) { 148 | var mdl = new relationshipTargetModel({ 149 | _id: id 150 | }); 151 | 152 | mdl.save(cb); 153 | }, 154 | function(err) { 155 | response(!err); 156 | } 157 | ); 158 | } else { 159 | response(false); 160 | } 161 | } else { 162 | response(true); 163 | } 164 | }); 165 | }, "Relationship entity " + opts.ref + " does not exist"); 166 | } 167 | } 168 | }); 169 | 170 | schema.pre('save', true, function(next, done) { 171 | var self = this; 172 | next(); 173 | 174 | self.constructor.findById(self._id, function(err, oldModel) { 175 | async.each( 176 | relationshipPaths, 177 | function(path, callback) { 178 | if (!self.isModified(path)) { 179 | return callback(); 180 | } 181 | 182 | var oldValue = oldModel ? oldModel.get(path) : undefined; 183 | var newValue = self.get(path); 184 | 185 | async.series([ 186 | function(cb) { 187 | self.updateCollectionForRelationship(path, oldValue, 'remove', cb); 188 | }, 189 | function(cb) { 190 | self.updateCollectionForRelationship(path, newValue, 'add', cb); 191 | } 192 | ], 193 | callback); 194 | }, done); 195 | }); 196 | }); 197 | 198 | schema.pre('remove', true, function(next, done) { 199 | var self = this; 200 | next(); 201 | async.each(relationshipPaths, 202 | function(path, callback) { 203 | self.updateCollectionForRelationship(path, self.get(path), 'remove', callback); 204 | }, 205 | done); 206 | }); 207 | 208 | schema.method('updateCollectionForRelationship', function(relationshipPathName, relationshiptPathValue, updateAction, done) { 209 | var relationshipPathOptions = optionsForRelationship(this.schema.paths[relationshipPathName]); 210 | var childPath = relationshipPathOptions.childPath; 211 | var relationshipTargetModel = this.db.model(relationshipPathOptions.ref); 212 | 213 | if (!relationshiptPathValue || !relationshipTargetModel || !relationshipTargetModel.schema.paths[childPath]) { 214 | return done(); 215 | } 216 | 217 | var relationshipTargetModelPath = relationshipTargetModel.schema.paths[childPath]; 218 | var relationshipTargetType = relationshipTargetModelPath.options.type; 219 | 220 | var updateBehavior = {}; 221 | var updateRule = {}; 222 | updateRule[childPath] = this._id; 223 | 224 | // one-one 225 | if (_.isFunction(relationshipTargetType)) { 226 | updateBehavior[operationMap.func[updateAction]] = updateRule; 227 | } 228 | // one-many and many-many 229 | else if (_.isObject(relationshipTargetType)) { 230 | updateBehavior[operationMap.obj[updateAction]] = updateRule; 231 | } 232 | 233 | if (_.isEmpty(updateBehavior)) { 234 | return done(); 235 | } 236 | 237 | if (!_.isArray(relationshiptPathValue)) { 238 | relationshiptPathValue = [relationshiptPathValue]; 239 | } 240 | 241 | if (_.isEmpty(relationshiptPathValue)) { 242 | return updateRemovedParents(this._id, relationshipTargetModel, childPath, relationshiptPathValue, done); 243 | } 244 | 245 | var self = this; 246 | var filterOpts = { 247 | _id: { 248 | $in: relationshiptPathValue 249 | } 250 | }; 251 | relationshipTargetModel.update( 252 | filterOpts, 253 | updateBehavior, { 254 | multi: true 255 | }, 256 | function(err, result) { 257 | if (err) { 258 | return done(err); 259 | } 260 | 261 | if (!options.triggerMiddleware) { 262 | return updateRemovedParents(self._id, relationshipTargetModel, childPath, relationshiptPathValue, done); 263 | } 264 | 265 | relationshipTargetModel.find(filterOpts, function(err, results) { 266 | if (err) { 267 | return done(err); 268 | } 269 | 270 | async.each(results, 271 | function(result, cb) { 272 | result.markModified(childPath); 273 | result.save(cb); 274 | }, 275 | function(err) { 276 | if (err) { 277 | return done(err); 278 | } 279 | updateRemovedParents(self._id, relationshipTargetModel, childPath, relationshiptPathValue, done); 280 | }); 281 | }); 282 | }); 283 | }); 284 | }; 285 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-relationship", 3 | "description": "mongoose plugin to create maintain one-many and many-many bidirectional relationships between two schemas", 4 | "version": "0.1.5", 5 | "author": "Mike Sabatini ", 6 | "dependencies": { 7 | "async": "0.2.10", 8 | "lodash": "^3.3.1" 9 | }, 10 | "devDependencies": { 11 | "mocha": "^2.1.0", 12 | "mongoose": "~3.8.24", 13 | "should": "^5.1.0" 14 | }, 15 | "keywords": [ 16 | "mongoose", 17 | "parent" 18 | ], 19 | "license": "MIT", 20 | "main": "index.js", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/sabymike/mongoose-relationship" 24 | }, 25 | "scripts": { 26 | "test": "mocha test -R spec" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/relationshipSpec.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /* jshint expr:true */ 4 | 5 | var mongoose = require("mongoose"), 6 | should = require("should"), 7 | async = require("async"), 8 | relationship = require(".."); 9 | var Schema = mongoose.Schema; 10 | var ObjectId = Schema.ObjectId; 11 | 12 | mongoose.connect(process.env.MONGODB_URL || "mongodb://localhost:27017/mongoose-relationship"); 13 | 14 | describe("Schema Key Tests", function() { 15 | describe("Testing initialization", function() { 16 | 17 | var ChildSchema = new Schema({ 18 | name: String, 19 | }); 20 | 21 | it("should throw an error if the path is not present in the schema", function() { 22 | (function() { 23 | ChildSchema.plugin(relationship); 24 | }).should.throw('No relationship path defined'); 25 | }); 26 | 27 | it("should throw an error if a ref is not provided on the relationship path", function() { 28 | (function() { 29 | ChildSchema.add({ 30 | relation: ObjectId 31 | }); 32 | ChildSchema.plugin(relationship, { 33 | relationshipPathName: 'relation', 34 | triggerMiddleware: false 35 | }); 36 | }).should.throw('Relationship relation requires a ref'); 37 | }); 38 | 39 | it("should thrown an error if the relationship path does not have a child collection option", function() { 40 | (function() { 41 | ChildSchema.add({ 42 | relation: { 43 | type: ObjectId, 44 | ref: 'ParentSchema' 45 | } 46 | }); 47 | ChildSchema.plugin(relationship, { 48 | relationshipPathName: 'relation', 49 | triggerMiddleware: false 50 | }); 51 | }).should.throw('Relationship relation requires a childPath for its parent'); 52 | }); 53 | 54 | it("should not throw an error if all the parameters are set correctly", function() { 55 | (function() { 56 | ChildSchema.add({ 57 | relation: { 58 | type: ObjectId, 59 | ref: 'ParentSchema', 60 | childPath: "children" 61 | } 62 | }); 63 | ChildSchema.plugin(relationship, { 64 | relationshipPathName: 'relation', 65 | triggerMiddleware: false 66 | }); 67 | }).should.not.throw(); 68 | }); 69 | }); 70 | 71 | describe("Testing Middleware Flag", function() { 72 | var Child, Parent; 73 | before(function() { 74 | var self = this; 75 | var ParentSchema = new Schema({ 76 | child: { 77 | type: ObjectId, 78 | ref: "ChildMiddleware" 79 | } 80 | }); 81 | ParentSchema.pre('save', function(next) { 82 | self.middlewareCalled = true; 83 | next(); 84 | }); 85 | Parent = mongoose.model("ParentMiddleware", ParentSchema); 86 | 87 | var ChildSchema = new Schema({ 88 | parent: { 89 | type: ObjectId, 90 | ref: "ParentMiddleware", 91 | childPath: "child" 92 | } 93 | }); 94 | ChildSchema.plugin(relationship, { 95 | relationshipPathName: "parent", 96 | triggerMiddleware: true 97 | }); 98 | Child = mongoose.model("ChildMiddleware", ChildSchema); 99 | }); 100 | 101 | beforeEach(function(done) { 102 | this.middlewareCalled = false; 103 | this.parent = new Parent({}); 104 | this.child = new Child({ 105 | parent: this.parent._id 106 | }); 107 | 108 | var self = this; 109 | this.parent.save(function(err, parent) { 110 | self.middlewareCalled = false; 111 | done(err); 112 | }); 113 | }); 114 | 115 | it("should trigger any parent save middleware when a relationship is updated", function(done) { 116 | var self = this; 117 | self.middlewareCalled.should.not.be.ok; 118 | this.child.save(function(err, child) { 119 | self.middlewareCalled.should.be.ok; 120 | done(err); 121 | }); 122 | }); 123 | }); 124 | 125 | describe("Upsert", function() { 126 | describe('One-To-One', function() { 127 | var Child, Parent; 128 | before(function() { 129 | var ParentSchema = new Schema({ 130 | child: { 131 | type: ObjectId, 132 | ref: 'ChildUpsertOneOne' 133 | } 134 | }); 135 | Parent = mongoose.model('ParentUpsertOneOne', ParentSchema); 136 | 137 | var ChildSchema = new Schema({ 138 | parent: { 139 | type: ObjectId, 140 | ref: 'ParentUpsertOneOne', 141 | childPath: 'child', 142 | upsert: true 143 | } 144 | }); 145 | ChildSchema.plugin(relationship, { 146 | relationshipPathName: 'parent', 147 | triggerMiddleware: false 148 | }); 149 | Child = mongoose.model('ChildUpsertOneOne', ChildSchema); 150 | }); 151 | 152 | it('should create the parent if it does not exist when upsert == true', function(done) { 153 | var child = new Child({ 154 | parent: mongoose.Types.ObjectId() 155 | }); 156 | 157 | child.save(function(err, child) { 158 | should.not.exist(err); 159 | Parent.findById(child.parent, function(err, parent) { 160 | should.exist(parent); 161 | done(err); 162 | }); 163 | }); 164 | }); 165 | }); 166 | 167 | describe('One-To-Many', function() { 168 | var Child, Parent; 169 | before(function() { 170 | var ParentSchema = new Schema({ 171 | child: { 172 | type: ObjectId, 173 | ref: 'ChildUpsertOneMany' 174 | } 175 | }); 176 | Parent = mongoose.model('ParentUpsertOneMany', ParentSchema); 177 | 178 | var ChildSchema = new Schema({ 179 | parents: [{ 180 | type: ObjectId, 181 | ref: 'ParentUpsertOneMany', 182 | childPath: 'child', 183 | upsert: true 184 | }] 185 | }); 186 | ChildSchema.plugin(relationship, { 187 | relationshipPathName: 'parents', 188 | triggerMiddleware: false 189 | }); 190 | Child = mongoose.model('ChildUpsertOneMany', ChildSchema); 191 | }); 192 | 193 | beforeEach(function(done) { 194 | this.parent = new Parent({}); 195 | this.parent.save(done); 196 | }); 197 | 198 | it('should create all the parents that do not exist', function(done) { 199 | var child = new Child({ 200 | parents: [this.parent._id, mongoose.Types.ObjectId()] 201 | }); 202 | 203 | child.save(function(err, child) { 204 | should.not.exist(err); 205 | Parent.find({ 206 | _id: { 207 | $in: child.parents 208 | } 209 | }, function(err, parents) { 210 | parents.should.have.length(child.parents.length); 211 | parents.should.containDeep([{ 212 | _id: child.parents[0] 213 | }]); 214 | parents.should.containDeep([{ 215 | _id: child.parents[1] 216 | }]); 217 | done(err); 218 | }); 219 | }); 220 | }); 221 | }); 222 | }); 223 | 224 | describe("One-To-One", function() { 225 | var Child, Parent; 226 | before(function() { 227 | var ParentSchema = new Schema({ 228 | child: { 229 | type: ObjectId, 230 | ref: "ChildOneOne" 231 | } 232 | }); 233 | Parent = mongoose.model("ParentOneOne", ParentSchema); 234 | 235 | var ChildSchema = new Schema({ 236 | name: String, 237 | parent: { 238 | type: ObjectId, 239 | ref: "ParentOneOne", 240 | childPath: "child" 241 | } 242 | }); 243 | ChildSchema.plugin(relationship, { 244 | relationshipPathName: 'parent', 245 | triggerMiddleware: false 246 | }); 247 | Child = mongoose.model("ChildOneOne", ChildSchema); 248 | }); 249 | 250 | beforeEach(function() { 251 | this.parent = new Parent({}); 252 | this.child = new Child({}); 253 | }); 254 | 255 | it("should not add a child if the parent does not exist in the database", function(done) { 256 | this.child.parent = this.parent._id; 257 | this.child.save(function(err, child) { 258 | should.not.exist(err); 259 | Parent.findById(child.parent, function(err, parent) { 260 | should.not.exist(parent); 261 | done(err); 262 | }); 263 | }); 264 | }); 265 | 266 | describe("Save Actions", function() { 267 | beforeEach(function(done) { 268 | var self = this; 269 | self.parent.save(function(err, parent) { 270 | self.child.parent = self.parent._id; 271 | self.child.save(function(err, child) { 272 | done(err); 273 | }); 274 | }); 275 | }); 276 | 277 | it("should add a child to the parent collection if the parent is set", function(done) { 278 | var self = this; 279 | Parent.findById(this.child.parent, function(err, parent) { 280 | parent.child.should.eql(self.child._id); 281 | done(err); 282 | }); 283 | }); 284 | 285 | it("should remove a child from the parent collection if the parent is set", function(done) { 286 | var self = this; 287 | self.child.remove(function(err, child) { 288 | Parent.findById(child.parent, function(err, parent) { 289 | should.not.exist(parent.child); 290 | done(err); 291 | }); 292 | }); 293 | }); 294 | 295 | it("should remove a child from the parent if the child relationship is unset", function(done) { 296 | var self = this; 297 | self.child.parent = undefined; 298 | self.child.save(function(err, child) { 299 | should.not.exist(err); 300 | should.not.exist(child.parent); 301 | Parent.findById(self.parent._id, function(err, parent) { 302 | should.not.exist(parent.child); 303 | done(err); 304 | }); 305 | }); 306 | }); 307 | }); 308 | }); 309 | 310 | describe("Parent Existence", function() { 311 | describe("Single Parent", function() { 312 | var Child, Parent; 313 | before(function() { 314 | var ParentSchema = new Schema({ 315 | children: [{ 316 | type: ObjectId, 317 | ref: "ChildOneManyValidate" 318 | }] 319 | }); 320 | Parent = mongoose.model("ParentOneManyValidate", ParentSchema); 321 | 322 | var ChildSchema = new Schema({ 323 | name: String, 324 | parent: { 325 | type: ObjectId, 326 | ref: "ParentOneManyValidate", 327 | childPath: "children", 328 | validateExistence: true 329 | } 330 | }); 331 | ChildSchema.plugin(relationship, { 332 | relationshipPathName: 'parent', 333 | triggerMiddleware: false 334 | }); 335 | Child = mongoose.model("ChildOneManyValidate", ChildSchema); 336 | }); 337 | 338 | beforeEach(function() { 339 | this.child = new Child({ 340 | parent: new mongoose.Types.ObjectId() 341 | }); 342 | }); 343 | 344 | it("should validate the existence of the relationship before saving if the flag is set", function(done) { 345 | this.child.save(function(err, child) { 346 | should.exist(err); 347 | err.errors.parent.message.should.eql("Relationship entity ParentOneManyValidate does not exist"); 348 | done(); 349 | }); 350 | }); 351 | 352 | it('should create the relationship if the parent actually exists', function(done) { 353 | var parent = new Parent(); 354 | this.child.parent = parent; 355 | 356 | var self = this; 357 | parent.save(function(err, parent) { 358 | self.child.save(function(err, child) { 359 | child.should.exist; 360 | done(err); 361 | }); 362 | }); 363 | }); 364 | 365 | it('should create and remove the relationship if the parent actually exists', function(done) { 366 | var parent = new Parent(); 367 | this.child.parent = parent; 368 | 369 | var self = this; 370 | async.series([ 371 | function(cb) { 372 | parent.save(function(err, parent) { 373 | parent.children.should.be.lengthOf(0); 374 | cb(err); 375 | }); 376 | }, 377 | function(cb) { 378 | self.child.save(function(err, child) { 379 | child.should.exist; 380 | child.parent.should.exist; 381 | cb(err); 382 | }); 383 | }, 384 | function(cb) { 385 | Parent.findById(parent._id, function(err, parent) { 386 | should.not.exist(err); 387 | parent.children.should.be.lengthOf(1); 388 | parent.children = []; 389 | parent.save(function(err, parent) { 390 | parent.should.exist; 391 | cb(err); 392 | }); 393 | }); 394 | } 395 | ], 396 | done); 397 | }); 398 | }); 399 | 400 | describe("Multiple Parents", function() { 401 | var Child, Parent; 402 | before(function() { 403 | var ParentSchema = new Schema({ 404 | children: [{ 405 | type: ObjectId, 406 | ref: "ChildManyManyValidate" 407 | }] 408 | }); 409 | Parent = mongoose.model("ParentManyManyValidate", ParentSchema); 410 | 411 | var ChildSchema = new Schema({ 412 | name: String, 413 | parents: [{ 414 | type: ObjectId, 415 | ref: "ParentManyManyValidate", 416 | childPath: "children", 417 | validateExistence: true 418 | }] 419 | }); 420 | ChildSchema.plugin(relationship, { 421 | relationshipPathName: 'parents', 422 | triggerMiddleware: false 423 | }); 424 | Child = mongoose.model("ChildManyManyValidate", ChildSchema); 425 | }); 426 | 427 | beforeEach(function() { 428 | this.child = new Child({ 429 | parents: [new mongoose.Types.ObjectId()] 430 | }); 431 | }); 432 | 433 | it("should validate the existence of the relationship before saving if the flag is set", function(done) { 434 | this.child.save(function(err, child) { 435 | should.exist(err); 436 | err.errors.parents.message.should.eql("Relationship entity ParentManyManyValidate does not exist"); 437 | done(); 438 | }); 439 | }); 440 | 441 | it('should fail if just one id in the relationship list does not exist', function(done) { 442 | var parent = new Parent(); 443 | this.child.parents = [parent, new mongoose.Types.ObjectId()]; 444 | 445 | var self = this; 446 | parent.save(function(err, parent) { 447 | self.child.save(function(err, child) { 448 | should.exist(err); 449 | err.errors.parents.message.should.eql("Relationship entity ParentManyManyValidate does not exist"); 450 | done(); 451 | }); 452 | }); 453 | }); 454 | 455 | it('should create the relationship if the parent actually exists', function(done) { 456 | var parent = new Parent(); 457 | this.child.parents = [parent]; 458 | 459 | var self = this; 460 | parent.save(function(err, parent) { 461 | self.child.save(function(err, child) { 462 | child.should.exist; 463 | done(err); 464 | }); 465 | }); 466 | }); 467 | }); 468 | }); 469 | 470 | describe("One-To-Many", function() { 471 | var Child, Parent; 472 | before(function() { 473 | var ParentSchema = new Schema({ 474 | children: [{ 475 | type: ObjectId, 476 | ref: "ChildOneMany" 477 | }] 478 | }); 479 | Parent = mongoose.model("ParentOneMany", ParentSchema); 480 | 481 | var ChildSchema = new Schema({ 482 | name: String, 483 | parent: { 484 | type: ObjectId, 485 | ref: "ParentOneMany", 486 | childPath: "children" 487 | } 488 | }); 489 | ChildSchema.plugin(relationship, { 490 | relationshipPathName: 'parent', 491 | triggerMiddleware: false 492 | }); 493 | Child = mongoose.model("ChildOneMany", ChildSchema); 494 | }); 495 | 496 | beforeEach(function() { 497 | this.parent = new Parent({}); 498 | this.child = new Child({}); 499 | }); 500 | 501 | it("should not add a child if the parent does not exist in the database", function(done) { 502 | this.child.parent = this.parent._id; 503 | this.child.save(function(err, child) { 504 | should.not.exist(err); 505 | Parent.findById(child.parent, function(err, parent) { 506 | should.not.exist(parent); 507 | done(err); 508 | }); 509 | }); 510 | }); 511 | 512 | describe("Save Actions", function() { 513 | beforeEach(function(done) { 514 | var self = this; 515 | self.parent.save(function(err, parent) { 516 | self.child.parent = self.parent._id; 517 | self.child.save(function(err, child) { 518 | done(err); 519 | }); 520 | }); 521 | }); 522 | 523 | it("should add a child to the parent collection if the parent is set", function(done) { 524 | var self = this; 525 | Parent.findById(this.child.parent, function(err, parent) { 526 | parent.children.should.containEql(self.child._id); 527 | done(err); 528 | }); 529 | }); 530 | 531 | it("should remove a child from the parent collection if the parent is set", function(done) { 532 | var self = this; 533 | self.child.remove(function(err, child) { 534 | Parent.findById(child.parent, function(err, parent) { 535 | parent.children.should.not.containEql(child._id); 536 | done(err); 537 | }); 538 | }); 539 | }); 540 | 541 | it("should remove a child from the parent if the child relationship is unset", function(done) { 542 | var self = this; 543 | self.child.parent = undefined; 544 | self.child.save(function(err, child) { 545 | should.not.exist(err); 546 | should.not.exist(child.parent); 547 | Parent.findById(self.parent._id, function(err, parent) { 548 | parent.children.should.be.empty; 549 | done(err); 550 | }); 551 | }); 552 | }); 553 | }); 554 | }); 555 | 556 | describe("Many-To-Many", function() { 557 | var Child, Parent; 558 | before(function() { 559 | var ParentSchema = new Schema({ 560 | children: [{ 561 | type: ObjectId, 562 | ref: "ChildManyMany" 563 | }] 564 | }); 565 | Parent = mongoose.model("ParentManyMany", ParentSchema); 566 | 567 | var ChildSchema = new Schema({ 568 | name: String, 569 | parents: [{ 570 | type: ObjectId, 571 | ref: "ParentManyMany", 572 | childPath: "children" 573 | }] 574 | }); 575 | ChildSchema.plugin(relationship, { 576 | relationshipPathName: 'parents', 577 | triggerMiddleware: false 578 | }); 579 | Child = mongoose.model("ChildManyMany", ChildSchema); 580 | }); 581 | 582 | beforeEach(function() { 583 | this.parent = new Parent({}); 584 | this.otherParent = new Parent({}); 585 | this.child = new Child({}); 586 | }); 587 | 588 | it("should not add a child if the parent does not exist in the database", function(done) { 589 | this.child.parents.push(this.parent._id); 590 | this.child.parents.push(this.otherParent._id); 591 | this.child.save(function(err, child) { 592 | should.not.exist(err); 593 | Parent.find({ 594 | _id: { 595 | $in: child.parents 596 | } 597 | }, function(err, parents) { 598 | parents.should.be.empty; 599 | done(err); 600 | }); 601 | }); 602 | }); 603 | 604 | describe("Save Actions", function() { 605 | beforeEach(function(done) { 606 | var self = this; 607 | self.parent.save(function(err, parent) { 608 | self.otherParent.save(function(err, otherParent) { 609 | self.child.parents.push(parent._id); 610 | self.child.parents.push(otherParent._id); 611 | self.child.save(function(err, child) { 612 | done(err); 613 | }); 614 | }); 615 | }); 616 | }); 617 | 618 | it("should add a child to the parent collection if the parent is set", function(done) { 619 | var self = this; 620 | Parent.find({ 621 | _id: { 622 | $in: this.child.parents 623 | } 624 | }, function(err, parents) { 625 | var parent; 626 | for (var i = 0; i < parents.length; i++) { 627 | parent = parents[i]; 628 | parent.should.have.property('children').containEql(self.child._id); 629 | } 630 | done(err); 631 | }); 632 | }); 633 | 634 | it("should remove a child from the parent collection if the parent is set", function(done) { 635 | var self = this; 636 | self.child.remove(function(err, child) { 637 | Parent.find({ 638 | _id: { 639 | $in: self.child.parents 640 | } 641 | }, function(err, parents) { 642 | var parent; 643 | for (var i = 0; i < parents.length; i++) { 644 | parent = parents[i]; 645 | parent.should.have.property('children').not.containEql(self.child._id); 646 | } 647 | done(err); 648 | }); 649 | }); 650 | }); 651 | 652 | it("should remove a child from the parent collection if parent is removed from child's set", function(done) { 653 | var self = this; 654 | self.child.parents = [self.otherParent._id]; 655 | self.child.save(function(err, child) { 656 | should.not.exist(err); 657 | Parent.find({ 658 | children: { 659 | $in: [child._id] 660 | } 661 | }, function(err, parents) { 662 | parents.should.have.a.lengthOf(1); 663 | parents[0]._id.should.eql(self.otherParent._id); 664 | done(err); 665 | }); 666 | }); 667 | }); 668 | 669 | it("should remove a child from the parents if the child relationship is removed from its parent list", function(done) { 670 | var self = this; 671 | self.child.parents = self.child.parents.splice(0, 1); 672 | self.child.save(function(err, child) { 673 | should.not.exist(err); 674 | child.parents.should.have.length(1); 675 | async.parallel([ 676 | function(cb) { 677 | Parent.findById(self.otherParent._id, function(err, parent) { 678 | parent.children.should.be.empty; 679 | cb(err); 680 | }); 681 | }, 682 | function(cb) { 683 | Parent.findById(self.parent._id, function(err, parent) { 684 | parent.children.should.containEql(self.child._id); 685 | cb(err); 686 | }); 687 | } 688 | ], 689 | done); 690 | }); 691 | }); 692 | 693 | }); 694 | }); 695 | 696 | describe("Many-To-Many With Multiple relationships", function() { 697 | var Child, Parent, OtherParent; 698 | before(function() { 699 | var ParentSchema = new Schema({ 700 | children: [{ 701 | type: ObjectId, 702 | ref: "ChildMultiple" 703 | }] 704 | }); 705 | Parent = mongoose.model("ParentMultiple", ParentSchema); 706 | 707 | var OtherParentSchema = new Schema({ 708 | otherChildren: [{ 709 | type: ObjectId, 710 | ref: "ChildMultiple" 711 | }] 712 | }); 713 | OtherParent = mongoose.model("OtherParentMultiple", OtherParentSchema); 714 | 715 | var ChildSchema = new Schema({ 716 | name: String, 717 | parents: [{ 718 | type: ObjectId, 719 | ref: "ParentMultiple", 720 | childPath: "children" 721 | }], 722 | otherParents: [{ 723 | type: ObjectId, 724 | ref: "OtherParentMultiple", 725 | childPath: "otherChildren" 726 | }] 727 | }); 728 | ChildSchema.plugin(relationship, { 729 | relationshipPathName: ['parents', 'otherParents'], 730 | triggerMiddleware: false 731 | }); 732 | Child = mongoose.model("ChildMultiple", ChildSchema); 733 | }); 734 | 735 | beforeEach(function() { 736 | this.parent = new Parent({}); 737 | this.otherParent = new OtherParent({}); 738 | this.child = new Child({}); 739 | }); 740 | 741 | it("should not add a child if the parent does not exist in the database", function(done) { 742 | this.child.parents.push(this.parent._id); 743 | this.child.otherParents.push(this.otherParent._id); 744 | this.child.save(function(err, child) { 745 | should.not.exist(err); 746 | async.parallel([ 747 | function(callback) { 748 | Parent.find({ 749 | _id: { 750 | $in: child.parents 751 | } 752 | }, function(err, parents) { 753 | parents.should.be.empty; 754 | callback(err); 755 | }); 756 | }, 757 | function(callback) { 758 | OtherParent.find({ 759 | _id: { 760 | $in: child.otherParents 761 | } 762 | }, function(err, parents) { 763 | parents.should.be.empty; 764 | callback(err); 765 | }); 766 | } 767 | ], 768 | function(err) { 769 | done(err); 770 | }); 771 | }); 772 | }); 773 | 774 | describe("Save Actions", function() { 775 | beforeEach(function(done) { 776 | var self = this; 777 | self.parent.save(function(err, parent) { 778 | self.otherParent.save(function(err, otherParent) { 779 | self.child.parents.push(parent._id); 780 | self.child.otherParents.push(otherParent._id); 781 | self.child.save(function(err, child) { 782 | done(err); 783 | }); 784 | }); 785 | }); 786 | }); 787 | 788 | it("should add a child to the parent collection if the parent is set", function(done) { 789 | var self = this; 790 | async.parallel([ 791 | function(callback) { 792 | Parent.find({ 793 | _id: { 794 | $in: self.child.parents 795 | } 796 | }, function(err, parents) { 797 | var parent; 798 | for (var i = 0; i < parents.length; i++) { 799 | parent = parents[i]; 800 | parent.should.have.property('children').containEql(self.child._id); 801 | } 802 | callback(err); 803 | }); 804 | }, 805 | function(callback) { 806 | OtherParent.find({ 807 | _id: { 808 | $in: self.child.otherParents 809 | } 810 | }, function(err, parents) { 811 | var parent; 812 | for (var i = 0; i < parents.length; i++) { 813 | parent = parents[i]; 814 | parent.should.have.property('otherChildren').containEql(self.child._id); 815 | } 816 | callback(err); 817 | }); 818 | } 819 | ], 820 | function(err) { 821 | done(err); 822 | }); 823 | }); 824 | 825 | it("should remove a child from the parent collection if the parent is set", function(done) { 826 | var self = this; 827 | self.child.remove(function(err, child) { 828 | async.parallel([ 829 | function(callback) { 830 | Parent.find({ 831 | _id: { 832 | $in: self.child.parents 833 | } 834 | }, function(err, parents) { 835 | var parent; 836 | for (var i = 0; i < parents.length; i++) { 837 | parent = parents[i]; 838 | parent.should.have.property('children').not.containEql(self.child._id); 839 | } 840 | callback(err); 841 | }); 842 | }, 843 | function(callback) { 844 | OtherParent.find({ 845 | _id: { 846 | $in: self.child.otherParents 847 | } 848 | }, function(err, parents) { 849 | var parent; 850 | for (var i = 0; i < parents.length; i++) { 851 | parent = parents[i]; 852 | parent.should.have.property('otherChildren').not.containEql(self.child._id); 853 | } 854 | callback(err); 855 | }); 856 | } 857 | ], 858 | function(err) { 859 | done(err); 860 | }); 861 | }); 862 | }); 863 | }); 864 | }); 865 | }); 866 | --------------------------------------------------------------------------------