├── .versions ├── smart.json ├── package.js ├── lib └── joins.js └── README.md /.versions: -------------------------------------------------------------------------------- 1 | meteor@1.8.0 2 | perak:joins@1.1.8 3 | -------------------------------------------------------------------------------- /smart.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "joins", 3 | "description": "Generic collection joins for Meteor", 4 | "homepage": "https://github.com/perak/meteor-joins", 5 | "author": "Petar Korponaic", 6 | "version": "1.2.0", 7 | "git": "https://github.com/perak/meteor-joins.git", 8 | "packages": {} 9 | } 10 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: "perak:joins", 3 | summary: "Generic collection joins for Meteor", 4 | version: "1.2.0", 5 | git: "https://github.com/perak/meteor-joins.git" 6 | }); 7 | 8 | Package.onUse(function (api) { 9 | // api.use(["mongo", "underscore"]); 10 | 11 | if(api.versionsFrom) { 12 | api.versionsFrom('METEOR@0.9.0'); 13 | } 14 | 15 | 16 | api.add_files('lib/joins.js', ["client", "server"]); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/joins.js: -------------------------------------------------------------------------------- 1 | var Mongo = Package.mongo.Mongo; 2 | var Random = Package.random.Random; 3 | 4 | var globalContext = this; 5 | 6 | var accessPropertyViaDotNotation = function(propertyName, obj) { 7 | var props = propertyName.split("."); 8 | var res = obj; 9 | for(var i = 0; i < props.length; i++) { 10 | res = res[props[i]]; 11 | if(typeof res == "undefined") { 12 | return res; 13 | } 14 | } 15 | return res; 16 | }; 17 | 18 | var __original = { 19 | find: Mongo.Collection.prototype.find, 20 | findOne: Mongo.Collection.prototype.findOne 21 | }; 22 | 23 | //---- 24 | 25 | this._ReactiveJoins = new Mongo.Collection("reactive_joins"); 26 | 27 | if(Meteor.isServer) { 28 | Meteor.publish({ 29 | reactive_joins: function() { 30 | return _ReactiveJoins.find(); 31 | } 32 | }); 33 | } else { 34 | __original.subscribe = Meteor.subscribe; 35 | 36 | Meteor.subscribe = function() { 37 | var args = []; 38 | for(var i = 0; i < arguments.length; i++) { 39 | args.push(arguments[i]); 40 | } 41 | 42 | if(args.length) { 43 | var update = _ReactiveJoins.findOne({ name: args[0] }); 44 | if(update) { 45 | args.push(update.updateId); 46 | } 47 | } 48 | return __original.subscribe.apply(null, args); 49 | }; 50 | 51 | 52 | Meteor.startup(function() { 53 | Meteor.subscribe("reactive_joins"); 54 | }); 55 | } 56 | 57 | 58 | Mongo.Collection.prototype.doJoin = function(collectionObject, collectionName, collectionNameField, foreignKey, containerField, fieldList) { 59 | this._joins = this._joins || []; 60 | 61 | this._joins.push({ 62 | collectionObject: collectionObject, 63 | collectionName: collectionName, 64 | collectionNameField: collectionNameField, 65 | foreignKey: foreignKey, 66 | containerField: containerField, 67 | fieldList: fieldList 68 | }); 69 | 70 | this.transformFind = function(originalFind, selector, options) { 71 | var self = this; 72 | selector = selector || {}; 73 | options = options || {}; 74 | 75 | var originalTransform = options.transform || null; 76 | 77 | options.transform = function (doc) { 78 | _.each(self._joins, function (join) { 79 | var opt = {}; 80 | if (join.fieldList && join.fieldList.length) { 81 | opt.fields = {}; 82 | _.each(join.fieldList, function (field) { 83 | opt.fields[field] = 1; 84 | }); 85 | } 86 | 87 | var coll = null; 88 | if (join.collectionObject) 89 | coll = join.collectionObject; 90 | else if (join.collectionName) 91 | coll = globalContext[join.collectionName]; 92 | else if (join.collectionNameField) 93 | coll = globalContext[doc[join.collectionNameField]]; 94 | 95 | if (coll) { 96 | var container = join.containerField || coll._name + "_joined"; 97 | doc[container] = __original.findOne.call(coll, {_id: accessPropertyViaDotNotation(join.foreignKey, doc)}, opt) || {}; 98 | } 99 | }); 100 | if (originalTransform) 101 | return originalTransform(doc); 102 | else 103 | return doc; 104 | }; 105 | return originalFind.call(this, selector, options); 106 | }; 107 | 108 | this.findOne = function (selector, options) { 109 | return this.transformFind(__original.findOne, selector, options); 110 | }; 111 | 112 | this.find = function (selector, options) { 113 | return this.transformFind(__original.find, selector, options); 114 | }; 115 | 116 | }; 117 | 118 | // collection argument can be collection object or collection name 119 | Mongo.Collection.prototype.join = function(collection, foreignKey, containerField, fieldList) { 120 | var collectionObject = null; 121 | var collectionName = ""; 122 | 123 | if(_.isString(collection)) { 124 | collectionName = collection; 125 | } else { 126 | collectionObject = collection; 127 | } 128 | 129 | this.doJoin(collectionObject, collectionName, "", foreignKey, containerField, fieldList); 130 | }; 131 | 132 | Mongo.Collection.prototype.genericJoin = function(collectionNameField, foreignKey, containerField) { 133 | this.doJoin(null, "", collectionNameField, foreignKey, containerField, []); 134 | }; 135 | 136 | Mongo.Collection.prototype.publishJoinedCursors = function(cursor, options, publication) { 137 | 138 | var self = this; 139 | var filters = {}; 140 | 141 | _.each(this._joins, function(join) { 142 | 143 | if(join.collectionObject || join.collectionName) { 144 | var coll = null; 145 | 146 | if(join.collectionObject) { 147 | coll = join.collectionObject; 148 | } else { 149 | coll = globalContext[join.collectionName]; 150 | } 151 | 152 | if(coll) { 153 | var ids = cursor.map(function(doc) { return accessPropertyViaDotNotation(join.foreignKey, doc); }); 154 | if(!filters[coll._name]) { 155 | filters[coll._name] = { 156 | collection: coll, 157 | filter: { _id: { $in: ids } }, 158 | foreignKeys: [join.foreignKey] 159 | }; 160 | } else { 161 | filters[coll._name].filter._id["$in"] = _.union(filters[coll._name].filter._id["$in"], ids); 162 | filters[coll._name].foreignKeys.push(join.foreignKey); 163 | } 164 | 165 | var options = filters[coll._name].options || {}; 166 | if(join.fieldList && join.fieldList.length) { 167 | if(!options.fields) { 168 | options.fields = {}; 169 | } 170 | _.each(join.fieldList, function(field) { 171 | options.fields[field] = 1; 172 | }); 173 | } 174 | filters[coll._name].options = options; 175 | } 176 | 177 | } else if(join.collectionNameField) { 178 | var data = cursor.map(function(doc) { 179 | var res = {}; 180 | res[join.collectionNameField] = doc[join.collectionNameField]; 181 | res[join.foreignKey] = accessPropertyViaDotNotation(join.foreignKey, doc); 182 | return res; 183 | }); 184 | 185 | var collectionNames = _.uniq(_.map(data, function(doc) { return doc[join.collectionNameField]; })); 186 | _.each(collectionNames, function(collectionName) { 187 | var coll = globalContext[collectionName]; 188 | if(coll) { 189 | var ids = _.map(_.filter(data, function(doc) { return doc[join.collectionNameField] === collectionName; }), function(el) { return accessPropertyViaDotNotation(join.foreignKey, el); }); 190 | if(!filters[coll._name]) { 191 | filters[coll._name] = { 192 | collection: coll, 193 | filter: { _id: { $in: ids } }, 194 | foreignKeys: [join.foreignKey] 195 | }; 196 | } else { 197 | filters[coll._name].filter._id["$in"] = _.union(filters[coll._name].filter._id["$in"], ids); 198 | filters[coll._name].foreignKeys.push(join.foreignKey); 199 | } 200 | 201 | var options = filters[coll._name].options || {}; 202 | if(join.fieldList && join.fieldList.length) { 203 | if(!options.fields) { 204 | options.fields = {}; 205 | } 206 | _.each(join.fieldList, function(field) { 207 | options.fields[field] = 1; 208 | }); 209 | } 210 | filters[coll._name].options = options; 211 | } 212 | }); 213 | } 214 | }); 215 | 216 | var observers = []; 217 | 218 | if(options && options.reactive && publication) { 219 | var observer = cursor.observe({ 220 | added: function(newDocument) { 221 | if(publication._ready) { 222 | _ReactiveJoins.upsert({ name: publication._name }, { $set: { name: publication._name, updateId: Random.id() }}); 223 | } 224 | }, 225 | changed: function(newDocument, oldDocument) { 226 | if(publication._ready) { 227 | var needUpdate = false; 228 | for(var key in filters) { 229 | var filter = filters[key]; 230 | if(filter && filter.foreignKeys) { 231 | filter.foreignKeys.map(function(foreignKey) { 232 | if(oldDocument[foreignKey] != newDocument[foreignKey]) { 233 | needUpdate = true; 234 | } 235 | }); 236 | } 237 | } 238 | 239 | if(needUpdate) { 240 | _ReactiveJoins.upsert({ name: publication._name }, { $set: { name: publication._name, updateId: Random.id() }}); 241 | } 242 | } 243 | } 244 | }); 245 | 246 | observers.push(observer); 247 | } 248 | 249 | var cursors = []; 250 | cursors.push(cursor); 251 | 252 | for(var key in filters) { 253 | var filter = filters[key]; 254 | if(filter && filter.collection && filter.filter) { 255 | var cur = filter.collection.find(filter.filter, filter.options); 256 | 257 | if(options && options.reactive && publication) { 258 | var observer = cur.observe({ 259 | changed: function (newDocument, oldDocument) { 260 | if(publication._ready) { 261 | _ReactiveJoins.upsert({ name: publication._name }, { $set: { name: publication._name, updateId: Random.id() }}); 262 | } 263 | } 264 | }); 265 | 266 | observers.push(observer); 267 | } 268 | cursors.push(cur); 269 | } 270 | } 271 | 272 | if(publication) { 273 | publication.onStop(function() { 274 | observers.map(function(observer) { 275 | observer.stop(); 276 | }) 277 | }); 278 | } 279 | 280 | return cursors; 281 | }; 282 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Generic collection joins for Meteor 2 | =================================== 3 | 4 | With this package included, you can define joins between collections. `Collection.find` and `Collection.findOne` will return data expanded with docs from joined collections. You can also create "generic join" - join one collection with multiple others using the same foreign key. 5 | 6 | This package is used by [Meteor Kitchen](http://www.meteorkitchen.com) - code generator for Meteor. 7 | 8 | Example 1 - simple join 9 | ----------------------- 10 | 11 | We have two collections: Companies & Employees 12 | 13 | ``` 14 | var Companies = new Mongo.Collection("companies"); 15 | var Employees = new Mongo.Collection("employees"); 16 | ``` 17 | 18 | Example **company** document: 19 | 20 | ``` 21 | { 22 | _id: "CQKDzmqmQXGhsC6PG", 23 | name: "Acme" 24 | } 25 | ``` 26 | 27 | Example **employee** document: 28 | 29 | ``` 30 | { 31 | _id: "dySSKA25pCtKjo5uA", 32 | name: "Jimi Hendrix", 33 | companyId: "CQKDzmqmQXGhsC6PG" 34 | } 35 | ``` 36 | 37 | Let's **define join** (in both server & client scope) 38 | 39 | ``` 40 | Employees.join(Companies, "companyId", "company", ["name"]); 41 | ``` 42 | 43 | *Or you can pass collection name:* 44 | 45 | ``` 46 | Employees.join("Companies", "companyId", "company", ["name"]); 47 | ``` 48 | 49 | And at server in publication, instead simply returning cursor, return with Collection.publishJoinedCursors method: 50 | 51 | ``` 52 | Meteor.publish("employees", function() { 53 | 54 | var cursor = Employees.find(); // do what you normally do here 55 | 56 | return Employees.publishJoinedCursors(cursor); // instead of simply returning resulting cursor 57 | }); 58 | ``` 59 | 60 | Now, if you do: 61 | 62 | ``` 63 | Employees.find(); 64 | ``` 65 | 66 | You'l get: 67 | 68 | ``` 69 | { 70 | _id: "dySSKA25pCtKjo5uA", 71 | name: "Jimi Hendrix", 72 | companyId: "CQKDzmqmQXGhsC6PG", 73 | company: { 74 | name: "Acme" 75 | } 76 | } 77 | ``` 78 | 79 | Join will be reactive if you pass `reactive: true` as option to publishJoinedCursors and publication context as last argument: 80 | 81 | ``` 82 | Meteor.publish("employees", function() { 83 | 84 | var cursor = Employees.find(); 85 | 86 | return Employees.publishJoinedCursors(cursor, { reactive: true }, this); 87 | 88 | }); 89 | ``` 90 | 91 | 92 | Example 2 - generic join 93 | ------------------------ 94 | 95 | Let's say we have four collections: 96 | 97 | ``` 98 | var Companies = new Mongo.Collection("companies"); 99 | var Employees = new Mongo.Collection("employees"); 100 | var Tags = new Mongo.Collection("tags"); 101 | var TaggedDocuments = new Mongo.Collection("tagged_documents"); 102 | ``` 103 | 104 | in "Tags" collection we have list of possible tags: 105 | 106 | ``` 107 | { 108 | _id: "wrWrXDqWwPrXCWsgu", 109 | name: "Awesome!" 110 | } 111 | ``` 112 | 113 | We can tag documents from both "Companies" and "Employees". When document is tagged we are storing three values into "TaggedDocuments" collection: 114 | 115 | ``` 116 | { 117 | tagId: "wrWrXDqWwPrXCWsgu", 118 | collectionName: "Employees", 119 | docId: "dySSKA25pCtKjo5uA" 120 | }, 121 | { 122 | tagId: "wrWrXDqWwPrXCWsgu", 123 | collectionName: "Companies", 124 | docId: "CQKDzmqmQXGhsC6PG" 125 | } 126 | ``` 127 | 128 | - `tagId` stores tag id from "Tags" collection 129 | - `collectionName` stores name of collection where tagged document belongs to 130 | - `docId` stores id of tagged document 131 | 132 | **collectionName** can be any existing collection. 133 | 134 | Let's define generic join: 135 | 136 | ``` 137 | TaggedDocuments.genericJoin("collectionName", "docId", "document"); 138 | ``` 139 | 140 | Now, if you do: 141 | 142 | ``` 143 | TaggedDocuments.find({ tagId: "wrWrXDqWwPrXCWsgu" }); 144 | ``` 145 | 146 | You'l get something like this: 147 | 148 | ``` 149 | { 150 | tagId: "wrWrXDqWwPrXCWsgu", 151 | collectionName: "Employees", 152 | docId: "dySSKA25pCtKjo5uA", 153 | document: { 154 | name: "Jimi Hendrix", 155 | companyId: "CQKDzmqmQXGhsC6PG" 156 | } 157 | }, 158 | { 159 | tagId: "wrWrXDqWwPrXCWsgu", 160 | collectionName: "Companies", 161 | docId: "CQKDzmqmQXGhsC6PG", 162 | document: { 163 | name: "Acme" 164 | } 165 | } 166 | ``` 167 | 168 | Also, you can define simple join to "Tags" collection too: 169 | 170 | ``` 171 | TaggedDocuments.join(Tags, "tagId", "tag", []); 172 | TaggedDocuments.genericJoin("collectionName", "docId", "document"); 173 | ``` 174 | 175 | And now if you do: 176 | 177 | ``` 178 | TaggedDocuments.find({ tagId: "wrWrXDqWwPrXCWsgu" }); 179 | ``` 180 | 181 | You'l get: 182 | 183 | ``` 184 | { 185 | tagId: "wrWrXDqWwPrXCWsgu", 186 | tag: { 187 | name: "Awesome!" 188 | }, 189 | collectionName: "Employees", 190 | docId: "dySSKA25pCtKjo5uA", 191 | document: { 192 | name: "Jimi Hendrix", 193 | companyId: "CQKDzmqmQXGhsC6PG" 194 | } 195 | }, 196 | { 197 | tagId: "wrWrXDqWwPrXCWsgu", 198 | tag: { 199 | name: "Awesome!" 200 | }, 201 | collectionName: "Companies", 202 | docId: "CQKDzmqmQXGhsC6PG", 203 | document: { 204 | name: "Acme" 205 | } 206 | } 207 | ``` 208 | 209 | voilà - we have generic N:M join! 210 | 211 | 212 | Function reference 213 | ================== 214 | 215 | Collection.join 216 | --------------- 217 | 218 | `Collection.join(collection, foreignKey, containerField, fieldList)` 219 | 220 | ### Arguments: 221 | 222 | - `collection` Mongo.Collection object (or collection name) to join 223 | - `foreignKey` field name where foreign document _id is stored (in our example: `"companyId"`) 224 | - `containerField` field name where to store foreign document (in our example: `"company"`) 225 | - `fieldList` array of field names we want to get from foreign collection (in our example array with one field `["name"]`) 226 | 227 | Use this function in scope visible both to client and server. 228 | 229 | 230 | Collection.genericJoin 231 | ---------------------- 232 | 233 | `Collection.genericJoin(collectionNameField, foreignKey, containerField)` 234 | 235 | - `collectionNameField` field name (from this collection) in which foreign collection name is stored 236 | - `foreignKey` field name where foreign document _id is stored 237 | - `containerField` field name where to store joined foreign document 238 | 239 | 240 | Collection.publishJoinedCursors 241 | ------------------------------- 242 | 243 | For use server side in publications: instead of simply returning result from collection, we want to return cursors with data from joined collections too. 244 | This function will query joined collections and will return array of cursors. 245 | 246 | `Collection.publishJoinedCursors(cursor, options, publicationContext)` 247 | 248 | ### Arguments 249 | 250 | - `cursor` cursor that you normally return from publish function 251 | - `options` options object, currently only one option exists: `{ reactive: true }` 252 | - `publicationContext` publish's `this` (only if you want it reactive) 253 | 254 | Example **publish** function: 255 | 256 | ``` 257 | Meteor.publish("employees", function() { 258 | 259 | var cursor = Employees.find(); // do what you normally do here 260 | 261 | return Employees.publishJoinedCursors(cursor); // instead of simply returning resulting cursor 262 | }); 263 | ``` 264 | With queried employees, cursor with companies filtered by employee.companyId will be returned too. 265 | 266 | If you want it reactive: 267 | 268 | ``` 269 | Meteor.publish("employees", function() { 270 | 271 | var cursor = Employees.find(); // do what you normally do here 272 | 273 | return Employees.publishJoinedCursors(cursor, { reactive: true }, this); 274 | }); 275 | ``` 276 | 277 | 278 | Using `reactive: true` and `auditargument-checks` package 279 | --------------------------------------------------------- 280 | 281 | Your publication will be called with one extra argument (internally used by package). That's OK unless you are using `audit-argument-checks` which forces you to `check()` all arguments passed to publication. In that case, you need to check that extra argument: 282 | 283 | ``` 284 | Meteor.publish("publicationName", function(arg1, extraArgument) { 285 | check(arg1, ...); // check your arguments as you normally do ... 286 | check(extraArgument, Match.Any); // ... but don't forget to check extraArgument 287 | }); 288 | 289 | ``` 290 | 291 | 292 | 293 | Version history 294 | =============== 295 | 296 | 1.1.9 297 | ----- 298 | 299 | Issue related to `audit-argument-checks` package is added to README.md 300 | 301 | 302 | 1.1.8 303 | ----- 304 | 305 | Fixed bug with Random 306 | 307 | 308 | 1.1.7 309 | ----- 310 | 311 | If joined doc is not found, set container to empty object instead null 312 | 313 | 314 | 1.1.6 315 | ----- 316 | 317 | Improved reactive joins 318 | 319 | 320 | 1.1.5 321 | ----- 322 | 323 | Fixed bug with reactive joins and removed super-dirty trick 324 | 325 | 326 | 1.1.4 327 | ----- 328 | 329 | Updated README.md 330 | 331 | 332 | 1.1.3 333 | ----- 334 | 335 | Fixed bugs related to reactive joins 336 | 337 | 338 | 1.1.2 339 | ----- 340 | 341 | Fixed bugs related to reactive joins 342 | 343 | 344 | 1.1.1 345 | ----- 346 | 347 | Updated README.md 348 | 349 | 350 | 1.1.0 351 | ----- 352 | 353 | Join is now reactive (using super ugly & dirty tricks) 354 | 355 | 356 | 1.0.9 357 | ----- 358 | 359 | Unsuccessfully tried to add reactivity (update details when master document changes) 360 | 361 | 1.0.8 362 | ----- 363 | 364 | - Foreign key name now can be in "dot" notation: `a.b.c` 365 | 366 | 1.0.7 367 | ----- 368 | 369 | - Fixed error: "Publish function returned multiple cursors" when collection joins with the another collection multiple times 370 | 371 | 1.0.6 372 | ----- 373 | 374 | - Updated this README.md 375 | 376 | 377 | 1.0.5 378 | ----- 379 | 380 | - Now you can pass collection name as first argument to `join` function. 381 | 382 | - Added generic joins. 383 | 384 | 385 | Credits 386 | ======= 387 | 388 | Thanks to [Robert Moggach](https://github.com/robmoggach). 389 | 390 | 391 | --- 392 | 393 | That's it :) 394 | --------------------------------------------------------------------------------