├── .npmrc ├── test ├── .eslintrc ├── fixtures │ └── simple-app │ │ ├── server │ │ ├── datasources.json │ │ ├── config.json │ │ ├── middleware.json │ │ ├── model-config.json │ │ ├── datasources.local.js │ │ └── server.js │ │ └── common │ │ └── models │ │ ├── person.json │ │ └── person.js └── test.js ├── .gitignore ├── .eslintrc ├── circle.yml ├── lib ├── index.js └── changed.js ├── LICENSE ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fullcube/mocha" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea 3 | .nyc_output/ 4 | coverage 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "fullcube", 3 | "root": true 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/datasources.json: -------------------------------------------------------------------------------- 1 | { 2 | "db": { 3 | "name": "db", 4 | "connector": "memory" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "restApiRoot": "/api", 3 | "host": "localhost", 4 | "port": 3000, 5 | "legacyExplorer": false 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/middleware.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial": {}, 3 | "session": {}, 4 | "auth": {}, 5 | "parse": {}, 6 | "routes": {}, 7 | "files": {}, 8 | "final": {} 9 | } 10 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6.1.0 4 | test: 5 | post: 6 | - npm run coverage 7 | deployment: 8 | master: 9 | branch: master 10 | commands: 11 | - npm run semantic-release 12 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/model-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "sources": [ 4 | "../common/models" 5 | ], 6 | "mixins": [ 7 | "../../../../lib" 8 | ] 9 | }, 10 | "Person": { 11 | "dataSource": "db", 12 | "public": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/datasources.local.js: -------------------------------------------------------------------------------- 1 | /* eslint no-process-env: 0 */ 2 | 3 | 'use strict' 4 | 5 | const datasources = {} 6 | const { MONGODB_URL } = process.env 7 | 8 | if (MONGODB_URL) { 9 | datasources.db = { 10 | name: 'db', 11 | connector: 'loopback-connector-mongodb', 12 | url: MONGODB_URL, 13 | } 14 | } 15 | 16 | module.exports = datasources 17 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const deprecate = require('depd')('loopback-ds-changed-mixin') 4 | const changed = require('./changed') 5 | 6 | module.exports = function mixin(app) { 7 | app.loopback.modelBuilder.mixins.define = deprecate.function(app.loopback.modelBuilder.mixins.define, 8 | 'app.modelBuilder.mixins.define: Use mixinSources instead') 9 | app.loopback.modelBuilder.mixins.define('Changed', changed) 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/person.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Person", 3 | "base": "PersistedModel", 4 | "idInjection": true, 5 | "options": { 6 | "validateUpsert": true 7 | }, 8 | "properties": { 9 | "name": "String", 10 | "nickname": "String", 11 | "age": "Number", 12 | "status": "String" 13 | }, 14 | "validations": [], 15 | "relations": {}, 16 | "methods": {}, 17 | "mixins": { 18 | "Changed": { 19 | "properties": { 20 | "name": "changeName", 21 | "nickname": "changeName", 22 | "age": "changeAge", 23 | "status": "changeStatus" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/server.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | const loopback = require('loopback') 4 | const boot = require('loopback-boot') 5 | 6 | const app = loopback() 7 | 8 | app.use('/api', loopback.rest()) 9 | 10 | app.start = function() { 11 | // start the web server 12 | return app.listen(function() { 13 | app.emit('started') 14 | console.log('Web server listening at: %s', app.get('url')) 15 | }) 16 | } 17 | 18 | // Bootstrap the application, configure models, datasources and middleware. 19 | // Sub-apps like REST API are mounted via boot scripts. 20 | boot(app, __dirname, function(err) { 21 | if (err) { 22 | throw err 23 | } 24 | 25 | // start the server if `$ node server.js` 26 | if (require.main === module) { 27 | app.start() 28 | } 29 | }) 30 | 31 | module.exports = app 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Tom Kirkpatrick 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/person.js: -------------------------------------------------------------------------------- 1 | const utils = require('loopback-datasource-juggler/lib/utils') 2 | const debug = require('debug')('loopback-ds-changed-mixin') 3 | 4 | module.exports = function(Person) { 5 | // Define a function that should be called when a change is detected. 6 | Person.changeName = function(args, cb) { 7 | cb = cb || utils.createPromiseCallback() 8 | debug('this.changeName() called with %o', args) 9 | process.nextTick(function() { 10 | cb(null) 11 | }) 12 | return cb.promise 13 | } 14 | 15 | // Define a function that should be called when a change is detected. 16 | Person.changeStatus = function(args, cb) { 17 | cb = cb || utils.createPromiseCallback() 18 | debug('this.changeStatus() called with %o', args) 19 | process.nextTick(function() { 20 | cb(null) 21 | }) 22 | return cb.promise 23 | } 24 | 25 | // Define a function that should be called when a change is detected. 26 | Person.changeAge = function(args, cb) { 27 | cb = cb || utils.createPromiseCallback() 28 | debug('this.changeAge() called with %o', args) 29 | process.nextTick(function() { 30 | cb(null) 31 | }) 32 | return cb.promise 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-ds-changed-mixin", 3 | "version": "0.0.0-development", 4 | "description": "A mixin to enable loopback Model properties to be marked as changed.", 5 | "main": "lib/index.js", 6 | "keywords": [ 7 | "loopback", 8 | "strongloop" 9 | ], 10 | "author": "Tom Kirkpatrick", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/fullcube/loopback-ds-changed-mixin.git" 15 | }, 16 | "scripts": { 17 | "lint": "eslint .", 18 | "test": "NODE_ENV=test nyc --reporter=lcov --reporter=text --reporter=text-summary mocha test/*test.js", 19 | "test:watch": "npm run test -- -w", 20 | "pretest": "npm run lint", 21 | "coverage": "nyc report --reporter=text-lcov | coveralls", 22 | "semantic-release": "semantic-release pre && npm publish && semantic-release post", 23 | "commitmsg": "validate-commit-msg" 24 | }, 25 | "dependencies": { 26 | "async": "2.5.0", 27 | "debug": "2.6.8", 28 | "lodash": "4.17.4" 29 | }, 30 | "devDependencies": { 31 | "@bubltechnology/customizable-commit-analyzer": "1.0.2-0", 32 | "bluebird": "3.5.0", 33 | "chai": "4.0.2", 34 | "condition-circle": "1.5.0", 35 | "conventional-commit-types": "2.1.0", 36 | "coveralls": "2.13.1", 37 | "dirty-chai": "2.0.0", 38 | "eslint-config-fullcube": "3.0.0", 39 | "husky": "0.14.2", 40 | "loopback": "3.8.0", 41 | "loopback-boot": "2.25.0", 42 | "loopback-connector-mongodb": "3.2.0", 43 | "loopback-testing": "1.4.0", 44 | "mocha": "3.4.2", 45 | "mocha-sinon": "2.0.0", 46 | "nyc": "11.0.3", 47 | "semantic-release": "6.3.6", 48 | "sinon": "2.3.6", 49 | "sinon-chai": "2.11.0", 50 | "validate-commit-msg": "2.12.3" 51 | }, 52 | "config": { 53 | "commitTypeMap": { 54 | "feat": "minor", 55 | "fix": "patch", 56 | "docs": "patch", 57 | "style": "patch", 58 | "refactor": "patch", 59 | "perf": "patch", 60 | "test": "patch", 61 | "build": "patch", 62 | "ci": "patch", 63 | "chore": "patch", 64 | "revert": "patch" 65 | }, 66 | "validate-commit-msg": { 67 | "types": "conventional-commit-types" 68 | } 69 | }, 70 | "release": { 71 | "verifyConditions": "condition-circle", 72 | "analyzeCommits": "@bubltechnology/customizable-commit-analyzer" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CHANGED 2 | ============= 3 | 4 | [![Greenkeeper badge](https://badges.greenkeeper.io/fullcube/loopback-ds-changed-mixin.svg)](https://greenkeeper.io/) 5 | 6 | [![Circle CI](https://circleci.com/gh/fullcube/loopback-ds-changed-mixin.svg?style=svg)](https://circleci.com/gh/fullcube/loopback-ds-changed-mixin) [![Coverage Status](https://coveralls.io/repos/github/fullcube/loopback-ds-changed-mixin/badge.svg?branch=master)](https://coveralls.io/github/fullcube/loopback-ds-changed-mixin?branch=master) [![Dependencies](http://img.shields.io/david/fullcube/loopback-ds-changed-mixin.svg?style=flat)](https://david-dm.org/fullcube/loopback-ds-changed-mixin) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 7 | 8 | This module is designed for the [Strongloop Loopback](https://github.com/strongloop/loopback) framework. 9 | It provides a mixin that makes it possible to trigger a function if selected 10 | model properties change. 11 | 12 | The property value is an object that details model properties to be 13 | watched as well as the callback function to be trigged. 14 | 15 | INSTALL 16 | ============= 17 | 18 | ```bash 19 | npm install --save loopback-ds-changed-mixin 20 | ``` 21 | 22 | SERVER CONFIG 23 | ============= 24 | Add the mixins property to your server/model-config.json: 25 | 26 | ``` 27 | { 28 | "_meta": { 29 | "sources": [ 30 | "loopback/common/models", 31 | "loopback/server/models", 32 | "../common/models", 33 | "./models" 34 | ], 35 | "mixins": [ 36 | "loopback/common/mixins", 37 | "../node_modules/loopback-ds-changed-mixin/lib", 38 | "../common/mixins" 39 | ] 40 | } 41 | } 42 | ``` 43 | 44 | MODEL CONFIG 45 | ============= 46 | 47 | To use with your Models add the `mixins` attribute to the definition object of 48 | your model config. 49 | 50 | ```json 51 | { 52 | "name": "Widget", 53 | "properties": { 54 | "name": "String", 55 | "description": "String", 56 | "status": "String" 57 | }, 58 | "mixins": { 59 | "Changed": { 60 | "properties": { 61 | "name": "changeName", 62 | "status": "changeStatus", 63 | } 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | In the above, if the status property is modified on one of more model instances, the function that is defined at the 70 | property will be executed with a ChangeSet as a parameter. 71 | 72 | 73 | ```javascript 74 | 75 | // The ChangeSet object passed here has several helper methods 76 | function changeName(changeSet) { 77 | 78 | // Return an array of all the ID's in this change set 79 | changeSet.getIdList(); 80 | 81 | // Return an object with all the ID's and their new values 82 | changeSet.getIds(); 83 | 84 | // Return the value for a given ID 85 | changeSet.getId(id); 86 | 87 | // Return an array of all the unique values in this change set 88 | changeSet.getValueList(); 89 | 90 | // Return an object with all the Values and an array of the ID's changed to this value 91 | changeSet.getValues(); 92 | 93 | // Return an array of all the ID's changed to a given value 94 | changeSet.getValue(value); 95 | 96 | // The raw data is available as well 97 | changeSet.ids; 98 | changeSet.values; 99 | 100 | } 101 | 102 | ``` 103 | 104 | 105 | OPTIONS 106 | ============= 107 | 108 | The specific fields that are to be marked as changed can be set by passing an 109 | object to the mixin options. 110 | 111 | In this example we mark the `status` and `productId` properties for change notifications. The value of each property 112 | defined here is the name of the callback method that is invoked when changes on that property is detected. 113 | 114 | ```json 115 | { 116 | "name": "Widget", 117 | "properties": { 118 | "name": "String", 119 | "description": "String", 120 | "status": "String" 121 | }, 122 | "mixins": { 123 | "Changed": { 124 | "properties": { 125 | "name": "changeName", 126 | "status": "changeStatus", 127 | } 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | You can selectively skip the changed mixin behavior in calls to loopback update methods by setting the 134 | `skipChange` option. This can be a boolean to skip the behavior on all properties, a string to skip the behavior 135 | for a single property, or an array or object to skip the behavior for multiple properties. 136 | 137 | ```javascript 138 | instance.save({skipChanged: true}); // skip behavior 139 | instance.save({skipChanged: 'name'}); // skip behavior for the properties. 140 | instance.save({skipChanged: ['name', 'status']}); // skip behavior for name and status properties. 141 | instance.save({skipChanged: {name: true, status: true}}); // skip behavior for name and status properties. 142 | ``` 143 | 144 | 145 | TESTING 146 | ============= 147 | 148 | Run the tests in `test.js` 149 | 150 | ```bash 151 | npm test 152 | ``` 153 | 154 | Run with debugging output on: 155 | 156 | ```bash 157 | DEBUG='loopback-ds-changed-mixin' npm test 158 | ``` 159 | 160 | Run the test with a mongodb datasource 161 | ```bash 162 | MONGODB_URL=mongodb://localhost/ds_changed_mixin npm test 163 | ``` 164 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const lt = require('loopback-testing') 2 | const chai = require('chai') 3 | const { expect } = chai 4 | 5 | chai.use(require('dirty-chai')) 6 | chai.use(require('sinon-chai')) 7 | require('mocha-sinon') 8 | 9 | // Create a new loopback app. 10 | const app = require('./fixtures/simple-app/server/server.js') 11 | 12 | describe('loopback datasource changed property', function() { 13 | beforeEach(function() { 14 | // Set up some spies so we can check whether our callbacks have been called. 15 | this.spyAge = this.sinon.spy(app.models.Person, 'changeAge') 16 | this.spyStatus = this.sinon.spy(app.models.Person, 'changeStatus') 17 | this.spyName = this.sinon.spy(app.models.Person, 'changeName') 18 | }) 19 | 20 | lt.beforeEach.withApp(app) 21 | 22 | describe('when called internally', function() { 23 | lt.beforeEach.givenModel('Person', 24 | { title: 'Mr', name: 'Joe Blogs', nickname: 'joe', age: 21, status: 'active' }, 'joe') 25 | lt.beforeEach.givenModel('Person', 26 | { title: 'Mr', name: 'Bilbo Baggins', nickname: 'bilbo', age: 99, status: 'active' }, 'bilbo') 27 | lt.beforeEach.givenModel('Person', 28 | { title: 'Miss', name: 'Tina Turner', nickname: 'tina', age: 80, status: 'pending' }, 'tina') 29 | 30 | describe('Model.create', function() { 31 | it('should not run callback when creating new instances.', function(done) { 32 | const self = this 33 | 34 | expect(self.spyAge).not.to.have.been.called() 35 | expect(self.spyStatus).not.to.have.been.called() 36 | expect(self.spyName).not.to.have.been.called() 37 | done() 38 | }) 39 | }) 40 | 41 | describe('Model.updateAttribute', function() { 42 | it('should not run callback if no watched properties are updated', function() { 43 | return this.joe.updateAttribute('title', 'Newtitle') 44 | .then(() => { 45 | expect(this.spyAge).not.to.have.been.called() 46 | expect(this.spyStatus).not.to.have.been.called() 47 | expect(this.spyName).not.to.have.been.called() 48 | }) 49 | }) 50 | 51 | it('should not run any callbacks if no the skipChanged option was set to true', function() { 52 | return this.joe.updateAttribute('name', 'NewName', { skipChanged: true }) 53 | .then(() => { 54 | expect(this.spyAge).not.to.have.been.called() 55 | expect(this.spyStatus).not.to.have.been.called() 56 | expect(this.spyName).not.to.have.been.called() 57 | }) 58 | }) 59 | 60 | it('should not run specific callbacks that are marked to be skipped in skipChanged', function() { 61 | this.joe.updateAttribute('name', 'NewName', { skipChanged: [ 'name' ] }) 62 | .then(() => { 63 | expect(this.spyAge).not.to.have.been.called() 64 | expect(this.spyStatus).not.to.have.been.called() 65 | expect(this.spyName).not.to.have.been.called() 66 | }) 67 | }) 68 | 69 | it('should run the callback after updating a watched property', function() { 70 | this.joe.updateAttribute('age', 22) 71 | .then(res => { 72 | expect(res.age).to.equal(22) 73 | expect(this.spyAge).to.have.been.called() 74 | expect(this.spyStatus).not.to.have.been.called() 75 | expect(this.spyName).not.to.have.been.called() 76 | }) 77 | }) 78 | }) 79 | 80 | describe('Model.updateAttributes', function() { 81 | it('should not run callback if no watched properties are updated', function() { 82 | return this.joe.updateAttributes({ 'title': 'Newtitle' }) 83 | .then(() => { 84 | expect(this.spyAge).not.to.have.been.called() 85 | expect(this.spyStatus).not.to.have.been.called() 86 | expect(this.spyName).not.to.have.been.called() 87 | }) 88 | }) 89 | 90 | it('should not run any callbacks if no the skipChanged option was set to true', function() { 91 | return this.joe.updateAttributes({ 'name': 'NewName' }, { skipChanged: true }) 92 | .then(() => { 93 | expect(this.spyAge).not.to.have.been.called() 94 | expect(this.spyStatus).not.to.have.been.called() 95 | expect(this.spyName).not.to.have.been.called() 96 | }) 97 | }) 98 | 99 | it('should not run specific callbacks that are marked to be skipped in skipChanged', function() { 100 | return this.joe.updateAttributes({ 'name': 'NewName', 'status': 'test' }, { skipChanged: 'name' }) 101 | .then(() => { 102 | expect(this.spyAge).not.to.have.been.called() 103 | expect(this.spyStatus).to.have.been.called() 104 | expect(this.spyName).not.to.have.been.called() 105 | }) 106 | }) 107 | 108 | it('should execute the callback after updating watched properties', function() { 109 | return this.joe.updateAttributes({ 'age': 22, nickname: 'somename' }) 110 | .then(res => { 111 | expect(res.age).to.equal(22) 112 | expect(res.nickname).to.equal('somename') 113 | expect(this.spyAge).to.have.been.called() 114 | expect(this.spyStatus).not.to.have.been.called() 115 | expect(this.spyName).to.have.been.called() 116 | }) 117 | }) 118 | }) 119 | 120 | describe('Model.save', function() { 121 | it('should not run callback if no watched properties are updated', function() { 122 | this.joe.title = 'Newtitle' 123 | return this.joe.save() 124 | .then(() => { 125 | expect(this.spyAge).not.to.have.been.called() 126 | expect(this.spyStatus).not.to.have.been.called() 127 | expect(this.spyName).not.to.have.been.called() 128 | }) 129 | }) 130 | 131 | it('should not run any callbacks if no the skipChanged option was set to true', function() { 132 | this.joe.name = 'NewName' 133 | return this.joe.save({ skipChanged: true }) 134 | .then(() => { 135 | expect(this.spyAge).not.to.have.been.called() 136 | expect(this.spyStatus).not.to.have.been.called() 137 | expect(this.spyName).not.to.have.been.called() 138 | }) 139 | }) 140 | 141 | it('should not run specific callbacks that are marked to be skipped in skipChanged', function() { 142 | this.joe.name = 'NewName' 143 | this.joe.status = 'test' 144 | return this.joe.save({ skipChanged: 'name' }) 145 | .then(() => { 146 | expect(this.spyAge).not.to.have.been.called() 147 | expect(this.spyName).not.to.have.been.called() 148 | expect(this.spyStatus).to.have.been.called() 149 | }) 150 | }) 151 | 152 | it('should execute 1 callback after updating 1 watched property', function() { 153 | this.joe.age = 22 154 | return this.joe.save() 155 | .then(() => { 156 | expect(this.spyAge).to.have.been.called() 157 | expect(this.spyStatus).not.to.have.been.called() 158 | expect(this.spyName).not.to.have.been.called() 159 | }) 160 | }) 161 | 162 | it('should execute 2 callbacks after updating 2 watched properties', function() { 163 | this.joe.age = 22 164 | this.joe.nickname = 'somename' 165 | this.joe.save() 166 | .then(() => { 167 | expect(this.spyAge).to.have.been.called() 168 | expect(this.spyStatus).not.to.have.been.called() 169 | expect(this.spyName).to.have.been.called() 170 | }) 171 | }) 172 | }) 173 | 174 | describe('Model.upsert', function() { 175 | it('should not run callback if no watched properties are updated', function() { 176 | this.joe.title = 'Newtitle' 177 | return app.models.Person.upsert(this.joe) 178 | .then(() => { 179 | expect(this.spyAge).not.to.have.been.called() 180 | expect(this.spyStatus).not.to.have.been.called() 181 | expect(this.spyName).not.to.have.been.called() 182 | }) 183 | }) 184 | 185 | it('should not run any callbacks if no the skipChanged option was set to true', function() { 186 | this.joe.name = 'NewName' 187 | return app.models.Person.upsert(this.joe, { skipChanged: true }) 188 | .then(() => { 189 | expect(this.spyAge).not.to.have.been.called() 190 | expect(this.spyStatus).not.to.have.been.called() 191 | expect(this.spyName).not.to.have.been.called() 192 | }) 193 | }) 194 | 195 | it('should not run specific callbacks that are marked to be skipped in skipChanged', function() { 196 | this.joe.name = 'NewName' 197 | this.joe.status = 'test' 198 | return app.models.Person.upsert(this.joe, { skipChanged: 'name' }) 199 | .then(() => { 200 | expect(this.spyAge).not.to.have.been.called() 201 | expect(this.spyName).not.to.have.been.called() 202 | expect(this.spyStatus).to.have.been.called() 203 | }) 204 | }) 205 | 206 | it('should execute 1 callback after updating 1 watched property', function() { 207 | this.joe.status = 'pending' 208 | return app.models.Person.upsert(this.joe) 209 | .then(() => { 210 | expect(this.spyAge).not.to.have.been.called() 211 | expect(this.spyStatus).to.have.been.called() 212 | expect(this.spyName).not.to.have.been.called() 213 | }) 214 | }) 215 | 216 | it('should execute 2 callbacks after updating 2 watched properties', function() { 217 | this.joe.status = 'pending' 218 | this.joe.age = '23' 219 | return app.models.Person.upsert(this.joe) 220 | .then(() => { 221 | expect(this.spyAge).to.have.been.called() 222 | expect(this.spyStatus).to.have.been.called() 223 | expect(this.spyName).not.to.have.been.called() 224 | }) 225 | }) 226 | }) 227 | 228 | describe('Model.updateAll', function() { 229 | it('should not run callback if no watched properties are updated', function() { 230 | return app.models.Person.updateAll(null, { title: 'Newtitle' }) 231 | .then(() => { 232 | expect(this.spyAge).not.to.have.been.called() 233 | expect(this.spyStatus).not.to.have.been.called() 234 | expect(this.spyName).not.to.have.been.called() 235 | }) 236 | }) 237 | 238 | it('should not run any callbacks if no the skipChanged option was set to true', function() { 239 | return app.models.Person.updateAll(null, { name: 'NewName' }, { skipChanged: true }) 240 | .then(() => { 241 | expect(this.spyAge).not.to.have.been.called() 242 | expect(this.spyStatus).not.to.have.been.called() 243 | expect(this.spyName).not.to.have.been.called() 244 | }) 245 | }) 246 | 247 | it('should not run specific callbacks that are marked to be skipped in skipChanged', function() { 248 | return app.models.Person.updateAll(null, { name: 'NewName', status: 'test' }, { skipChanged: 'name' }) 249 | .then(() => { 250 | expect(this.spyAge).not.to.have.been.called() 251 | expect(this.spyName).not.to.have.been.called() 252 | expect(this.spyStatus).to.have.been.called() 253 | }) 254 | }) 255 | 256 | it('should execute 1 callback after updating 1 watched propertie on multiple models', function() { 257 | return app.models.Person.updateAll(null, { status: 'pending', title: 'Newtitle' }) 258 | .then(() => { 259 | expect(this.spyAge).not.to.have.been.called() 260 | expect(this.spyStatus).to.have.been.called() 261 | expect(this.spyName).not.to.have.been.called() 262 | }) 263 | }) 264 | 265 | it('should execute 2 callbacks after updating 2 watched properties on multiple models', function() { 266 | return app.models.Person.updateAll(null, { status: 'pending', age: '23' }) 267 | .then(() => { 268 | expect(this.spyAge).to.have.been.called() 269 | expect(this.spyStatus).to.have.been.called() 270 | expect(this.spyName).not.to.have.been.called() 271 | }) 272 | }) 273 | }) 274 | 275 | }) 276 | }) 277 | -------------------------------------------------------------------------------- /lib/changed.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | 3 | 'use strict' 4 | 5 | const debug = require('debug')('loopback-ds-changed-mixin') 6 | const utils = require('loopback-datasource-juggler/lib/utils') 7 | const _ = require('lodash') 8 | const async = require('async') 9 | const util = require('util') 10 | 11 | module.exports = function changedMixin(Model, options) { 12 | // Trigger a warning and remove the property from the watchlist when one of 13 | // the property is not found on the model or the defined callback is not found 14 | _.mapKeys(options.properties, (callback, property) => { 15 | if (_.isUndefined(Model.definition.properties[property])) { 16 | debug('Property %s on %s is undefined', property, Model.modelName) 17 | } 18 | 19 | if (typeof Model[callback] !== 'function') { 20 | debug('Callback %s for %s is not a model function', callback, property) 21 | } 22 | }) 23 | 24 | debug('Changed mixin for Model %s with properties', Model.modelName, options.properties) 25 | 26 | // This is the structure that we want to return 27 | function ChangeSet(changeset) { 28 | this.ids = changeset.ids 29 | this.values = changeset.values 30 | } 31 | ChangeSet.prototype.getIdList = function getIdList() { 32 | return Object.keys(this.ids) 33 | } 34 | ChangeSet.prototype.getIds = function getIds() { 35 | return this.ids 36 | } 37 | ChangeSet.prototype.getId = function getId(id) { 38 | return this.ids[id] 39 | } 40 | ChangeSet.prototype.getValueList = function getValueList() { 41 | return Object.keys(this.values) 42 | } 43 | ChangeSet.prototype.getValues = function getValues() { 44 | return this.values 45 | } 46 | ChangeSet.prototype.getValue = function getValue(value) { 47 | return this.values[value] 48 | } 49 | 50 | /** 51 | * Helper method that converts the set of items to a set of property 52 | * In the future this structure should be used directly by the code 53 | * that detects the changes. 54 | * 55 | * Input: 56 | * 57 | * { 58 | * '5586c51948fb091e068f80f4': { 59 | * status: 'pending' 60 | * }, 61 | * '5586c51948fb091e068f80f5': { 62 | * status: 'pending' 63 | * } 64 | * } 65 | * 66 | * Output: 67 | * 68 | * { 69 | * status: { 70 | * ids: { 71 | * '5586c58848fb091e068f8115': 'pending', 72 | * '5586c58848fb091e068f8116': 'pending' 73 | * }, 74 | * values: { 75 | * pending: [ 76 | * '5586c58848fb091e068f8115', 77 | * '5586c58848fb091e068f8116' 78 | * ] 79 | * } 80 | * } 81 | * } 82 | * 83 | * @returns {{}} 84 | */ 85 | function convertItemsToProperties(items) { 86 | 87 | // The object that contains the converted items 88 | const result = {} 89 | 90 | // Loop through all the inserted items 91 | _.mapKeys(items, (changes, itemId) => { 92 | 93 | // Loop through changes for this item 94 | _.mapKeys(changes, (value, property) => { 95 | 96 | // Add basic structure to hold ids and values 97 | if (_.isUndefined(result[property])) { 98 | result[property] = { ids: {}, values: {} } 99 | } 100 | 101 | // Create an array to hold ids for each value 102 | if (_.isUndefined(result[property].values[value])) { 103 | result[property].values[value] = [] 104 | } 105 | 106 | // Create an object { itemId: value } 107 | result[property].ids[itemId] = value 108 | 109 | // Enter itemId in the array for this value 110 | result[property].values[value].push(itemId) 111 | }) 112 | 113 | }) 114 | 115 | const changedProperties = {} 116 | 117 | _.mapKeys(result, (changeset, property) => { 118 | changedProperties[property] = new ChangeSet(changeset) 119 | }) 120 | 121 | return changedProperties 122 | } 123 | 124 | /** 125 | * Determine which properties are changed and store them in changedItems 126 | */ 127 | Model.observe('before save', (ctx, next) => { 128 | // Do nothing if a new instance if being created. 129 | if (ctx.instance && ctx.isNewInstance) { 130 | return next() 131 | } 132 | 133 | // Do nothing if the caller has chosen to skip the changed mixin. 134 | if (ctx.options && ctx.options.skipChanged && typeof ctx.options.skipChanged === 'boolean') { 135 | debug('skipping changed mixin') 136 | return next() 137 | } 138 | 139 | if (!ctx.hookState.changedItems) { 140 | ctx.hookState.changedItems = [] 141 | } 142 | 143 | const properties = _.keys(options.properties) 144 | 145 | debug('before save ctx.instance: %o', ctx.instance) 146 | debug('before save ctx.currentInstance: %o', ctx.currentInstance) 147 | debug('before save ctx.data: %o', ctx.data) 148 | debug('before save ctx.where: %o', ctx.where) 149 | 150 | if (ctx.currentInstance) { 151 | debug('Detected prototype.updateAttributes') 152 | ctx.hookState.changedItems = Model.getChangedProperties(ctx.currentInstance, ctx.data, properties) 153 | return next() 154 | } 155 | else if (ctx.instance) { 156 | debug('Working with existing instance %o', ctx.instance) 157 | // Figure out wether this item has changed properties. 158 | return ctx.instance.itemHasChangedProperties(ctx.instance, properties) 159 | .then(changed => (ctx.hookState.changedItems = changed)) 160 | } 161 | debug('anything else: upsert, updateAll') 162 | // Figure out which items have changed properties. 163 | return Model.itemsWithChangedProperties(ctx.where, ctx.data, properties) 164 | .then(changed => (ctx.hookState.changedItems = changed)) 165 | }) 166 | 167 | Model.observe('after save', (ctx, next) => { 168 | 169 | // Convert the changeItems to Properties 170 | if (ctx.hookState.changedItems && !_.isEmpty(ctx.hookState.changedItems)) { 171 | const properties = convertItemsToProperties(ctx.hookState.changedItems) 172 | 173 | debug('after save changedProperties %o', properties) 174 | 175 | return async.forEachOf(properties, (changeset, property, cb) => { 176 | const callback = options.properties[property] 177 | 178 | if (ctx.options && ctx.options.skipChanged && Model.shouldSkipProperty(property, ctx.options.skipChanged)) { 179 | debug('skipping changed callback for', property) 180 | return cb() 181 | } 182 | if (typeof Model[callback] !== 'function') { 183 | return cb(new Error(util.format('Function %s not found on Model', callback))) 184 | } 185 | debug('after save: invoke %s with %o', callback, changeset) 186 | return Model[callback](changeset).then(() => cb()).catch(cb) 187 | }, err => { 188 | if (err) { 189 | console.error(err) 190 | } 191 | return next() 192 | }) 193 | } 194 | return next() 195 | }) 196 | 197 | Model.shouldSkipProperty = function shouldSkipProperty(property, skipChanged) { 198 | if (typeof skipChanged === 'boolean') { 199 | return skipChanged 200 | } 201 | else if (typeof skipChanged === 'string') { 202 | return property === skipChanged 203 | } 204 | else if (Array.isArray(skipChanged)) { 205 | return _.includes(skipChanged, property) 206 | } 207 | else if (typeof skipChanged === 'object') { 208 | return _.find(skipChanged, property, true) 209 | } 210 | return false 211 | } 212 | 213 | /** 214 | * Searches for items with properties that differ from a specific set. 215 | * 216 | * @param {Object} conditions Where clause detailing items to compare. 217 | * @param {Object} newVals New values. 218 | * @param {Object} properties Properties to compare with the found instances. 219 | * @param {Function} cb A Cllback function. 220 | * @returns {Array} Returns a list of Model instance Ids whose data differ from 221 | * that in the properties argument. 222 | */ 223 | Model.itemsWithChangedProperties = function itemsWithChangedProperties(conditions, newVals, properties, cb) { 224 | debug('itemsWithChangedProperties: Looking for items with changed properties...') 225 | debug('itemsWithChangedProperties: conditions is: %o', conditions) 226 | debug('itemsWithChangedProperties: newVals is: %o', newVals) 227 | debug('itemsWithChangedProperties: properties is 1 : %o', properties) 228 | cb = cb || utils.createPromiseCallback() 229 | 230 | conditions = conditions || {} 231 | newVals = typeof newVals.toJSON === 'function' ? newVals.toJSON() : newVals || {} 232 | properties = properties || {} 233 | 234 | const filterFields = [ 235 | Model.getIdName(), 236 | ] 237 | 238 | // Build up a list of property conditions to include in the query. 239 | let propertyConditions = { or: [] } 240 | 241 | _.forEach(newVals, (value, key) => { 242 | if (_.includes(properties, key)) { 243 | const fieldFilter = {} 244 | 245 | fieldFilter[key] = { 'neq': value } 246 | propertyConditions.or.push(fieldFilter) 247 | filterFields.push(key) 248 | } 249 | }) 250 | 251 | if (!propertyConditions.or.length) { 252 | propertyConditions = {} 253 | } 254 | 255 | debug('itemsWithChangedProperties: propertyConditions 1 : %o', propertyConditions) 256 | 257 | // If there are no property conditions, do nothing. 258 | if (_.isEmpty(propertyConditions)) { 259 | process.nextTick(() => { 260 | cb(null, false) 261 | }) 262 | return cb.promise 263 | } 264 | 265 | // Build the final filter. 266 | const filter = { 267 | fields: filterFields, 268 | where: { 269 | and: [ propertyConditions, conditions ], 270 | }, 271 | } 272 | 273 | debug('itemsWithChangedProperties: propertyConditions 2 : %o', propertyConditions) 274 | debug('itemsWithChangedProperties: filter Fields %o', filterFields) 275 | debug('itemsWithChangedProperties: conditions %o', conditions) 276 | debug('itemsWithChangedProperties: final filter %o', filter) 277 | 278 | Model.find(filter) 279 | .then(results => { 280 | 281 | debug('itemsWithChangedProperties: filter results %o', results) 282 | 283 | const changedProperties = {} 284 | 285 | results.forEach(oldVals => { 286 | debug('itemsWithChangedProperties: oldVals %o', oldVals) 287 | debug('itemsWithChangedProperties: newVals %o', newVals) 288 | 289 | // changedProperties[oldVals.id] = {}; 290 | 291 | const changed = {} 292 | 293 | properties.forEach(property => { 294 | debug('itemsWithChangedProperties: Matching property %s', property) 295 | 296 | if (typeof newVals[property] !== 'undefined') { 297 | const newVal = newVals[property] 298 | 299 | debug('itemsWithChangedProperties: - newVal %s : %s : ', property, newVal) 300 | 301 | if (!oldVals[property]) { 302 | changed[property] = newVal 303 | debug('itemsWithChangedProperties: - no oldVal %s : %s : ', property, newVal) 304 | } 305 | else if (!_.isEqual(oldVals[property], newVal)) { 306 | const oldVal = oldVals[property] 307 | 308 | debug('itemsWithChangedProperties: - oldVal %s : %s : ', property, oldVal) 309 | changed[property] = newVal 310 | } 311 | 312 | } 313 | }) 314 | 315 | debug('itemsWithChangedProperties: changed %o', changed) 316 | changedProperties[oldVals.id] = changed 317 | }) 318 | 319 | debug('itemsWithChangedProperties: changedProperties %o', changedProperties) 320 | cb(null, changedProperties) 321 | }).catch(cb) 322 | 323 | return cb.promise 324 | } 325 | 326 | /** 327 | * Compare self with data to see if specific properties have been altered. 328 | * 329 | * @param {Object} data Target object to compare with. 330 | * @param {Array} properties List of properties to be chacked. 331 | * @returns {Boolean} Returns true if the properties have been altered. 332 | */ 333 | Model.prototype.itemHasChangedProperties = function itemHasChangedProperties(data, properties, cb) { 334 | cb = cb || utils.createPromiseCallback() 335 | 336 | properties = properties || {} 337 | 338 | if (_.isEmpty(properties)) { 339 | process.nextTick(() => { 340 | cb(null, false) 341 | }) 342 | return cb.promise 343 | } 344 | 345 | Model.findById(this.getId()) 346 | .then(instance => { 347 | const changedProperties = Model.getChangedProperties(instance, data, properties) 348 | 349 | debug('itemHasChangedProperties: found supposedly changed items: %o', changedProperties) 350 | cb(null, changedProperties) 351 | }).catch(cb) 352 | 353 | return cb.promise 354 | } 355 | 356 | /** 357 | * Compare source and target objects to see if specific properties have 358 | * been altered. 359 | * 360 | * @param {Object} source Source object to compare against. 361 | * @param {Object} target Target object to compare with. 362 | * @param {Array} properties List of properties to be chacked. 363 | * @returns {Boolean} Returns true if the properties have been altered. 364 | */ 365 | Model.propertiesChanged = function propertiesChanged(source, target, properties) { 366 | debug('comparing source %o with target %o in properties %o', source, target, properties) 367 | 368 | let changed = false 369 | 370 | _.forEach(properties, key => { 371 | debug('checking property %s ', key) 372 | if (target[key]) { 373 | if (!source[key] || target[key] !== source[key]) { 374 | changed = true 375 | } 376 | } 377 | }) 378 | if (changed) { 379 | debug('propertiesChanged: properties were changed') 380 | } 381 | return changed 382 | } 383 | 384 | Model.getChangedProperties = function getChangedProperties(oldVals, newVals, properties) { 385 | debug('getChangedProperties: comparing oldVals %o with newVals %o in properties %o', oldVals, newVals, properties) 386 | 387 | const itemId = oldVals[Model.getIdName()] 388 | const changedProperties = {} 389 | 390 | changedProperties[itemId] = {} 391 | 392 | _.forEach(properties, key => { 393 | debug('getChangedProperties: - checking property %s ', key) 394 | 395 | if (newVals[key]) { 396 | const newVal = newVals[key] 397 | 398 | debug('getChangedProperties: - new value %s ', newVal) 399 | 400 | if (!oldVals[key] || !_.isEqual(oldVals[key], newVal)) { 401 | debug('getChangedProperties: - changed or new value: %s itemId: %s', newVal, itemId) 402 | 403 | changedProperties[itemId][key] = newVal 404 | } 405 | } 406 | }) 407 | if (!_.isEmpty(changedProperties[itemId])) { 408 | debug('getChangedProperties: Properties were changed %o', changedProperties) 409 | return changedProperties 410 | } 411 | return false 412 | } 413 | 414 | Model.extractChangedItemIds = function extractChangedItemIds(items) { 415 | return _.pluck(items, Model.getIdName()) 416 | } 417 | 418 | } 419 | --------------------------------------------------------------------------------