├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── README.md ├── changelog.md ├── denormalized-views.js ├── denormalized-views.tests.js ├── docs ├── data-schema.jpg └── user-interface.jpg ├── license.md ├── package.js └── tools.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # TODO: Remove Trailing Whitespace for .js!! 2 | # https://github.com/sindresorhus/editorconfig-sublime 3 | 4 | # editorconfig.org 5 | root = true 6 | 7 | [*] 8 | indent_style = tab 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.js] 18 | indent_style = space 19 | indent_size = 2 20 | tab_width = 2 21 | end_of_line = lf 22 | charset = utf-8 23 | trim_trailing_whitespace = true 24 | insert_final_newline = true 25 | 26 | [*.html] 27 | indent_style = space 28 | indent_size = 2 29 | tab_width = 2 30 | end_of_line = lf 31 | charset = utf-8 32 | trim_trailing_whitespace = true 33 | insert_final_newline = true 34 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "airbnb/base", 4 | "plugin:meteor/recommended" 5 | ], 6 | "plugins": [ 7 | "meteor" 8 | ], 9 | "settings": { 10 | "import/resolver": "meteor" 11 | }, 12 | "rules": { 13 | "guard-for-in": 0, 14 | "radix": 0, 15 | "semi": 0, // Henning: disable semi rule 16 | "max-len": 0, // Henning: disable max-len rule 17 | "space-infix-ops": 0, // Henning: disable space-infix-ops rule 18 | "no-unused-expressions": 0, // Henning: disable to enable stuff like ``expect(browser.getText(modalFormCreatedAt)).to.be.defined`` 19 | "no-multi-spaces": 0, // Henning: we want to define consts with multispaces 20 | "func-names": 0, // Henning: we want to use stuff like ``$('tbody tr').each(function (iRow) {`` without being destraced by our linter 21 | "consistent-return": 0, 22 | "class-methods-use-this": 0, 23 | "import/first": 0, 24 | "import/no-absolute-path": 0, 25 | "import/no-extraneous-dependencies": 0, 26 | "import/no-unresolved": 0, 27 | "import/newline-after-import": 0, 28 | "import/prefer-default-export": 0, // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/prefer-default-export.md 29 | "import/extensions": 0, // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/extensions.md 30 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 31 | "global-require": 0, 32 | "no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"] 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #Meteor 2 | .npm/ 3 | node_modules 4 | npm-debug.log 5 | */.meteor/dev_bundle 6 | */.meteor/local 7 | *.swp 8 | .meteor-spk 9 | .tx/ 10 | *.sublime-workspace 11 | tmp/ 12 | 13 | # SublimeText 14 | *.tmlanguage.cache 15 | *.tmPreferences.cache 16 | *.stTheme.cache 17 | 18 | # LOGS 19 | *.log 20 | 21 | # PyCharm 22 | .idea 23 | .idea/ 24 | 25 | # OSX 26 | .DS_Store 27 | .AppleDouble 28 | .LSOverride 29 | 30 | # Files that might appear on external disk 31 | .Spotlight-V100 32 | .Trashes 33 | 34 | # SublimeText project files 35 | *.sublime-workspace 36 | *.sublime-project 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **THIS IS AN EARLY RELEASE CANDIDATE. ALL TESTS PASS, BUT WE ARE LOOKING FOR FEEDBACK ON THIS TO MAKE IT 100% PRODUCTION STABLE. PLEASE TRY IT OUT AND GIVE US FEEDBACK!!!** 2 | 3 | # Denormalized Views for Meteor 4 | *thebarty:denormalized-views* 5 | 6 | A toolkit that helps you to create "read-only" denormalized mongo-"views" (collections), which are especially useful for search-able tables, [reporting databases](http://martinfowler.com/bliki/ReportingDatabase.html) or other read-heavy scenarios (*see "[Example Use-Case](#example-use-case)" for a quick overview*). It's concept also matches with [CQRS's](http://martinfowler.com/bliki/CQRS.html) ["materialized views"](https://msdn.microsoft.com/en-us/library/dn589782.aspx) concept. 7 | 8 | The resulting "view"-collection can then be used with tools like ``aldeed:tabular``, or ``easy:search`` to display and search related data. 9 | 10 | Simply define how the data shall be collected based on a "source"-collection. Whenever a change happens in "source"-collection (insert | update | remove), the "view"-collection will automatically be refreshed. 11 | 12 | Additionally you can hookup "related"-collections to automatically refresh the "source"-collection or trigger manual refreshes (*if necessary at all*). 13 | 14 | 15 | # Table of Contents 16 | 17 | 18 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 19 | 20 | - [Installation](#installation) 21 | - [Example Use-Case](#example-use-case) 22 | - [Setup by ``addView()``](#setup-by-addview) 23 | - [Behind the scenes](#behind-the-scenes) 24 | - [Denormalize via ``sync:``](#denormalize-via-sync) 25 | - [Create "joined search fields" via ``postSync:``](#create-joined-search-fields-via-postsync) 26 | - [Pick the fields you need via ``pick()``](#pick-the-fields-you-need-via-pick) 27 | - [Filter via ``filter()``](#filter-via-filter) 28 | - [Post-processing via the ``postHook(doc)``-hook](#post-processing-via-the-posthookdoc-hook) 29 | - [Staying in sync](#staying-in-sync) 30 | - [*Automatically* synchronize "related"-collections (``refreshByCollection()``)](#automatically-synchronize-related-collections-refreshbycollection) 31 | - [*Manually* refresh individual docs (``refreshManually()``)](#manually-refresh-individual-docs-refreshmanually) 32 | - [*Manually* refreshing the whole collection (``refreshAll()``)](#manually-refreshing-the-whole-collection-refreshall) 33 | - [Debug mode ``DenormalizedViews.Debug = true``](#debug-mode-denormalizedviewsdebug--true) 34 | - [Defer syncing via ``DenormalizedViews.DeferWriteAccess``](#defer-syncing-via-denormalizedviewsdeferwriteaccess) 35 | - [A full example containing all options](#a-full-example-containing-all-options) 36 | - [Latency compensation?](#latency-compensation) 37 | - [Open Todos](#open-todos) 38 | - [How to contribute to this package](#how-to-contribute-to-this-package) 39 | - [Research Resources](#research-resources) 40 | 41 | 42 | 43 | 44 | # Installation 45 | 46 | In your Meteor app directory, enter: 47 | 48 | ``` 49 | $ meteor add thebarty:denormalized-views 50 | ``` 51 | 52 | 53 | # Example Use-Case 54 | 55 | Let's say you have 3 collections: 56 | * Posts (relate to 1 author & multiple comments) 57 | * Comments (relate to 1 post) 58 | * Authors (relate to multiple posts) 59 | 60 | ![data schema](https://github.com/thebarty/meteor-denormalized-views/blob/master/docs/data-schema.jpg) 61 | 62 | In your app you want to show a list of posts **with infos about their related authors and comments**. Additionally you want to give the user the option **to search** by the following: 63 | - search the post-text (field "text" in collection "Posts") 64 | - search the comment-text (field "text" in collection "Comments") 65 | - search the author-name (field "name" in collection "Author") 66 | 67 | ![user interface](https://github.com/thebarty/meteor-denormalized-views/blob/master/docs/user-interface.jpg) 68 | 69 | You know that ``aldeed:tabular`` is a great package to list a collection in the frontend. Although it can easily show joined collection thru a package like ``reywood:publish-composite``, it does NOT support search over joined collections. **Here is where ``denormalized-views`` comes into play**: simply create a denormalized "view"-collection and use it to display and search data thru tabular. 70 | 71 | 72 | # Setup by ``addView()`` 73 | 74 | Use ``addView()`` to define how your "view"-collection collects its data. It all starts at the "source"-collection (p.e. Posts): **data of the "source"-collection will automatically be copied 1-to-1 to the "view"-collection** (= the "view"-collection). Scroll down to see the first code. 75 | 76 | ## Behind the scenes 77 | The concept is that your "source"-collection is **writable**, while your "view"-collection is **read-only**. DO NOT write to your "view"-collection - otherwise data will get out of sync! 78 | 79 | **The synchronization is "one way"** and will run anytime the "source"-collection receives an Mongo- ``insert``, ``update`` or ``remove``-command. The effected docs will then process via your ``sync:``- && ``postSync``-definitions and stored to the "view"-collection. 80 | 81 | Of course the ``_id`` will be the same in both collections. A ``remove`` on the "source"-collection will remove the doc from "view"-collection. 82 | 83 | ## Denormalize via ``sync:`` 84 | 85 | Within the ``sync``-property you **extend the target document** and hand each new property a function to collect the denormalized data and return it. 86 | 87 | **Start by defining your synchronization:** 88 | 89 | ```js 90 | import { DenormalizedViews } from 'meteor/thebarty:denormalized-views' 91 | 92 | const IDENTIFIER = 'identifier' // unique id 93 | const PostsView = new Mongo.Collection('postsview') // create "view"-collection 94 | 95 | DenormalizedViews.addView({ 96 | identifier: IDENTIFIER, 97 | sourceCollection: Posts, 98 | viewCollection: PostsView, 99 | sync: { 100 | // In here you extend the doc of the "source"-collection 101 | // in order to be put into the "view"-collection. 102 | // 103 | // Simply define a property and assign it a function. 104 | // Within the function: Collect the data you need 105 | // and return it. If you return "undefined", 106 | // the property will removed from the doc. 107 | // 108 | // The function will be passed 2 parameters: 109 | // 1) the current doc of the "source"-Collection 110 | // 2) the current userId (when available) 111 | // 112 | // Some examples: 113 | authorCache: (post, userId) => { 114 | return Authors.findOne(post.authorId) 115 | }, 116 | categoryCache: (post, userId) => { 117 | return Categories.findOne(post.categoryId) 118 | }, 119 | commentsCache: (post, userId) => { 120 | const comments = [] 121 | for (const commentId of post.commentIds) { 122 | const comment = Comments.findOne(commentId) 123 | comments.push(comment) 124 | } 125 | return comments 126 | }, 127 | }, 128 | }) 129 | ``` 130 | 131 | ## Create "joined search fields" via ``postSync:`` 132 | 133 | There is also a ``postSync:`` property, which acts the same as ``sync:``, but it runs **after** ``sync:`` has collected data, meaning that the passed doc will already contain the new properties from ``sync:``. You can use ``postSync:`` to create joined search fields or get creative. 134 | 135 | ```js 136 | // ... continuing the example from above 137 | postSync: { 138 | // This will be called AFTER ``sync:`` has attached 139 | // new data to the doc, so you can use this to create 140 | // joined search fields, or get creative. 141 | wholeText: (post, userId) => { 142 | let authorText = '' 143 | if (post.authorCache) { 144 | authorText = post.authorCache.name 145 | } 146 | return `${post.text}, ${_.pluck(post.commentsCache, 'text').join(', ')}, ${authorText}` 147 | }, 148 | numberOfComments: (post, userId) => { 149 | return post.commentsCache.length 150 | }, 151 | }, 152 | ``` 153 | 154 | ## Pick the fields you need via ``pick()`` 155 | 156 | By default the whole doc from your "source"-collection will be copied to "view"-collection. If you want to **restrict** the fields being copied you can use the ``pick``-option: 157 | 158 | ```js 159 | DenormalizedViews.addView({ 160 | identifier: IDENTIFIER, 161 | sourceCollection: Posts, 162 | viewCollection: PostsView, 163 | pick: ['text'], // (optional) set to pick specific fields 164 | // from sourceCollection 165 | // continue with 166 | // ... sync: 167 | // ... postSync: 168 | }) 169 | ``` 170 | 171 | ## Filter via ``filter()`` 172 | 173 | In some useCases you ONLY want to create a doc in the "view"-collection, if it passes a FILTER. Use the `filter()`-option to specify this condition and have it `return true`, if you want the doc to be created. If the function returns anything else than ``true``, there will be no further processing and an existing doc (with the same ``_id`` will be removed). 174 | 175 | Example: 176 | ```js 177 | DenormalizedViews.addView({ 178 | identifier: IDENTIFIER, 179 | sourceCollection: Posts, 180 | viewCollection: PostsView, 181 | filter(doc) { 182 | if (doc.author==='Donald') { 183 | return true // process ONLY posts that where created by "Donald" 184 | } 185 | return false 186 | }, 187 | // continue with 188 | // ... sync: 189 | // ... postSync: 190 | }) 191 | ``` 192 | 193 | ## Post-processing via the ``postHook(doc)``-hook 194 | 195 | If you need to do some processing related to the creation of a "view"-doc, you can use the `postHook`-option. It will be called after a successful insert-/update-/remove- of the "view"-collection has happened and contains the resulting "view"-`doc` as the first- and (if exists) the userId as the second-parameter. 196 | 197 | Example: 198 | ```js 199 | DenormalizedViews.addView({ 200 | identifier: IDENTIFIER, 201 | sourceCollection: Posts, 202 | viewCollection: PostsView, 203 | // ... sync: 204 | // ... postSync: 205 | postHook(doc, userId) { 206 | // do something afterwards 207 | }, 208 | }) 209 | ``` 210 | 211 | 212 | # Staying in sync 213 | 214 | If within your app you only write to "source"-collection, that is all you have to do, because by setting up ``addView`` you enabled the automatic synchronization between "source"-Collection and "view"-collection. 215 | 216 | **BUT** changes made to other "related"-collections will potentially invalidate data within your "view"-collection. In our example this would happen when you update ``Authors.name``. *(p.e. ``PostsView.authorsCache.name`` will then contain the wrong old name)* 217 | 218 | There are **2 options to keep the "view"-collection in sync with "related"-collection**: 219 | 1. hook up the **"related"-collection** via ``refreshByCollection()`` and let this package do the rest 220 | 2. do it **manually** via ``refreshManually(identifier)`` 221 | 222 | Recommendation: Start with option 1) and use option 2) if needed at all. 223 | 224 | ## *Automatically* synchronize "related"-collections (``refreshByCollection()``) 225 | 226 | Setup a ``refreshByCollection()`` to **automatically synchronize** changes made to a "related"-collection. Your task in here is to tell the "view"-collection which `_ids` shall be refreshed: 227 | 228 | Within the ``refreshIds``-parameter's function **return an array of ``_ids``**. Those `_ids` will then be refreshed within "view"-collection. The first parameter in this function gives you the current doc change in the "related"-collection. 229 | 230 | Within the ``relatedCollection`` parameter you define a function that returns the instance of the "related"-collection. By doing so we support circular-imports, p.e. where a "Product"-collection refreshes the "Category"-view and a "Category"-collection refreshes the "Product"-view. Read [4] for more infos. 231 | 232 | If you return false, undefined or null a refresh will NOT be triggered. 233 | 234 | ```js 235 | DenormalizedViews.refreshByCollection({ 236 | identifier: IDENTIFIER, 237 | relatedCollection: Authors, 238 | refreshIds: (author, authorPrevious, userId) => { 239 | // The function is passed 3 parameters 240 | // 1) The current doc changed within the "related"-collection 241 | // 2) The previous doc changed within the "related"-collection 242 | // (this is available on Mongo-"update"-modifiers) 243 | // 3) The current userId (if available) 244 | // Return an array of _ids that should be updated in "view"-collection. 245 | // Returning false, an empty array or undefined, will simply 246 | // not refresh anything. 247 | const posts = Posts.find({ authorId: author._id }).fetch() 248 | return _.pluck(posts, '_id') 249 | }, 250 | }) 251 | 252 | // Example2: 253 | // if you store references within the related collection, 254 | // be sure to union all affected _ids in doc & docPrevious 255 | DenormalizedViews.refreshByCollection({ 256 | identifier: DENORMALIZED_POST_COLLECTION, 257 | relatedCollection: Categories, 258 | refreshIds: (doc, docPrevious, userId) => { 259 | if (docPrevious) { 260 | // update 261 | return _.union(doc.postIds, docPrevious.postIds) 262 | } else { 263 | // insert | remove 264 | return doc.postIds 265 | } 266 | }, 267 | }) 268 | ``` 269 | 270 | 271 | ## *Manually* refresh individual docs (``refreshManually()``) 272 | 273 | There might be places where you want to **manually refresh** the "view"-collection, p.e. in a ``Meteor.method``. You can use ``refreshManually()`` to do so: 274 | 275 | ```js 276 | // this is the manual way of doing it, 277 | // p.e. from a ``Meteor.method`` 278 | DenormalizedViews.refreshManually({ 279 | identifier: IDENTIFIER, 280 | refreshIds: [Mongo._id], // _id-array of posts that should be updated 281 | }) 282 | ``` 283 | 284 | 285 | ## *Manually* refreshing the whole collection (``refreshAll()``) 286 | 287 | If you ever want to manually **refresh the whole "view"-collection"**, you can use ``refreshAll()``. Filter will be applied, if defined via `filter()`. 288 | 289 | **Note that this is the slowest option, because the whole table will be refreshed.** 290 | 291 | ```js 292 | // simply pass the identifier 293 | DenormalizedViews.refreshAll(IDENTIFIER) 294 | ``` 295 | 296 | 297 | # Debug mode ``DenormalizedViews.Debug = true`` 298 | 299 | ```js 300 | import { DenormalizedViews } from 'thebarty:denormalized-views' 301 | // enable logs 302 | DenormalizedViews.Debug = true 303 | ``` 304 | 305 | 306 | # Defer syncing via ``DenormalizedViews.DeferWriteAccess`` 307 | 308 | If you don't care about data being sync 100% real-time and want to relax the server, you can switch on ``DenormalizedViews.DeferWriteAccess = true``. This will wrap all ``insert-`` | ``updates``– | ``removes``-commands into a ``Meteor.defer()`` an make those writes run asynchronously in the background. Data will take a bit longer to be synced to the "view"-collections. By default this setting is switched off. 309 | 310 | ```js 311 | import { DenormalizedViews } from 'thebarty:denormalized-views' 312 | // enable Meteor.defer() for writes 313 | DenormalizedViews.DeferWriteAccess = true 314 | ``` 315 | 316 | 317 | # A full example containing all options 318 | 319 | **Please have a look at the tests in "denormalized-views.tests.js" for more details.** 320 | 321 | ```js 322 | import { DenormalizedViews } from 'meteor/thebarty:denormalized-views' 323 | 324 | const IDENTIFIER = 'identifier' // unique id 325 | const PostsView = new Mongo.Collection('PostsView') 326 | 327 | DenormalizedViews.addView({ 328 | identifier: IDENTIFIER, // unique id for synchronization 329 | sourceCollection: Posts, 330 | viewCollection: PostsView, 331 | pick: ['text'], // (optional) 332 | filter(post) { 333 | if (post.author==='Donald') { 334 | return true // process ONLY posts that where created by "Donald" 335 | } 336 | return false 337 | }, 338 | sync: { 339 | authorCache: (post, userId) => { 340 | return Authors.findOne(post.authorId) 341 | }, 342 | categoryCache: (post, userId) => { 343 | return Categories.findOne(post.categoryId) 344 | }, 345 | commentsCache: (post, userId) => { 346 | const comments = [] 347 | for (const commentId of post.commentIds) { 348 | const comment = Comments.findOne(commentId) 349 | comments.push(comment) 350 | } 351 | return comments 352 | }, 353 | }, 354 | postSync: { 355 | wholeText: (post, userId) => { 356 | let authorText = '' 357 | if (post.authorCache) { 358 | authorText = post.authorCache.name 359 | } 360 | return `${post.text}, ${_.pluck(post.commentsCache, 'text').join(', ')}, ${authorText}` 361 | }, 362 | numberOfComments: (post, userId) => { 363 | return post.commentsCache.length 364 | }, 365 | }, 366 | postHook(post, userId) { 367 | // do something afterwards 368 | }, 369 | }) 370 | ``` 371 | 372 | # Latency compensation? 373 | 374 | Note that we run database-queries ONLY on the server, meaning that the client will receive new data via pub/sub on the "view"-collection. Tools like "aldeed:tabular" have build-in mechanisms to relax the client and only publish the docs currently needed. 375 | 376 | 377 | # Open Todos 378 | 379 | * Receive feedback from the community 380 | 381 | 382 | # How to contribute to this package 383 | 384 | Lets make this perfect and collaborate. This is how to set up your local testing environment: 385 | 1. run "meteor create whatever; cd whatever; mkdir packages;" 386 | 2. clone this package into the packages dir, p.e. "cd packages; git clone https://github.com/thebarty/meteor-denormalized-views.git denormalized-views" 387 | 3. run tests from the root (/whatever/.) of your project like ``meteor test-packages ./packages/meteor-denormalized-views/ --driver-package=cultofcoders:mocha`` 388 | 4. develop, write tests, and submit a pull request 389 | 390 | # Research Resources 391 | 392 | * [1] https://themeteorchef.com/snippets/using-unblock-and-defer-in-methods/#tmc-when-to-use-unblock-vs-defer When to use Meteor.defer(). Inspiration our ``DenormalizedViews.DeferWriteAccess``-setting. 393 | * [2] http://martinfowler.com/bliki/CQRS.html 394 | * [3] http://martinfowler.com/bliki/ReportingDatabase.html 395 | * [4] https://msdn.microsoft.com/en-us/library/dn589782.aspx Materialized-Views concept 396 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # Table Of Content 4 | 5 | 6 | 7 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 8 | 9 | - [0.0.11](#0011) 10 | - [0.0.10](#0010) 11 | - [0.0.9](#009) 12 | - [0.0.8](#008) 13 | - [0.0.7](#007) 14 | 15 | 16 | 17 | # 0.0.11 18 | * made `refreshAll()` use `filter()` (if defined) 19 | 20 | # 0.0.10 21 | * quickfix for 0.0.9 which crashed in production due to wrong use of `Package.onUse()` 22 | 23 | # 0.0.9 24 | * made `refreshAll()` call `postHook()` (if defined) 25 | * fix to add files ONLY to server 26 | * Added `getView()` as an easy way to get an existing configuration 27 | 28 | # 0.0.8 29 | * added `filter(doc)`-option (optional) to `DenormalizedViews.addView`, which can be used to only create a doc in the "view"-collection, if it passes a filter (meaning if the function returns `true`). 30 | * added `postHook`-option (optional) to `DenormalizedViews.addView`, which you can pass a function that will be called after a successfull insert-/update-/remove- of the "view"-collection. 31 | 32 | # 0.0.7 33 | * Enhanced ``refreshByCollection.refreshIds`` to pass previousDoc as a parameter. In a lot of useCases this is needed to get all affected _ids 34 | * throw ``Meteor.Error`` instead of pure ``Error`` 35 | * when ``sync``- or ``postSync`` functions return ``0`` or ``[]`` the property will now be stored in the doc 36 | * added more tests 37 | -------------------------------------------------------------------------------- /denormalized-views.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | /** 4 | * Denormalization 5 | */ 6 | import { _ } from 'underscore' 7 | 8 | import { check, Match } from 'meteor/check' 9 | import { Meteor } from 'meteor/meteor' 10 | import { Mongo } from 'meteor/mongo' 11 | import { SimpleSchema } from 'meteor/aldeed:simple-schema' 12 | 13 | import { debug } from './tools.js' 14 | 15 | // ERRORS (export needed for tests) 16 | export const ERROR_IDENTIFIERT_EXISTS = 'identifier already exists' 17 | export const ERROR_SOURCE_AND_TARGET_COLLECTIONS_NEED_TO_BE_DIFFERENT = 'sourceCollection and viewCollection need to refer to different collections' 18 | export const ERROR_SYNC_NEEDS_TO_HAVE_CONTENT = 'sync needs to have properties attached' 19 | export const ERROR_SYNC_ALREADY_EXISTS_FOR_SOURCE_TARGET_COLLECTIONS = 'a sync already exists for the given sourceCollection and viewCollection' 20 | export const ERROR_REFRESH_BY_COLLECTION_CAN_NOT_BE_SET_TO_SOURCE_COLLECTION = 'relatedCollection can NOT be set to sourceCollection or viewCollection. It is meant to be registered to a related collection.' 21 | export const ERROR_REFRESH_BY_COLLECTION_NEEDS_TO_BE_ASSIGNED_TO_AN_EXISTING_ID = 'identifier in refreshByCollection() needs to be a registered syncronisation. It has to be registered before via addView()' 22 | 23 | // Storage for ALL system-wide syncronisations 24 | export const SyncronisationStore = [] 25 | 26 | // =========================================================== 27 | // DENORMALIZED-VIEWS CLASS 28 | // =========================================================== 29 | export const DenormalizedViews = class DenormalizedViews { 30 | // ================================================ 31 | // PUBLIC API (to be used from outside) 32 | 33 | static addView(options = {}) { 34 | new SimpleSchema({ 35 | identifier: { type: String }, 36 | sourceCollection: { type: Mongo.Collection }, 37 | viewCollection: { type: Mongo.Collection }, 38 | filter: { type: Function, blackbox: true, optional: true }, 39 | postHook: { type: Function, blackbox: true, optional: true }, 40 | pick: { type: [String], optional: true }, 41 | postSync: { type: Object, blackbox: true, optional: true }, 42 | sync: { type: Object, blackbox: true }, 43 | }).validate(options) 44 | 45 | const { identifier, sourceCollection, viewCollection, sync } = options 46 | 47 | // Validate options 48 | // validate that identifier is NOT yet registered 49 | if (_.contains(_.pluck(SyncronisationStore, 'identifier'), identifier)) { 50 | throw new Meteor.Error(`${ERROR_IDENTIFIERT_EXISTS}: ${identifier}`) 51 | } 52 | // validate that collections are NOT the same 53 | if (sourceCollection===viewCollection) { 54 | throw new Meteor.Error(ERROR_SOURCE_AND_TARGET_COLLECTIONS_NEED_TO_BE_DIFFERENT) 55 | } 56 | if (_.isEmpty(sync)) { 57 | throw new Meteor.Error(ERROR_SYNC_NEEDS_TO_HAVE_CONTENT) 58 | } 59 | if (_.find(SyncronisationStore, 60 | store => (store.sourceCollection===sourceCollection 61 | && store.viewCollection===viewCollection))) { 62 | throw new Meteor.Error(ERROR_SYNC_ALREADY_EXISTS_FOR_SOURCE_TARGET_COLLECTIONS) 63 | } 64 | // is valid? Register it 65 | debug(`addView from sourceCollection "${sourceCollection._name}" to "${viewCollection._name}"`) 66 | SyncronisationStore.push(options) 67 | 68 | // register hooks to sourceCollection 69 | // those hooks wil sync to viewCollection 70 | sourceCollection.after.insert(function (userId, doc) { // eslint-disable-line prefer-arrow-callback 71 | debug(`${sourceCollection._name}.after.insert`) 72 | // Filter? 73 | if (DenormalizedViews._isDocValidToBeProcessed({ doc, userId, syncronisation: options })) { 74 | // fix for insert-hook 75 | // doc._id = doc._id.insertedIds[0] 76 | const processedDoc = DenormalizedViews._processDoc({ 77 | doc, 78 | userId, 79 | syncronisation: options, 80 | }) 81 | DenormalizedViews._executeDatabaseComand(() => { 82 | debug(`inserting doc with id ${processedDoc._id}`) 83 | viewCollection.insert(processedDoc) 84 | }) 85 | DenormalizedViews._callPostHookIfExists({ doc, userId, postHook: options.postHook }) 86 | } else { 87 | // filter OUT doc, if it exists 88 | DenormalizedViews._removeDocFromViewCollectionIfExists({ doc, viewCollection: options.viewCollection }) 89 | } 90 | }) 91 | 92 | sourceCollection.after.update(function (userId, doc) { // eslint-disable-line prefer-arrow-callback 93 | debug(`${sourceCollection._name}.after.update`) 94 | // Filter? 95 | if (DenormalizedViews._isDocValidToBeProcessed({ doc, userId, syncronisation: options })) { 96 | const processedDoc = DenormalizedViews._processDoc({ 97 | doc, 98 | userId, 99 | syncronisation: options, 100 | }) 101 | DenormalizedViews._executeDatabaseComand(() => { 102 | debug(`updating doc with id ${processedDoc._id}`) 103 | viewCollection.update(processedDoc._id, { $set: processedDoc }, { 104 | upsert: true, // important: it might be that doc has passed the filter AFTER an update 105 | // and did NOT exist yet in "view"-collection, p.e. because on "insert" 106 | // it did NOT pass the filter. So let's upsert 107 | }) 108 | }) 109 | DenormalizedViews._callPostHookIfExists({ doc, userId, postHook: options.postHook }) 110 | } else { 111 | // filter OUT doc, if it exists 112 | DenormalizedViews._removeDocFromViewCollectionIfExists({ doc, viewCollection: options.viewCollection }) 113 | } 114 | }) 115 | 116 | sourceCollection.after.remove(function (userId, doc) { // eslint-disable-line prefer-arrow-callback 117 | debug(`${sourceCollection._name}.after.remove`) 118 | DenormalizedViews._executeDatabaseComand(() => { 119 | debug(`removing doc with id ${doc._id}`) 120 | viewCollection.remove(doc._id) 121 | }) 122 | DenormalizedViews._callPostHookIfExists({ doc, userId, postHook: options.postHook }) 123 | }) 124 | } 125 | 126 | /** 127 | * Get a reference to an existing view, identified by an identifier. 128 | * You can use this in unit-tests to stub certain behaviour. 129 | */ 130 | static getView(identifier) { 131 | check(identifier, String) 132 | return DenormalizedViews._getExistingSyncronisation({ identifier }) 133 | } 134 | 135 | static refreshByCollection(options = {}) { 136 | new SimpleSchema({ 137 | identifier: { type: String }, 138 | relatedCollection: { type: Mongo.Collection }, 139 | refreshIds: { type: Function }, 140 | }).validate(options) 141 | 142 | const { identifier, relatedCollection, refreshIds } = options 143 | 144 | // Validate 145 | const existingSyncronisation = DenormalizedViews._getExistingSyncronisation({ identifier }) 146 | // validate that we have a valid identifier 147 | if (!existingSyncronisation) { 148 | throw new Meteor.Error(ERROR_REFRESH_BY_COLLECTION_NEEDS_TO_BE_ASSIGNED_TO_AN_EXISTING_ID) 149 | } 150 | // validate that we have a valid collection assigned 151 | if (existingSyncronisation.sourceCollection===relatedCollection 152 | || existingSyncronisation.viewCollection===relatedCollection) { 153 | throw new Meteor.Error(ERROR_REFRESH_BY_COLLECTION_CAN_NOT_BE_SET_TO_SOURCE_COLLECTION) 154 | } 155 | 156 | debug(`setup refreshByCollection for identifier "${identifier}" and relatedCollection "${relatedCollection._name}"`) 157 | 158 | relatedCollection.after.insert(function (userId, doc) { // eslint-disable-line prefer-arrow-callback 159 | debug(`relatedCollection ${relatedCollection._name}.after.insert`) 160 | // doc._id = doc._id.insertedIds[0] // fix for insert-hook 161 | const ids = DenormalizedViews._validateAndCallRefreshIds({ doc, refreshIds, userId }) 162 | if (ids && ids.length>0) { 163 | DenormalizedViews._updateIds({ 164 | identifier, 165 | idsToRefresh: ids, 166 | }) 167 | } else { 168 | debug('no _ids received from refreshIds-function. So NO docs will be updated in "view"-collection') 169 | } 170 | }) 171 | 172 | relatedCollection.after.update(function (userId, doc) { // eslint-disable-line prefer-arrow-callback 173 | debug(`relatedCollection ${relatedCollection._name}.after.update`) 174 | const ids = DenormalizedViews._validateAndCallRefreshIds({ 175 | doc, 176 | refreshIds, 177 | userId, 178 | docPrevious: this.previous, // the caller is gonna need that to find the correct ids 179 | }) 180 | if (ids && ids.length>0) { 181 | DenormalizedViews._updateIds({ 182 | identifier, 183 | idsToRefresh: ids, 184 | }) 185 | } else { 186 | debug('no _ids received from refreshIds-function. So NO docs will be updated in "view"-collection') 187 | } 188 | }) 189 | 190 | // REMOVE hook 191 | // our aim is to always UPDATE the "view"-collection. P.e. if Author changes 192 | // his name or gets deleted than the "view"-collection needs to refresh. 193 | // Of course in this case the App itself would have to make sure that 194 | // before authorId is removed from sourceCollection 195 | relatedCollection.after.remove(function (userId, doc) { // eslint-disable-line prefer-arrow-callback 196 | debug(`relatedCollection ${relatedCollection._name}.after.remove`) 197 | const ids = DenormalizedViews._validateAndCallRefreshIds({ doc, refreshIds, userId }) 198 | if (ids && ids.length>0) { 199 | DenormalizedViews._updateIds({ 200 | identifier, 201 | idsToRefresh: ids, 202 | }) 203 | } else { 204 | debug('no _ids received from refreshIds-function. So NO docs will be updated in "view"-collection') 205 | } 206 | }) 207 | } 208 | 209 | /** 210 | * Manually refresh a set of Ids in an syncronisation 211 | * for an given identifier. 212 | * 213 | * Use this in your App at places where a manual refresh is needed. 214 | * 215 | * @param {Object} options [description] 216 | * @return {[type]} [description] 217 | */ 218 | static refreshManually(options = {}) { 219 | new SimpleSchema({ 220 | identifier: { type: String }, 221 | refreshIds: { type: [String] }, 222 | }).validate(options) 223 | 224 | const { identifier, refreshIds } = options 225 | debug(`refreshManually for identifier ${identifier} and ids:`, refreshIds) 226 | 227 | if (refreshIds && refreshIds.length>0) { 228 | DenormalizedViews._updateIds({ 229 | identifier, 230 | idsToRefresh: refreshIds, 231 | }) 232 | } 233 | } 234 | 235 | /** 236 | * Do a TOTAL refresh of the target-collection, 237 | * meaning that ALL elements will get reloaded. 238 | * 239 | * In big collections this can be very time-intense. So be careful with timeouts. 240 | * 241 | * @param {[type]} identifier [description] 242 | * @return {[type]} [description] 243 | */ 244 | static refreshAll(identifier) { 245 | check(identifier, String) 246 | 247 | const existingSyncronisation = DenormalizedViews._getExistingSyncronisation({ identifier }) 248 | debug(`refreshAll for collection "${existingSyncronisation.sourceCollection._name}"`) 249 | 250 | DenormalizedViews._executeDatabaseComand(() => { 251 | existingSyncronisation.viewCollection.remove({}) 252 | }) 253 | 254 | let ids = existingSyncronisation.sourceCollection.find({}, { fields: { _id: 1 } }).fetch() 255 | ids = _.pluck(ids, '_id') 256 | 257 | for (const id of ids) { 258 | let doc = existingSyncronisation.sourceCollection.findOne(id) // todo check if we better use data from fetch above 259 | const userId = undefined // TODO: add `Meteor.userId()`. we had problems getting it to work (adding "meteor-base" to packages did not help) 260 | // Filter? 261 | if (DenormalizedViews._isDocValidToBeProcessed({ doc, userId, syncronisation: existingSyncronisation })) { 262 | debug(`refreshAll refreshing doc._id ${id}`) 263 | doc = DenormalizedViews._processDoc({ 264 | doc, 265 | syncronisation: existingSyncronisation, 266 | }) 267 | DenormalizedViews._executeDatabaseComand(() => { 268 | existingSyncronisation.viewCollection.insert(doc) 269 | }) 270 | DenormalizedViews._callPostHookIfExists({ doc, userId: undefined, postHook: existingSyncronisation.postHook }) 271 | } else { 272 | debug(`refreshAll: filtered out doc._id ${id}`) 273 | } 274 | } 275 | debug(`${ids.length} docs in cache ${existingSyncronisation.viewCollection._name} were refreshed`) 276 | } 277 | 278 | /** 279 | * Check, if the doc of the source-collection valid to be processed. 280 | * If NO filter exists it is always valid. 281 | * If a filter exists, the doc is considered valid, if the filter returns true. 282 | * @return (Boolean) true if doc shall be further processed 283 | * @return (Boolean) false if doc shall be filtered out 284 | */ 285 | static _isDocValidToBeProcessed(options={}) { 286 | const { syncronisation, userId } = options 287 | const { filter } = syncronisation 288 | const doc = options.doc 289 | let returnValue = true 290 | if (!_.isUndefined(filter) && _.isFunction(filter)) { 291 | const filterResult = filter.call(this, doc, userId) 292 | if (!_.isUndefined(filterResult) && _.isBoolean(filterResult) && filterResult===false) { 293 | debug(`doc with _id ${doc._id} was filtered out. NO doc was created in "view"-collection`) 294 | returnValue = false // do NOT process 295 | } 296 | } 297 | return returnValue 298 | } 299 | 300 | /** 301 | * Call the `postHook` function, if it exists 302 | */ 303 | static _callPostHookIfExists(options={}) { 304 | const { doc, userId, postHook } = options 305 | if (!_.isUndefined(postHook) && _.isFunction(postHook)) { 306 | postHook.call(this, doc, userId) 307 | } 308 | } 309 | 310 | /** 311 | * Process a given doc by a given syncronisation-setting 312 | * and add "sync"- and "postSync" options. 313 | * 314 | * @param {Object} options [description] 315 | * @return {Object} doc that contains the collected data 316 | */ 317 | static _processDoc(options = {}) { 318 | const { syncronisation, userId } = options 319 | const { viewCollection, sync, postSync, pick } = syncronisation 320 | let doc = options.doc 321 | // validate options 322 | // we cannot use SimpleSchema-validation here, 323 | // because we want to support use of superclasses 324 | // for docs in collection. 325 | if (!_.isObject(doc)) { 326 | throw new Meteor.Error('options.doc needs to be an Object') 327 | } 328 | check(syncronisation, Object) 329 | check(userId, Match.Maybe(String)) 330 | 331 | // Loop each property set in "sync" 332 | // and assign its return-value to the doc 333 | for (const property of Object.getOwnPropertyNames(sync)) { 334 | const propertyFunction = sync[property] 335 | if (!_.isFunction(propertyFunction)) { 336 | throw new Meteor.Error(`sync.${property} needs to be a function`) 337 | } 338 | 339 | // call the function 340 | // and assign its result to the object 341 | const result = propertyFunction.call(this, doc, userId) 342 | // if there is a valid result: assign it to doc 343 | if (result || result===[] || result===0) { 344 | doc[property] = result 345 | } else { 346 | delete doc[property] 347 | // we are using a $set operation which will ADD, 348 | // but NOT remove a property. So in this special case 349 | // we need to do an extra write to the db and unset 350 | DenormalizedViews._unsetProperty({ property, _id: doc._id, collection: viewCollection }) 351 | } 352 | } 353 | 354 | // Loop each property set in "postSync" 355 | // and assign its return-value to the doc 356 | if (postSync) { 357 | for (const property of Object.getOwnPropertyNames(postSync)) { 358 | const propertyFunction = postSync[property] 359 | if (!_.isFunction(propertyFunction)) { 360 | throw new Meteor.Error(`postSync.${property} needs to be a function`) 361 | } 362 | 363 | // call the function 364 | // and assign its result to the object 365 | const result = propertyFunction.call(this, doc, userId) 366 | // if there is a valid result: assign it to doc 367 | if (result || result===[] || result===0) { 368 | doc[property] = result 369 | } else { 370 | delete doc[property] 371 | // we are using a $set operation which will ADD, 372 | // but NOT remove a property. So in this special case 373 | // we need to do an extra write to the db and unset 374 | DenormalizedViews._unsetProperty({ property, _id: doc._id, collection: viewCollection }) 375 | } 376 | } 377 | } 378 | 379 | // pick 380 | if (pick) { 381 | doc = _.pick(doc, _.union( 382 | pick, 383 | Object.getOwnPropertyNames(sync), 384 | Object.getOwnPropertyNames(postSync), 385 | )) 386 | } 387 | 388 | return doc 389 | } 390 | 391 | /** 392 | * Remove doc from view-collection 393 | */ 394 | static _removeDocFromViewCollectionIfExists(options={}) { 395 | const { doc, viewCollection } = options 396 | DenormalizedViews._executeDatabaseComand(() => { 397 | const nrOfUpdates = viewCollection.remove(doc._id) 398 | if (nrOfUpdates>0) { 399 | debug(`Removed doc ${doc._id} from view-collection, because it was filtered out`) 400 | } 401 | }) 402 | } 403 | 404 | /** 405 | * Refreshes (=updates) the given ids in a syncronisation. 406 | * 407 | * To be used within insert- and update-hooks. 408 | * In remove hooks: use ``_removeIds``` 409 | * 410 | * @param {Object} options [description] 411 | * @return {[type]} [description] 412 | */ 413 | static _updateIds(options = {}) { 414 | new SimpleSchema({ 415 | identifier: { type: String }, 416 | idsToRefresh: { type: [String] }, 417 | userId: { type: String, optional: true }, 418 | }).validate(options) 419 | 420 | const { identifier, idsToRefresh, userId } = options 421 | 422 | const existingSyncronisation = DenormalizedViews._getExistingSyncronisation({ identifier }) 423 | debug(`refreshing ids in "view"-collection "${existingSyncronisation.viewCollection._name}":`, idsToRefresh) 424 | 425 | for (const id of idsToRefresh) { 426 | let doc = existingSyncronisation.sourceCollection.findOne(id) 427 | if (!doc) { 428 | debug(`existing docs in ${existingSyncronisation.sourceCollection._name}`, existingSyncronisation.sourceCollection.find().fetch()) 429 | 430 | throw new Meteor.Error(`trying to refresh "${id}", but it does NOT exist in collection "${existingSyncronisation.sourceCollection._name}". Are you sure that you passed the correct _ids?`) 431 | } 432 | doc = DenormalizedViews._processDoc({ 433 | doc, 434 | userId, 435 | syncronisation: existingSyncronisation, 436 | }) 437 | 438 | DenormalizedViews._executeDatabaseComand(() => { 439 | existingSyncronisation.viewCollection.update(doc._id, { $set: doc }) 440 | }) 441 | } 442 | } 443 | 444 | /** 445 | * Return an exising syncronisation by a given identifier. 446 | * NOTE: *depreciated* use `getView()` instead. 447 | * @param {Object} options [description] 448 | * @return {[type]} [description] 449 | */ 450 | static _getExistingSyncronisation(options = {}) { 451 | new SimpleSchema({ 452 | identifier: { type: String }, 453 | }).validate(options) 454 | 455 | const { identifier } = options 456 | 457 | return _.find(SyncronisationStore, store => store.identifier===identifier) 458 | } 459 | 460 | /** 461 | * Helper function which is used in refreshByCollection 462 | * to validate the refreshIds-property. 463 | * 464 | * Validate that we have a function. 465 | * Validate that the function returns either undefined, [String] or [] 466 | * @param {Object} options [description] 467 | * @return {[type]} [description] 468 | */ 469 | static _validateAndCallRefreshIds(options = {}) { 470 | new SimpleSchema({ 471 | doc: { type: Object, blackbox: true }, 472 | docPrevious: { type: Object, blackbox: true, optional: true }, // only on updates! 473 | refreshIds: { type: Function }, 474 | userId: { type: String, optional: true }, 475 | }).validate(options) 476 | 477 | const { doc, docPrevious, userId, refreshIds } = options 478 | 479 | // validate that we have a function 480 | if (!_.isFunction(refreshIds)) { 481 | throw new Meteor.Error('refreshByCollection.refreshIds needs to be a function') 482 | } 483 | // validate that the refreshIds-function returns either 484 | // undefined, [String] or [] 485 | const ids = refreshIds.call(this, doc, docPrevious, userId) 486 | if (!(Match.test(ids, [String]) || _.isUndefined(ids) || ids===[])) { 487 | throw new Meteor.Error(`refreshByCollection.refreshIds needs to return an array of strings, an empty array or undefined, BUT it returned "${ids}"`) 488 | } 489 | 490 | return ids 491 | } 492 | 493 | /** 494 | * This is a quick and dirty solution to remove a property. 495 | * 496 | * We should make this better and minimize writes to the collection, 497 | * this will do it for now. 498 | * 499 | * @param {Object} options [description] 500 | * @return {[type]} [description] 501 | */ 502 | static _unsetProperty(options = {}) { 503 | new SimpleSchema({ 504 | _id: { type: String }, 505 | collection: { type: Mongo.Collection }, 506 | property: { type: String }, 507 | }).validate(options) 508 | 509 | const { _id, collection, property } = options 510 | 511 | DenormalizedViews._executeDatabaseComand(() => { 512 | collection.update(_id, JSON.parse(`{ "$unset": { "${property}": 1 } }`)) 513 | }) 514 | } 515 | 516 | /** 517 | * Execute a database command. If ``DeferWriteAccess=true```, 518 | * wrap it into a ``Meteor.defer``, otherwise call it like any 519 | * other command and give it more priority. 520 | * 521 | * @param {[type]} aFunction [description] 522 | * @return {[type]} [description] 523 | */ 524 | static _executeDatabaseComand(aFunction) { 525 | // we run database-updates ONLY on the server, 526 | // in order to relax the client, BUT need collections 527 | // to be known on the client, 528 | // p.e. for aldeed:tabular-support, so this is the 529 | // place to make sure, that we are on server. 530 | // In future we might add a "publishToClient" setting, 531 | // so that we can utilizy latency compensation. 532 | // For now this will do it. 533 | if (Meteor.isServer) { 534 | if (DenormalizedViews.DeferWriteAccess) { 535 | // good for mass-data 536 | Meteor.defer(() => { 537 | aFunction.call() 538 | }) 539 | } else { 540 | // high speed 541 | aFunction.call() 542 | } 543 | } 544 | } 545 | } 546 | DenormalizedViews.Debug = false 547 | DenormalizedViews.DeferWriteAccess = false 548 | -------------------------------------------------------------------------------- /denormalized-views.tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* eslint-disable func-names, prefer-arrow-callback */ 3 | import { _ } from 'underscore' 4 | import { chai } from 'meteor/practicalmeteor:chai' 5 | const expect = chai.expect 6 | import { spies } from 'meteor/practicalmeteor:sinon' 7 | 8 | import { Meteor } from 'meteor/meteor' 9 | import { Mongo } from 'meteor/mongo' 10 | import { SimpleSchema } from 'meteor/aldeed:simple-schema' 11 | 12 | import { DenormalizedViews, ERROR_IDENTIFIERT_EXISTS, ERROR_SOURCE_AND_TARGET_COLLECTIONS_NEED_TO_BE_DIFFERENT, ERROR_SYNC_NEEDS_TO_HAVE_CONTENT, ERROR_SYNC_ALREADY_EXISTS_FOR_SOURCE_TARGET_COLLECTIONS, ERROR_REFRESH_BY_COLLECTION_CAN_NOT_BE_SET_TO_SOURCE_COLLECTION, ERROR_REFRESH_BY_COLLECTION_NEEDS_TO_BE_ASSIGNED_TO_AN_EXISTING_ID } from './denormalized-views.js' 13 | 14 | DenormalizedViews.Debug = true 15 | 16 | // empty class used for spies 17 | const HookClass = class HookClass { 18 | static processHook(doc, userId) { 19 | // do something 20 | } 21 | } 22 | 23 | // FIXTURES 24 | const Authors = new Mongo.Collection('authors') 25 | const Categories = new Mongo.Collection('categories') 26 | const Comments = new Mongo.Collection('comments') 27 | const Posts = new Mongo.Collection('posts') 28 | const PostsDenormalizedView = new Mongo.Collection('postsdenormalizedview') 29 | const Tags = new Mongo.Collection('tags') 30 | const HookTestCollection = new Mongo.Collection('hooktestcollection') // needed for testcase 31 | Authors.attachSchema(new SimpleSchema({ 32 | name: { 33 | type: String, 34 | }, 35 | })) 36 | Comments.attachSchema(new SimpleSchema({ 37 | text: { 38 | type: String, 39 | }, 40 | })) 41 | Categories.attachSchema(new SimpleSchema({ 42 | text: { 43 | type: String, 44 | }, 45 | postId: { 46 | type: String, 47 | }, 48 | })) 49 | Tags.attachSchema(new SimpleSchema({ 50 | text: { 51 | type: String, 52 | }, 53 | postIds: { 54 | type: [String], 55 | }, 56 | })) 57 | Posts.attachSchema(new SimpleSchema({ 58 | text: { 59 | type: String, 60 | }, 61 | additionalText: { 62 | type: String, 63 | }, 64 | authorId: { 65 | type: String, 66 | }, 67 | commentIds: { 68 | type: [String], 69 | optional: true, 70 | }, 71 | categoryId: { // field to test insert-commant on refreshByCollection 72 | type: String, 73 | optional: true, 74 | }, 75 | })) 76 | 77 | // Identifier allow you to add MULTIPLE view to one single collection 78 | const DENORMALIZED_POST_COLLECTION = 'DENORMALIZED_POST_COLLECTION' 79 | DenormalizedViews.addView({ 80 | identifier: DENORMALIZED_POST_COLLECTION, 81 | sourceCollection: Posts, 82 | viewCollection: PostsDenormalizedView, 83 | filter(post) { 84 | // let's filter out post 5 85 | if (post.text!=='post 5') { 86 | return true 87 | } 88 | return false 89 | }, 90 | postHook(doc, userId) { 91 | HookClass.processHook(doc, userId) 92 | PostsDenormalizedView.update(doc._id, { $set: { 93 | postHookValue: `postHookValue for ${doc._id}`, 94 | }}) 95 | }, 96 | sync: { 97 | commentsCache: (post, userId) => { 98 | const comments = [] 99 | for (const commentId of post.commentIds) { 100 | const comment = Comments.findOne(commentId) 101 | comments.push(comment) 102 | } 103 | return comments 104 | }, 105 | authorCache: (post, userId) => { 106 | return Authors.findOne(post.authorId) 107 | }, 108 | categoryCache: (post, userId) => { 109 | return Categories.findOne(post.categoryId) 110 | }, 111 | tagsCache: (post, userId) => { 112 | return Tags.find({ postIds: post._id }).fetch() 113 | }, 114 | }, 115 | postSync: { 116 | wholeText: (post, userId) => { 117 | let authorText = '' 118 | if (post.authorCache) { 119 | authorText = post.authorCache.name 120 | } 121 | return `${post.text}, ${_.pluck(post.commentsCache, 'text').join(', ')}, ${authorText}` 122 | }, 123 | numberOfComments: (post, userId) => { 124 | return post.commentsCache.length 125 | }, 126 | }, 127 | }) 128 | 129 | DenormalizedViews.refreshByCollection({ 130 | identifier: DENORMALIZED_POST_COLLECTION, 131 | relatedCollection: Authors, 132 | refreshIds: (author, authorPrevious, userId) => { 133 | expect(author).to.be.defined 134 | // return _id-array of posts that should be updated 135 | // return false, an empty array or undefined to NOT sync 136 | const posts = Posts.find({ authorId: author._id }).fetch() 137 | return _.pluck(posts, '_id') 138 | }, 139 | }) 140 | 141 | DenormalizedViews.refreshByCollection({ 142 | identifier: DENORMALIZED_POST_COLLECTION, 143 | relatedCollection: Categories, 144 | refreshIds: (doc, docPrevious, userId) => { 145 | expect(doc).to.be.defined 146 | // return _id-array of posts that should be updated 147 | // return false, an empty array or undefined to NOT sync 148 | if (docPrevious) { 149 | // update 150 | return _.union(doc.postIds, docPrevious.postIds) 151 | } else { 152 | // insert | remove 153 | return doc.postIds 154 | } 155 | }, 156 | }) 157 | 158 | DenormalizedViews.refreshByCollection({ 159 | identifier: DENORMALIZED_POST_COLLECTION, 160 | relatedCollection: Tags, 161 | refreshIds: (doc, docPrevious) => { 162 | if (docPrevious) { 163 | // update 164 | return _.union(doc.postIds, docPrevious.postIds) 165 | } else { 166 | // insert | remove 167 | return doc.postIds 168 | } 169 | }, 170 | }) 171 | 172 | const setupFixtures = () => { 173 | Authors.direct.remove({}) 174 | Comments.direct.remove({}) 175 | Posts.direct.remove({}) 176 | PostsDenormalizedView.direct.remove({}) 177 | Tags.direct.remove({}) 178 | 179 | const authorId1 = Authors.insert({ 180 | name: 'author 1', 181 | }) 182 | const authorId2 = Authors.insert({ 183 | name: 'author 2', 184 | }) 185 | const authorId3 = Authors.insert({ 186 | name: 'author 3', 187 | }) 188 | const commentId1 = Comments.insert({ 189 | text: 'comment 1', 190 | }) 191 | const commentId2 = Comments.insert({ 192 | text: 'comment 2', 193 | }) 194 | const commentId3 = Comments.insert({ 195 | text: 'comment 3', 196 | }) 197 | const commentId4 = Comments.insert({ 198 | text: 'comment 4', 199 | }) 200 | const postId1 = Posts.insert({ 201 | text: 'post 1', 202 | additionalText: 'additionalText post 1', 203 | authorId: authorId1, 204 | commentIds: [ 205 | commentId1, 206 | ], 207 | }) 208 | const postId2 = Posts.insert({ 209 | text: 'post 2', 210 | additionalText: 'additionalText post 2', 211 | authorId: authorId1, 212 | commentIds: [ 213 | commentId2, 214 | ], 215 | }) 216 | const postId3 = Posts.insert({ 217 | text: 'post 3', 218 | additionalText: 'additionalText post 3', 219 | authorId: authorId2, 220 | commentIds: [ 221 | ], 222 | }) 223 | const postId4 = Posts.insert({ 224 | text: 'post 4', 225 | additionalText: 'additionalText post 4', 226 | authorId: authorId2, 227 | commentIds: [ 228 | commentId4, 229 | ], 230 | }) 231 | const tagId1 = Tags.insert({ 232 | text: 'tag 1', 233 | postIds: [ 234 | postId1, 235 | postId2, 236 | ], 237 | }) 238 | 239 | return { 240 | commentId1, 241 | commentId2, 242 | commentId3, 243 | commentId4, 244 | authorId1, 245 | authorId2, 246 | authorId3, 247 | postId1, 248 | postId2, 249 | postId3, 250 | postId4, 251 | tagId1, 252 | } 253 | } 254 | 255 | const validateFixtures = (fixtures) => { 256 | expect(Authors.find().count()).to.equal(3) 257 | expect(Comments.find().count()).to.equal(4) 258 | expect(Posts.find().count()).to.equal(4) 259 | expect(PostsDenormalizedView.find().count()).to.equal(4) 260 | expect(Tags.find().count()).to.equal(1) 261 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 262 | const postDenormalized2 = PostsDenormalizedView.findOne(fixtures.postId2) 263 | const postDenormalized3 = PostsDenormalizedView.findOne(fixtures.postId3) 264 | const postDenormalized4 = PostsDenormalizedView.findOne(fixtures.postId4) 265 | 266 | expect(postDenormalized1._id).to.equal(fixtures.postId1) 267 | expect(postDenormalized1.text).to.equal('post 1') 268 | expect(postDenormalized1.commentsCache.length).to.equal(1) 269 | expect(postDenormalized1.commentsCache[0].text).to.equal('comment 1') 270 | expect(postDenormalized1.authorCache.name).to.equal('author 1') 271 | expect(postDenormalized1.tagsCache[0].text).to.equal('tag 1') 272 | expect(postDenormalized1.wholeText).to.equal('post 1, comment 1, author 1') 273 | expect(postDenormalized1.numberOfComments).to.equal(1) 274 | 275 | expect(postDenormalized2._id).to.equal(fixtures.postId2) 276 | expect(postDenormalized2.text).to.equal('post 2') 277 | expect(postDenormalized2.commentsCache.length).to.equal(1) 278 | expect(postDenormalized2.commentsCache[0].text).to.equal('comment 2') 279 | expect(postDenormalized2.authorCache.name).to.equal('author 1') 280 | expect(postDenormalized2.tagsCache[0].text).to.equal('tag 1') 281 | expect(postDenormalized2.wholeText).to.equal('post 2, comment 2, author 1') 282 | expect(postDenormalized2.numberOfComments).to.equal(1) 283 | 284 | expect(postDenormalized3._id).to.equal(fixtures.postId3) 285 | expect(postDenormalized3.text).to.equal('post 3') 286 | expect(postDenormalized3.commentsCache.length).to.equal(0) 287 | expect(postDenormalized3.authorCache.name).to.equal('author 2') 288 | expect(postDenormalized3.wholeText).to.equal('post 3, , author 2') 289 | expect(postDenormalized3.numberOfComments).to.equal(0) 290 | 291 | expect(postDenormalized4._id).to.equal(fixtures.postId4) 292 | expect(postDenormalized4.text).to.equal('post 4') 293 | expect(postDenormalized4.commentsCache.length).to.equal(1) 294 | expect(postDenormalized4.commentsCache[0].text).to.equal('comment 4') 295 | expect(postDenormalized4.authorCache.name).to.equal('author 2') 296 | expect(postDenormalized4.wholeText).to.equal('post 4, comment 4, author 2') 297 | expect(postDenormalized4.numberOfComments).to.equal(1) 298 | } 299 | 300 | // TESTS 301 | if (Meteor.isServer) { 302 | describe('Foundation', function () { 303 | it('CollectionHooks-package allows us to instanciate multiple hook-functions. All defined hook-functions will be run.', function () { 304 | // define 2 hooks to test if they are both run 305 | HookTestCollection.after.insert(function (userId, doc) { 306 | HookTestCollection.update(doc._id, { $set: { insertHook1: 'insertHook1 was here' } }) 307 | }) 308 | HookTestCollection.after.insert(function (userId, doc) { 309 | HookTestCollection.update(doc._id, { $set: { insertHook2: 'insertHook2 was here' } }) 310 | }) 311 | // do an insert, to trigger the hooks 312 | const docId = HookTestCollection.insert({ 313 | test: 'test insert', 314 | }) 315 | // check 316 | const doc = HookTestCollection.findOne(docId) 317 | expect(doc.test).to.equal('test insert') 318 | expect(doc.insertHook1).to.equal('insertHook1 was here') 319 | expect(doc.insertHook2).to.equal('insertHook2 was here') 320 | }) 321 | }) 322 | describe('DenormalizedViews', function () { 323 | beforeEach(() => { 324 | Authors.remove({}) 325 | Comments.remove({}) 326 | Posts.remove({}) 327 | PostsDenormalizedView.remove({}) 328 | // SPIES 329 | spies.create('processHook', HookClass, 'processHook') 330 | }) 331 | afterEach(() => { 332 | // SPIES 333 | spies.restoreAll() 334 | }) 335 | 336 | it('.addView does validate options correctly', function () { 337 | expect(() => { 338 | DenormalizedViews.addView({ 339 | identifier: DENORMALIZED_POST_COLLECTION, // duplicate id 340 | sourceCollection: Posts, 341 | viewCollection: PostsDenormalizedView, 342 | sync: { }, 343 | }) 344 | }).to.throw(ERROR_IDENTIFIERT_EXISTS) 345 | 346 | expect(() => { 347 | DenormalizedViews.addView({ 348 | identifier: 'unique', 349 | sourceCollection: Posts, // same collection 350 | viewCollection: Posts, // same collection 351 | sync: { }, 352 | }) 353 | }).to.throw(ERROR_SOURCE_AND_TARGET_COLLECTIONS_NEED_TO_BE_DIFFERENT) 354 | 355 | expect(() => { 356 | DenormalizedViews.addView({ 357 | identifier: 'unique', 358 | sourceCollection: Posts, 359 | viewCollection: PostsDenormalizedView, 360 | sync: { 361 | // NO content 362 | }, 363 | }) 364 | }).to.throw(ERROR_SYNC_NEEDS_TO_HAVE_CONTENT) 365 | 366 | expect(() => { 367 | DenormalizedViews.addView({ 368 | identifier: 'unique but combination already exists', 369 | sourceCollection: Posts, 370 | viewCollection: PostsDenormalizedView, 371 | sync: { 372 | authorCache: (post) => { 373 | return Authors.findOne(post.authorId) 374 | }, 375 | }, 376 | }) 377 | }).to.throw(ERROR_SYNC_ALREADY_EXISTS_FOR_SOURCE_TARGET_COLLECTIONS) 378 | }) 379 | 380 | it('.addView works as expected on INSERTS on viewCollection', function () { 381 | const fixtures = setupFixtures() // inserts happen here 382 | validateFixtures(fixtures) // inserts are validated in here 383 | }) 384 | 385 | it('.addView works as expected on UPDATES on viewCollection', function () { 386 | const fixtures = setupFixtures() 387 | validateFixtures(fixtures) 388 | 389 | const updates = Posts.update(fixtures.postId1, { $set: { text: 'post 1 newtext', commentIds: [fixtures.commentId2, fixtures.commentId3], authorId: fixtures.authorId2 } }) 390 | expect(updates).to.equal(1) 391 | expect(PostsDenormalizedView.find().count()).to.equal(4) 392 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 393 | expect(postDenormalized1.text).to.equal('post 1 newtext') 394 | expect(postDenormalized1.commentsCache.length).to.equal(2) 395 | expect(postDenormalized1.commentsCache[0].text).to.equal('comment 2') 396 | expect(postDenormalized1.commentsCache[1].text).to.equal('comment 3') 397 | expect(postDenormalized1.authorCache.name).to.equal('author 2') 398 | expect(postDenormalized1.wholeText).to.equal('post 1 newtext, comment 2, comment 3, author 2') 399 | expect(postDenormalized1.numberOfComments).to.equal(2) 400 | }) 401 | 402 | it('.addView works as expected on REMOVES on viewCollection', function () { 403 | const fixtures = setupFixtures() 404 | validateFixtures(fixtures) 405 | 406 | const updates = Posts.remove(fixtures.postId1) 407 | expect(updates).to.equal(1) 408 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 409 | expect(postDenormalized1).to.be.undefined 410 | }) 411 | 412 | it('.getView works as expected', function () { 413 | const fixtures = setupFixtures() 414 | validateFixtures(fixtures) 415 | 416 | expect(DenormalizedViews.getView(DENORMALIZED_POST_COLLECTION).identifier).to.equal(DENORMALIZED_POST_COLLECTION) 417 | }) 418 | 419 | it('.refreshByCollection correctly validated options', function () { 420 | expect(() => { 421 | DenormalizedViews.refreshByCollection({ 422 | identifier: 'unique does NOT exist yet', 423 | relatedCollection: Authors, // wrong collection!! 424 | refreshIds: () => {}, 425 | }) 426 | }).to.throw(ERROR_REFRESH_BY_COLLECTION_NEEDS_TO_BE_ASSIGNED_TO_AN_EXISTING_ID) 427 | expect(() => { 428 | DenormalizedViews.refreshByCollection({ 429 | identifier: DENORMALIZED_POST_COLLECTION, 430 | relatedCollection: Posts, // wrong collection!! 431 | refreshIds: () => {}, 432 | }) 433 | }).to.throw(ERROR_REFRESH_BY_COLLECTION_CAN_NOT_BE_SET_TO_SOURCE_COLLECTION) 434 | }) 435 | 436 | it('.refreshByCollection works as expected on updates on relatedCollection', function () { 437 | // NOTE: refreshByCollection() is set up above on Authors collection 438 | // a simple update on author should refresh the "view"-Collection 439 | const fixtures = setupFixtures() 440 | validateFixtures(fixtures) 441 | 442 | // UPDATE (has one relationship) 443 | Authors.update(fixtures.authorId1, { $set: { name: 'author 1 name NEW' } }) 444 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 445 | expect(postDenormalized1.authorCache.name).to.equal('author 1 name NEW') 446 | expect(postDenormalized1.wholeText).to.equal('post 1, comment 1, author 1 name NEW') 447 | 448 | // UPDATE (has multiple relationship) 449 | Tags.update(fixtures.tagId1, { $set: { 450 | postIds: [ 451 | fixtures.postId1, 452 | ], 453 | } }) 454 | const postDenormalized1_1 = PostsDenormalizedView.findOne(fixtures.postId1) 455 | const postDenormalized2_1 = PostsDenormalizedView.findOne(fixtures.postId2) 456 | expect(postDenormalized1_1.tagsCache.length).to.equal(1) 457 | expect(postDenormalized1_1.tagsCache[0].text).to.equal('tag 1') 458 | expect(postDenormalized2_1.tagsCache.length).to.equal(0) 459 | }) 460 | 461 | it('.refreshByCollection works as expected on removes on relatedCollection', function () { 462 | const fixtures = setupFixtures() 463 | validateFixtures(fixtures) 464 | 465 | // REMOVE 466 | Authors.remove(fixtures.authorId1) 467 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 468 | expect(postDenormalized1.authorCache).to.be.undefined 469 | expect(postDenormalized1.wholeText).to.equal('post 1, comment 1, ') 470 | }) 471 | 472 | it('.refreshByCollection works as expected on inserts on relatedCollection', function () { 473 | const fixtures = setupFixtures() 474 | validateFixtures(fixtures) 475 | 476 | // this is a weird useCase for an insert-trigger, because in order 477 | // to keep data consistentand enable Posts "view-collection" 478 | // to load the correct data, we need to set Posts.categoryId anyway. 479 | // So this test does NOT really make sense as it does NOT test, how relatedCollection() 480 | // works on insert, except that it makes sure that NO errors are thrown. 481 | const categoryId1 = Categories.insert({ text: 'category 1', postId: fixtures.postId1 }) 482 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 483 | expect(postDenormalized1.categoryCache).to.be.undefined 484 | Posts.update(fixtures.postId1, { $set: { categoryId: categoryId1 } }) 485 | const postDenormalized1_2 = PostsDenormalizedView.findOne(fixtures.postId1) 486 | expect(postDenormalized1_2.categoryCache.text).to.equal('category 1') 487 | }) 488 | 489 | it('.refreshManually works as expected', function () { 490 | const fixtures = setupFixtures() 491 | validateFixtures(fixtures) 492 | 493 | // NOTE: in our test-setting Comments are NOT automatically synced 494 | // to "view"-collection via ``refreshByCollection``, 495 | // so that we can test ``refreshManually()`` 496 | Comments.update(fixtures.commentId1, { $set: { text: 'comment 1 new text' } }) 497 | DenormalizedViews.refreshManually({ 498 | identifier: DENORMALIZED_POST_COLLECTION, 499 | refreshIds: [fixtures.postId1] 500 | }) 501 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 502 | expect(postDenormalized1.text).to.equal('post 1') 503 | expect(postDenormalized1.commentsCache.length).to.equal(1) 504 | expect(postDenormalized1.commentsCache[0].text).to.equal('comment 1 new text') 505 | expect(postDenormalized1.authorCache.name).to.equal('author 1') 506 | expect(postDenormalized1.wholeText).to.equal('post 1, comment 1 new text, author 1') 507 | expect(postDenormalized1.numberOfComments).to.equal(1) 508 | }) 509 | 510 | it('.refreshAll works as expected', function () { 511 | const fixtures = setupFixtures() 512 | validateFixtures(fixtures) 513 | 514 | // NOTE: comments are NOT synced automatically, so in this test 515 | // we update their text and then check if the "view"-collection 516 | // contains the right data after ``refreshAll()`` 517 | Comments.update(fixtures.commentId1, { $set: { text: 'comment 1 new text' } }) 518 | Comments.update(fixtures.commentId2, { $set: { text: 'comment 2 new text' } }) 519 | Comments.update(fixtures.commentId3, { $set: { text: 'comment 3 new text' } }) 520 | Comments.update(fixtures.commentId4, { $set: { text: 'comment 4 new text' } }) 521 | const filteredId = Posts.insert({ 522 | text: 'post 5', // to be FILTERED out 523 | additionalText: 'additionalText post 1', 524 | authorId: Authors.findOne()._id, // random 525 | commentIds: [ 526 | Comments.findOne()._id, // random 527 | ], 528 | }) 529 | 530 | DenormalizedViews.refreshAll(DENORMALIZED_POST_COLLECTION) 531 | 532 | const postDenormalized1 = PostsDenormalizedView.findOne(fixtures.postId1) 533 | const postDenormalized2 = PostsDenormalizedView.findOne(fixtures.postId2) 534 | const postDenormalized3 = PostsDenormalizedView.findOne(fixtures.postId3) 535 | const postDenormalized4 = PostsDenormalizedView.findOne(fixtures.postId4) 536 | expect(PostsDenormalizedView.findOne(filteredId)).to.be.undefined 537 | 538 | expect(postDenormalized1.commentsCache.length).to.equal(1) 539 | expect(postDenormalized1.commentsCache[0].text).to.equal('comment 1 new text') 540 | expect(postDenormalized1.postHookValue).to.equal(`postHookValue for ${fixtures.postId1}`) 541 | expect(postDenormalized2.commentsCache.length).to.equal(1) 542 | expect(postDenormalized2.commentsCache[0].text).to.equal('comment 2 new text') 543 | expect(postDenormalized2.postHookValue).to.equal(`postHookValue for ${fixtures.postId2}`) 544 | expect(postDenormalized3.commentsCache.length).to.equal(0) 545 | expect(postDenormalized3.postHookValue).to.equal(`postHookValue for ${fixtures.postId3}`) 546 | expect(postDenormalized4.commentsCache.length).to.equal(1) 547 | expect(postDenormalized4.commentsCache[0].text).to.equal('comment 4 new text') 548 | expect(postDenormalized4.postHookValue).to.equal(`postHookValue for ${fixtures.postId4}`) 549 | }) 550 | 551 | it('filter-option works as expected', function () { 552 | const fixtures = setupFixtures() 553 | validateFixtures(fixtures) 554 | expect(PostsDenormalizedView.find().count()).to.equal(4) 555 | // TEST: 556 | // add 1 more post to test filter 557 | // INSERT 558 | // .. with text that is expected to be FILTERED 559 | const id = Posts.insert({ 560 | text: 'post 5', // to be FILTERED out 561 | additionalText: 'additionalText post 1', 562 | authorId: Authors.findOne()._id, // random 563 | commentIds: [ 564 | Comments.findOne()._id, // random 565 | ], 566 | }) 567 | expect(PostsDenormalizedView.find().count()).to.equal(4) 568 | // UPDATE 569 | // .. same text (expect to be filtered out) 570 | Posts.update(id, { $set: { 571 | text: 'post 5', // to be FILTERED out 572 | additionalText: 'additionalText post 1 update', 573 | } }) 574 | expect(PostsDenormalizedView.find().count()).to.equal(4) 575 | // UPDATE 576 | // .. different text (NOT to be filtered) 577 | Posts.update(id, { $set: { 578 | text: 'post 6', // expect PASS 579 | } }) 580 | expect(PostsDenormalizedView.find().count()).to.equal(5) 581 | }) 582 | 583 | it('processHook-option works as expected on insert, update and remove', function () { 584 | const authorId1 = Authors.insert({ 585 | name: 'author 1', 586 | }) 587 | const commentId1 = Comments.insert({ 588 | text: 'comment 1', 589 | }) 590 | const id = Posts.insert({ 591 | text: 'post 1', 592 | additionalText: 'additionalText post 1', 593 | authorId: authorId1, 594 | commentIds: [ 595 | commentId1, 596 | ], 597 | }) 598 | expect(spies.processHook).to.have.callCount(1) 599 | Posts.update(id, { $set: { 600 | text: 'post 1 update', 601 | } }) 602 | expect(spies.processHook).to.have.callCount(2) 603 | Posts.remove(id) 604 | expect(spies.processHook).to.have.callCount(3) 605 | }) 606 | }) 607 | } 608 | -------------------------------------------------------------------------------- /docs/data-schema.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thebarty/meteor-denormalized-views/925aaca6cb9e945e0e3f2bd4c36b0ed10ea27049/docs/data-schema.jpg -------------------------------------------------------------------------------- /docs/user-interface.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thebarty/meteor-denormalized-views/925aaca6cb9e945e0e3f2bd4c36b0ed10ea27049/docs/user-interface.jpg -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016 Henning 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ 2 | name: 'thebarty:denormalized-views', 3 | version: '0.0.11', 4 | summary: 'Easily create "readonly" denormalized mongo-"views" (collections), p.e. for searchable tables', 5 | git: 'https://github.com/thebarty/meteor-denormalized-views', 6 | documentation: 'README.md', 7 | }); 8 | 9 | Npm.depends({ 10 | 'underscore': '1.8.3', 11 | 'underscore.string': '3.3.4', 12 | }) 13 | 14 | Package.onUse(function(api) { 15 | api.versionsFrom('1.3.1'); // todo: test if we can set versions down 16 | api.use([ 17 | 'check', 18 | 'ecmascript', 19 | 'aldeed:simple-schema@1.5.1', // todo: test if we can set versions down 20 | 'matb33:collection-hooks@0.8.4', // needed due to https://github.com/matb33/meteor-collection-hooks/issues/207 21 | ]) 22 | // TODO add code ONLY to server without crashing production, 23 | // BUT be CAREFUL: a simple `], 'server')` will crash the client when running 24 | // meteor in production (or via `meteor --production`) 25 | // see https://github.com/thebarty/meteor-denormalized-views/issues/3 26 | api.mainModule('denormalized-views.js'); 27 | }); 28 | 29 | Package.onTest(function(api) { 30 | api.use([ 31 | 'check', 32 | 'ecmascript', 33 | 'tinytest', 34 | 'test-helpers', 35 | 'ejson', 36 | 'ordered-dict', 37 | 'random', 38 | 'deps', 39 | 'minimongo', 40 | 'aldeed:simple-schema', 41 | 'aldeed:collection2@2.9.1', 42 | 'matb33:collection-hooks', 43 | 'thebarty:denormalized-views', 44 | 'cultofcoders:mocha', 45 | ]) 46 | // TODO add code ONLY to server without crashing production, 47 | // BUT be CAREFUL: a simple `], 'server')` will crash the client when running 48 | // meteor in production (or via `meteor --production`) 49 | // see https://github.com/thebarty/meteor-denormalized-views/issues/3 50 | api.mainModule('denormalized-views.tests.js'); 51 | }); 52 | -------------------------------------------------------------------------------- /tools.js: -------------------------------------------------------------------------------- 1 | import { DenormalizedViews } from './denormalized-views.js' 2 | 3 | /** 4 | * [debug description] 5 | */ 6 | export const debug = function debug(message, object = undefined) { 7 | if (DenormalizedViews.Debug) { 8 | console.log(`[DenormalizedViews] ${message}`) 9 | if (object) { 10 | console.log(object) 11 | } 12 | } 13 | } 14 | 15 | /** 16 | * Extend Object by another object WITHOUT overwriting properties. 17 | * Thanks to http://stackoverflow.com/questions/20590177/merge-two-objects-without-override 18 | */ 19 | export const extend = function (target) { 20 | for(var i=1; i