├── .npmignore ├── test ├── fixtures │ └── simple-app │ │ ├── common │ │ └── models │ │ │ ├── widget.js │ │ │ └── widget.json │ │ └── server │ │ ├── datasources.json │ │ ├── config.json │ │ ├── middleware.json │ │ ├── model-config.json │ │ └── server.js ├── test-index.js └── test.js ├── es6 ├── debug.js ├── index.js └── time-stamp.js ├── .gitignore ├── .eslintrc.json ├── .travis.yml ├── gulpfile.js ├── LICENSE ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/widget.js: -------------------------------------------------------------------------------- 1 | module.exports = function(Widget) { 2 | 3 | }; 4 | -------------------------------------------------------------------------------- /es6/debug.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | export default (name = 'time-stamp') => debug(`loopback:mixins:${name}`); 4 | -------------------------------------------------------------------------------- /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": "0.0.0.0", 4 | "port": 3000, 5 | "legacyExplorer": false 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .nyc_output 3 | npm-debug.log 4 | debug.js 5 | debug.js.map 6 | index.js 7 | index.js.map 8 | time-stamp.js 9 | time-stamp.js.map 10 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/middleware.json: -------------------------------------------------------------------------------- 1 | { 2 | "initial:before": { 3 | }, 4 | "initial": { 5 | }, 6 | "session": { 7 | }, 8 | "auth": { 9 | }, 10 | "parse": { 11 | }, 12 | "routes": { 13 | }, 14 | "files": { 15 | }, 16 | "final": { 17 | }, 18 | "final:after": { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": "airbnb-base", 7 | "ecmaFeatures": { 8 | "arrowFunctions": true, 9 | "defaultParams": true, 10 | "destructuring": true, 11 | "templateStrings": true 12 | }, 13 | "rules": { 14 | "no-param-reassign": 1 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /es6/index.js: -------------------------------------------------------------------------------- 1 | import { deprecate } from 'util'; 2 | import timestamp from './time-stamp'; 3 | 4 | export default deprecate((app) => { 5 | app.loopback.modelBuilder.mixins.define('TimeStamp', timestamp); 6 | }, 'DEPRECATED: Use mixinSources, see https://github.com/clarkbw/loopback-ds-timestamp-mixin#mixinsources'); 7 | 8 | module.exports = exports.default; 9 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/common/models/widget.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Widget", 3 | "base": "PersistedModel", 4 | "idInjection": true, 5 | "options": { 6 | "validateUpsert": true 7 | }, 8 | "properties": { 9 | "name": { 10 | "type": "string", 11 | "required": false 12 | }, 13 | "type": { 14 | "type": "string", 15 | "required": false 16 | } 17 | }, 18 | "mixins": { 19 | "TimeStamp" : true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "6" 5 | - "5" 6 | - "4" 7 | after_success: npm run coverage 8 | deploy: 9 | provider: npm 10 | email: clarkbw@gmail.com 11 | api_key: 12 | secure: uv53tKTWDrK0EPRcYhZcLqOiuhBONXI9sU0GhLS6Q7JaUfGyk0EnVJxsAqOV1OmCd1vgkIbp0fc78gYmtgo1C0Anbx1qNW9RgLdZEnrC6v3+sLbHxapGF2k0xBFQc5qnDJ+wuHl9bQD8ymoy0TNYNwvvit9zc1odMtpd8nqOtSc= 13 | on: 14 | tags: true 15 | repo: clarkbw/loopback-ds-timestamp-mixin 16 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/model-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "sources": [ 4 | "../common/models" 5 | ], 6 | "mixins": [ 7 | "../../../../" 8 | ] 9 | }, 10 | "User": { 11 | "dataSource": "db" 12 | }, 13 | "AccessToken": { 14 | "dataSource": "db", 15 | "public": false 16 | }, 17 | "ACL": { 18 | "dataSource": "db", 19 | "public": false 20 | }, 21 | "RoleMapping": { 22 | "dataSource": "db", 23 | "public": false 24 | }, 25 | "Role": { 26 | "dataSource": "db", 27 | "public": false 28 | }, 29 | "Widget": { 30 | "dataSource": "db", 31 | "public": false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/fixtures/simple-app/server/server.js: -------------------------------------------------------------------------------- 1 | var loopback = require('loopback'); 2 | var boot = require('loopback-boot'); 3 | 4 | var app = module.exports = loopback(); 5 | 6 | app.start = function() { 7 | // start the web server 8 | return app.listen(function() { 9 | app.emit('started'); 10 | console.log('Web server listening at: %s', app.get('url')); 11 | }); 12 | }; 13 | 14 | // Bootstrap the application, configure models, datasources and middleware. 15 | // Sub-apps like REST API are mounted via boot scripts. 16 | boot(app, __dirname, function(err) { 17 | if (err) throw err; 18 | 19 | // start the server if `$ node server.js` 20 | if (require.main === module) 21 | app.start(); 22 | }); 23 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var sourcemaps = require('gulp-sourcemaps'); 3 | var babel = require('gulp-babel'); 4 | 5 | var path = require('path'); 6 | 7 | var paths = { 8 | es6: ['es6/*.js'], 9 | es5: '.', 10 | // Must be absolute or relative to source map 11 | sourceRoot: path.join(__dirname, 'es6'), 12 | }; 13 | 14 | gulp.task('babel', function() { 15 | return gulp.src(paths.es6) 16 | .pipe(sourcemaps.init()) 17 | .pipe(babel({ 18 | plugins: ['transform-object-assign'] 19 | })) 20 | .pipe(sourcemaps.write('.', { sourceRoot: paths.sourceRoot })) 21 | .pipe(gulp.dest(paths.es5)); 22 | }); 23 | 24 | gulp.task('watch', function() { 25 | gulp.watch(paths.es6, ['babel']); 26 | }); 27 | 28 | // gulp.task('default', ['watch']); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2015 Bryan W Clark 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback-ds-timestamp-mixin", 3 | "version": "3.4.1", 4 | "description": "A mixin to automatically generate created and updated Date attributes for loopback Models", 5 | "main": "index.js", 6 | "scripts": { 7 | "preversion": "npm test", 8 | "pretest": "eslint ./es6/*.js && gulp babel", 9 | "test": "nyc tap ./test/*.js", 10 | "watch": "gulp", 11 | "coverage": "nyc report --reporter=text-lcov | coveralls", 12 | "outdated": "npm outdated --depth=0" 13 | }, 14 | "keywords": [ 15 | "loopback", 16 | "strongloop", 17 | "mixin", 18 | "timestamp" 19 | ], 20 | "author": "Bryan Clark", 21 | "license": "ISC", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/clarkbw/loopback-ds-timestamp-mixin.git" 25 | }, 26 | "babel": { 27 | "presets": [ 28 | "es2015" 29 | ], 30 | "plugins": [ 31 | "transform-object-assign", 32 | "transform-es2015-modules-commonjs" 33 | ] 34 | }, 35 | "dependencies": { 36 | "debug": "^2.6.1" 37 | }, 38 | "devDependencies": { 39 | "babel-core": "^6.23.1", 40 | "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0", 41 | "babel-plugin-transform-object-assign": "^6.22.0", 42 | "babel-preset-es2015": "^6.22.0", 43 | "coveralls": "^2.11.16", 44 | "eslint": "^3.16.1", 45 | "eslint-config-airbnb-base": "^11.0.0", 46 | "eslint-plugin-import": "^2.0.1", 47 | "gulp": "latest", 48 | "gulp-babel": "^6.1.2", 49 | "gulp-sourcemaps": "^2.4.1", 50 | "loopback": "=3.4.0", 51 | "loopback-boot": "=2.23.0", 52 | "loopback-datasource-juggler": "=3.1.1", 53 | "nyc": "^10.1.2", 54 | "tap": "^10.2.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /es6/time-stamp.js: -------------------------------------------------------------------------------- 1 | import _debug from './debug'; 2 | 3 | const debug = _debug(); 4 | const warn = _debug(); // create a namespaced warning 5 | warn.log = console.warn.bind(console); // eslint-disable-line no-console 6 | 7 | export default (Model, bootOptions = {}) => { 8 | debug('TimeStamp mixin for Model %s', Model.modelName); 9 | 10 | const options = Object.assign({ 11 | createdAt: 'createdAt', 12 | updatedAt: 'updatedAt', 13 | required: true, 14 | validateUpsert: false, // default to turning validation off 15 | silenceWarnings: false, 16 | index: false, 17 | }, bootOptions); 18 | 19 | debug('options', options); 20 | 21 | // enable our warnings via the options 22 | warn.enabled = !options.silenceWarnings; 23 | 24 | if (!options.validateUpsert && Model.settings.validateUpsert) { 25 | Model.settings.validateUpsert = false; 26 | warn(`${Model.pluralModelName} settings.validateUpsert was overriden to false`); 27 | } 28 | 29 | if (Model.settings.validateUpsert && options.required) { 30 | warn(`Upserts for ${Model.pluralModelName} will fail when 31 | validation is turned on and time stamps are required`); 32 | } 33 | 34 | Model.defineProperty(options.createdAt, { 35 | type: Date, 36 | required: options.required, 37 | defaultFn: 'now', 38 | index: options.index, 39 | }); 40 | 41 | Model.defineProperty(options.updatedAt, { 42 | type: Date, 43 | required: options.required, 44 | index: options.index, 45 | }); 46 | 47 | Model.observe('before save', (ctx, next) => { 48 | debug('ctx.options', ctx.options); 49 | if (ctx.options && ctx.options.skipUpdatedAt) { 50 | return next(); 51 | } 52 | if (ctx.instance) { 53 | debug('%s.%s before save: %s', ctx.Model.modelName, options.updatedAt, ctx.instance.id); 54 | ctx.instance[options.updatedAt] = new Date(); 55 | } else { 56 | debug('%s.%s before update matching %j', 57 | ctx.Model.pluralModelName, options.updatedAt, ctx.where); 58 | ctx.data[options.updatedAt] = new Date(); 59 | } 60 | return next(); 61 | }); 62 | }; 63 | 64 | module.exports = exports.default; 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NPM](https://nodei.co/npm/loopback-ds-timestamp-mixin.png?compact=true)](https://nodei.co/npm/loopback-ds-timestamp-mixin/) 2 | 3 | [![dependencies](https://img.shields.io/david/clarkbw/loopback-ds-timestamp-mixin.svg)]() 4 | [![devDependencies](https://img.shields.io/david/dev/clarkbw/loopback-ds-timestamp-mixin.svg)]() 5 | [![Build Status](https://travis-ci.org/clarkbw/loopback-ds-timestamp-mixin.svg?branch=master)](https://travis-ci.org/clarkbw/loopback-ds-timestamp-mixin) 6 | [![Coverage Status](https://coveralls.io/repos/clarkbw/loopback-ds-timestamp-mixin/badge.svg)](https://coveralls.io/r/clarkbw/loopback-ds-timestamp-mixin) 7 | 8 | TIMESTAMPS 9 | ============= 10 | 11 | This module is designed for the [Strongloop Loopback](https://github.com/strongloop/loopback) framework. It automatically adds `createdAt` and `updatedAt` attributes to any Model. 12 | 13 | `createdAt` will be set to the current Date the by using the default property of the attribute. 14 | 15 | `updatedAt` will be set for every update of an object through bulk `updateAll` or instance `model.save` methods. 16 | 17 | This module is implemented with the `before save` [Operation Hook](http://docs.strongloop.com/display/public/LB/Operation+hooks#Operationhooks-beforesave) which requires the loopback-datasource-juggler module greater than [v2.23.0](strongloop/loopback-datasource-juggler@0002aaedeffadda34ae03752d03d0805ab661665). 18 | 19 | INSTALL 20 | ============= 21 | 22 | ```bash 23 | npm i loopback-ds-timestamp-mixin --save 24 | ``` 25 | 26 | UPSERT ISSUES 27 | ============= 28 | 29 | With version 2.33.2 of this module the [upsert validation was turned off](https://github.com/clarkbw/loopback-ds-timestamp-mixin/blob/master/es6/time-stamp.js#L16). This may create issues for your project if upsert validation is required. If you require upsert validation, set the `validateUpsert` option to true, however most upserts will fail unless you supply the `createdAt` and `updatedAt` fields or set `required` option to false. 30 | 31 | SERVER CONFIG 32 | ============= 33 | 34 | Add the `mixins` property to your `server/model-config.json`: 35 | 36 | ```json 37 | { 38 | "_meta": { 39 | "sources": [ 40 | "loopback/common/models", 41 | "loopback/server/models", 42 | "../common/models", 43 | "./models" 44 | ], 45 | "mixins": [ 46 | "loopback/common/mixins", 47 | "../node_modules/loopback-ds-timestamp-mixin", 48 | "../common/mixins" 49 | ] 50 | } 51 | } 52 | ``` 53 | 54 | MODEL CONFIG 55 | ============= 56 | 57 | To use with your Models add the `mixins` attribute to the definition object of your model config. 58 | 59 | ```json 60 | { 61 | "name": "Widget", 62 | "properties": { 63 | "name": { 64 | "type": "string", 65 | } 66 | }, 67 | "mixins": { 68 | "TimeStamp" : true 69 | } 70 | } 71 | ``` 72 | 73 | MODEL OPTIONS 74 | ============= 75 | 76 | The attribute names `createdAt` and `updatedAt` are configurable. To use different values for the default attribute names add the following parameters to the mixin options. 77 | 78 | You can also configure whether `createdAt` and `updatedAt` are required or not. This can be useful when applying this mixin to existing data where the `required` constraint would fail by default. 79 | 80 | By setting the `validateUpsert` option to true you will prevent this mixin from overriding the default Model settings. With validation turned on most upsert operations will fail with validation errors about missing the required fields like `createdAt` or `updatedAt`. 81 | 82 | This mixin uses console logs to warn you whenever something might need your attention. If you would prefer not to receive these warnings, you can disable them by setting the option `silenceWarnings` to `true` on a per model basis. 83 | 84 | In this example we change `createdAt` and `updatedAt` to `createdOn` and `updatedOn`, respectively. We also change the default `required` to `false` and set `validateUpsert` to true. We also disable console warnings with `silenceWarnings`. 85 | 86 | ```json 87 | { 88 | "name": "Widget", 89 | "properties": { 90 | "name": { 91 | "type": "string", 92 | } 93 | }, 94 | "mixins": { 95 | "TimeStamp" : { 96 | "createdAt" : "createdOn", 97 | "updatedAt" : "updatedOn", 98 | "required" : false, 99 | "validateUpsert": true, 100 | "silenceWarnings": true, 101 | "index": true 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | **NOTE for database MySQL and Postgres options** 108 | 109 | When you use database options for MySQL and the like beware that you may have to use the `columnName` value configured for the database instead of the loopback configured name. 110 | 111 | In the following example for the `Widget` object your `createdAt` field should equal the `columnName` which would be `created_at`. 112 | 113 | ```json 114 | { 115 | "name": "Widget", 116 | "properties": { 117 | "createdAt": { 118 | "type": "Date", 119 | "required": true, 120 | "length": null, 121 | "precision": null, 122 | "scale": null, 123 | "mysql": { 124 | "columnName": "created_at", 125 | "dataType": "datetime", 126 | "dataLength": null, 127 | "dataPrecision": null, 128 | "dataScale": null, 129 | "nullable": "N" 130 | } 131 | } 132 | } 133 | } 134 | ``` 135 | Thus the configuration looks like this for the above example. 136 | 137 | ```json 138 | { 139 | "name": "Widget", 140 | "properties": { 141 | "name": { 142 | "type": "string", 143 | } 144 | }, 145 | "mixins": { 146 | "TimeStamp" : { 147 | "createdAt" : "created_at" 148 | } 149 | } 150 | } 151 | ``` 152 | 153 | Please see [issue #19](clarkbw/loopback-ds-timestamp-mixin/issues/19) for more information on database options. 154 | 155 | OPERATION OPTIONS 156 | ============= 157 | 158 | By passing in additional options to an update or save operation you can control when this mixin updates the `updatedAt` field. The passing true to the option `skipUpdatedAt` will skip updating the `updatedAt` field. 159 | 160 | In this example we assume a book object with the id of 2 already exists. Normally running this operation would change the `updatedAt` field to a new value. 161 | 162 | ```js 163 | Book.updateOrCreate({name: 'New name', id: 2}, {skipUpdatedAt: true}, function(err, book) { 164 | // book.updatedAt will not have changed 165 | }); 166 | ``` 167 | 168 | DEVELOPMENT 169 | ============= 170 | 171 | This package is written in ES6 JavaScript, check out [@getify/You-Dont-Know-JS](https://github.com/getify/You-Dont-Know-JS) if you want to learn more about ES6. 172 | 173 | Source files are located in the [`es6`](https://github.com/clarkbw/loopback-ds-timestamp-mixin/tree/master/es6) directory. Edit the source files to make changes while running `gulp` in the background. Gulp is using [babel](https://babeljs.io/docs/setup/#gulp) to transform the es6 JavaScript into node compatible JavaScript. 174 | 175 | ```bash 176 | gulp 177 | ``` 178 | 179 | TESTING 180 | ============= 181 | 182 | For error checking and to help maintain style this package uses `eslint` as a pretest. All test are run against the transformed versions of files, not the es6 versions. 183 | 184 | Run the tests in the `test` directory. 185 | 186 | ```bash 187 | npm test 188 | ``` 189 | 190 | Run with debugging output on: 191 | 192 | ```bash 193 | DEBUG='loopback:mixins:time-stamp' npm test 194 | ``` 195 | 196 | LICENSE 197 | ============= 198 | [ISC](LICENSE) 199 | -------------------------------------------------------------------------------- /test/test-index.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test; 2 | 3 | var app = require('loopback'); 4 | 5 | // https://github.com/strongloop/loopback-boot/blob/master/lib/executor.js#L57-L71 6 | // the loopback-boot module patches in the loopback attribute so we can assume the same 7 | app.loopback = require('loopback'); 8 | 9 | var dataSource = app.createDataSource({ 10 | connector: app.Memory 11 | }); 12 | 13 | // import our TimeStamp mixin 14 | require('../')(app); 15 | 16 | test('loopback datasource timestamps', function(tap) { 17 | 'use strict'; 18 | 19 | tap.test('createdAt', function(t) { 20 | 21 | var Book = dataSource.createModel('Book', 22 | { name: String, type: String }, 23 | { mixins: { TimeStamp: true } } 24 | ); 25 | 26 | t.test('should exist on create', function(tt) { 27 | Book.destroyAll(function() { 28 | Book.create({name: 'book 1', type: 'fiction'}, function(err, book) { 29 | tt.error(err); 30 | tt.ok(book.createdAt); 31 | tt.type(book.createdAt, Date); 32 | tt.end(); 33 | }); 34 | }); 35 | }); 36 | 37 | t.test('should not change on save', function(tt) { 38 | Book.destroyAll(function() { 39 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 40 | tt.error(err); 41 | tt.ok(book.createdAt); 42 | book.name = 'book inf'; 43 | book.save(function(err, b) { 44 | tt.equal(book.createdAt, b.createdAt); 45 | tt.end(); 46 | }); 47 | }); 48 | }); 49 | }); 50 | 51 | t.test('should not change on update', function(tt) { 52 | Book.destroyAll(function() { 53 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 54 | tt.error(err); 55 | tt.ok(book.createdAt); 56 | book.updateAttributes({ name:'book inf' }, function(err, b) { 57 | tt.error(err); 58 | tt.equal(book.createdAt, b.createdAt); 59 | tt.end(); 60 | }); 61 | }); 62 | }); 63 | }); 64 | 65 | t.test('should not change on upsert', function(tt) { 66 | Book.destroyAll(function() { 67 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 68 | tt.error(err); 69 | tt.ok(book.createdAt); 70 | Book.upsert({id: book.id, name:'book inf'}, function(err, b) { 71 | tt.error(err); 72 | tt.equal(book.createdAt.getTime(), b.createdAt.getTime()); 73 | tt.end(); 74 | }); 75 | }); 76 | }); 77 | }); 78 | 79 | t.test('should not change with bulk updates', function(tt) { 80 | var createdAt; 81 | Book.destroyAll(function() { 82 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 83 | tt.error(err); 84 | tt.ok(book.createdAt); 85 | Book.updateAll({ type:'fiction' }, { type:'non-fiction' }, function(err) { 86 | tt.error(err); 87 | Book.findById(book.id, function(err, b) { 88 | tt.error(err); 89 | tt.equal(book.createdAt.getTime(), b.createdAt.getTime()); 90 | tt.end(); 91 | }); 92 | }); 93 | }); 94 | }); 95 | }); 96 | 97 | t.end(); 98 | 99 | }); 100 | 101 | tap.test('updatedAt', function(t) { 102 | 103 | var Book = dataSource.createModel('Book', 104 | { name: String, type: String }, 105 | { mixins: { TimeStamp: true } } 106 | ); 107 | 108 | t.test('should exist on create', function(tt) { 109 | Book.destroyAll(function() { 110 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 111 | tt.error(err); 112 | tt.ok(book.updatedAt); 113 | tt.type(book.updatedAt, Date); 114 | tt.end(); 115 | }); 116 | }); 117 | }); 118 | 119 | t.test('should be updated via updateAttributes', function(tt) { 120 | var updatedAt; 121 | Book.destroyAll(function() { 122 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 123 | tt.error(err); 124 | tt.ok(book.updatedAt); 125 | updatedAt = book.updatedAt; 126 | 127 | // ensure we give enough time for the updatedAt value to be different 128 | setTimeout(function pause() { 129 | book.updateAttributes({ type:'historical-fiction' }, function(err, b) { 130 | tt.error(err); 131 | tt.ok(b.updatedAt); 132 | tt.ok(b.updatedAt.getTime() > updatedAt.getTime()); 133 | tt.end(); 134 | }); 135 | }, 1); 136 | }); 137 | }); 138 | }); 139 | 140 | t.test('should update bulk model updates at once', function(tt) { 141 | var createdAt1, createdAt2, updatedAt1, updatedAt2; 142 | Book.destroyAll(function() { 143 | Book.create({name:'book 1', type:'fiction'}, function(err, book1) { 144 | tt.error(err); 145 | createdAt1 = book1.createdAt; 146 | updatedAt1 = book1.updatedAt; 147 | setTimeout(function pause1() { 148 | Book.create({name:'book 2', type:'fiction'}, function(err, book2) { 149 | tt.error(err); 150 | createdAt2 = book2.createdAt; 151 | updatedAt2 = book2.updatedAt; 152 | tt.ok(updatedAt2.getTime() > updatedAt1.getTime()); 153 | setTimeout(function pause2() { 154 | Book.updateAll({ type:'fiction' }, { type:'romance' }, function(err, count) { 155 | tt.error(err); 156 | tt.equal(createdAt1.getTime(), book1.createdAt.getTime()); 157 | tt.equal(createdAt2.getTime(), book2.createdAt.getTime()); 158 | Book.find({ type:'romance' }, function(err, books) { 159 | tt.error(err); 160 | tt.equal(books.length, 2); 161 | books.forEach(function(book) { 162 | // because both books were updated in the updateAll call 163 | // our updatedAt1 and updatedAt2 dates have to be less than the current 164 | tt.ok(updatedAt1.getTime() < book.updatedAt.getTime()); 165 | tt.ok(updatedAt2.getTime() < book.updatedAt.getTime()); 166 | }); 167 | tt.end(); 168 | }); 169 | }); 170 | }, 1); 171 | }); 172 | }, 1); 173 | }); 174 | }); 175 | }); 176 | 177 | t.end(); 178 | 179 | }); 180 | 181 | tap.test('boot options', function(t) { 182 | 183 | t.test('should use createdOn and updatedOn instead', function(tt) { 184 | var Book = dataSource.createModel('Book', 185 | { name: String, type: String }, 186 | { mixins: { TimeStamp: { createdAt:'createdOn', updatedAt:'updatedOn' } } } 187 | ); 188 | Book.destroyAll(function() { 189 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 190 | tt.error(err); 191 | 192 | tt.type(book.createdAt, 'undefined'); 193 | tt.type(book.updatedAt, 'undefined'); 194 | 195 | tt.ok(book.createdOn); 196 | tt.type(book.createdOn, Date); 197 | 198 | tt.ok(book.updatedOn); 199 | tt.type(book.updatedOn, Date); 200 | 201 | tt.end(); 202 | }); 203 | }); 204 | }); 205 | 206 | t.test('should default required on createdAt and updatedAt ', function(tt) { 207 | var Book = dataSource.createModel('Book', 208 | { name: String, type: String }, 209 | { mixins: { TimeStamp: true } } 210 | ); 211 | tt.equal(Book.definition.properties.createdAt.required, true); 212 | tt.equal(Book.definition.properties.updatedAt.required, true); 213 | tt.end(); 214 | }); 215 | 216 | t.test('should have optional createdAt and updatedAt', function(tt) { 217 | var Book = dataSource.createModel('Book', 218 | { name: String, type: String }, 219 | { mixins: { TimeStamp: { required: false } } } 220 | ); 221 | tt.equal(Book.definition.properties.createdAt.required, false); 222 | tt.equal(Book.definition.properties.updatedAt.required, false); 223 | tt.end(); 224 | }); 225 | 226 | t.end(); 227 | 228 | }); 229 | 230 | tap.test('operation hook options', function(t) { 231 | 232 | var Book = dataSource.createModel('Book', 233 | { name: String, type: String }, 234 | { mixins: { TimeStamp: true } } 235 | ); 236 | 237 | t.test('should skip changing updatedAt when option passed', function(tt) { 238 | Book.destroyAll(function() { 239 | Book.create({name:'book 1', type:'fiction'}, function(err, book1) { 240 | tt.error(err); 241 | 242 | tt.ok(book1.updatedAt); 243 | 244 | var book = {id: book1.id, name:'book 2'}; 245 | 246 | Book.updateOrCreate(book, {skipUpdatedAt: true}, function(err, book2) { 247 | tt.error(err); 248 | 249 | tt.ok(book2.updatedAt); 250 | tt.equal(book1.updatedAt.getTime(), book2.updatedAt.getTime()); 251 | tt.end(); 252 | }); 253 | 254 | }); 255 | }); 256 | }); 257 | 258 | t.end(); 259 | }); 260 | 261 | tap.end(); 262 | 263 | }); 264 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test; 2 | 3 | var path = require('path'); 4 | var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); 5 | var app = require(path.join(SIMPLE_APP, 'server/server.js')); 6 | 7 | test('loopback datasource timestamps', function(tap) { 8 | 'use strict'; 9 | 10 | var Widget = app.models.Widget; 11 | 12 | tap.test('createdAt', function(t) { 13 | 14 | t.test('should exist on create', function(tt) { 15 | Widget.destroyAll(function() { 16 | Widget.create({name: 'book 1', type: 'fiction'}, function(err, book) { 17 | tt.error(err); 18 | tt.type(book.createdAt, Date); 19 | tt.end(); 20 | }); 21 | }); 22 | }); 23 | 24 | t.test('should not change on save', function(tt) { 25 | Widget.destroyAll(function() { 26 | Widget.create({name:'book 1', type:'fiction'}, function(err, book) { 27 | tt.error(err); 28 | tt.type(book.createdAt, Date); 29 | book.name = 'book inf'; 30 | book.save(function(err, b) { 31 | tt.equal(book.createdAt, b.createdAt); 32 | tt.end(); 33 | }); 34 | }); 35 | }); 36 | }); 37 | 38 | t.test('should not change on update', function(tt) { 39 | Widget.destroyAll(function() { 40 | Widget.create({name:'book 1', type:'fiction'}, function(err, book) { 41 | tt.error(err); 42 | tt.type(book.createdAt, Date); 43 | book.updateAttributes({ name:'book inf' }, function(err, b) { 44 | tt.error(err); 45 | tt.equal(book.createdAt, b.createdAt); 46 | tt.end(); 47 | }); 48 | }); 49 | }); 50 | }); 51 | 52 | t.test('should not change on upsert', function(tt) { 53 | Widget.destroyAll(function() { 54 | Widget.create({name:'book 1', type:'fiction'}, function(err, book) { 55 | tt.error(err); 56 | tt.type(book.createdAt, Date); 57 | Widget.upsert({id: book.id, name:'book inf'}, function(err, b) { 58 | tt.error(err); 59 | tt.equal(book.createdAt.getTime(), b.createdAt.getTime()); 60 | tt.end(); 61 | }); 62 | }); 63 | }); 64 | }); 65 | 66 | t.test('should not change with bulk updates', function(tt) { 67 | var createdAt; 68 | Widget.destroyAll(function() { 69 | Widget.create({name:'book 1', type:'fiction'}, function(err, book) { 70 | tt.error(err); 71 | tt.type(book.createdAt, Date); 72 | Widget.updateAll({ type:'fiction' }, { type:'non-fiction' }, function(err) { 73 | tt.error(err); 74 | Widget.findById(book.id, function(err, b) { 75 | tt.error(err); 76 | tt.equal(book.createdAt.getTime(), b.createdAt.getTime()); 77 | tt.end(); 78 | }); 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | t.end(); 85 | 86 | }); 87 | 88 | tap.test('updatedAt', function(t) { 89 | 90 | t.test('should exist on create', function(tt) { 91 | Widget.destroyAll(function() { 92 | Widget.create({name:'book 1', type:'fiction'}, function(err, book) { 93 | tt.error(err); 94 | tt.type(book.updatedAt, Date); 95 | tt.end(); 96 | }); 97 | }); 98 | }); 99 | 100 | t.test('should be updated via updateAttributes', function(tt) { 101 | var updatedAt; 102 | Widget.destroyAll(function() { 103 | Widget.create({name:'book 1', type:'fiction'}, function(err, book) { 104 | tt.error(err); 105 | tt.type(book.createdAt, Date); 106 | updatedAt = book.updatedAt; 107 | 108 | // ensure we give enough time for the updatedAt value to be different 109 | setTimeout(function pause() { 110 | book.updateAttributes({ type:'historical-fiction' }, function(err, b) { 111 | tt.error(err); 112 | tt.type(b.createdAt, Date); 113 | tt.ok(b.updatedAt.getTime() > updatedAt.getTime()); 114 | tt.end(); 115 | }); 116 | }, 1); 117 | }); 118 | }); 119 | }); 120 | 121 | t.test('should update bulk model updates at once', function(tt) { 122 | var createdAt1, createdAt2, updatedAt1, updatedAt2; 123 | Widget.destroyAll(function() { 124 | Widget.create({name:'book 1', type:'fiction'}, function(err, book1) { 125 | tt.error(err); 126 | createdAt1 = book1.createdAt; 127 | updatedAt1 = book1.updatedAt; 128 | setTimeout(function pause1() { 129 | Widget.create({name:'book 2', type:'fiction'}, function(err, book2) { 130 | tt.error(err); 131 | createdAt2 = book2.createdAt; 132 | updatedAt2 = book2.updatedAt; 133 | tt.ok(updatedAt2.getTime() > updatedAt1.getTime()); 134 | setTimeout(function pause2() { 135 | Widget.updateAll({ type:'fiction' }, { type:'romance' }, function(err, count) { 136 | tt.error(err); 137 | tt.equal(createdAt1.getTime(), book1.createdAt.getTime()); 138 | tt.equal(createdAt2.getTime(), book2.createdAt.getTime()); 139 | Widget.find({ type:'romance' }, function(err, books) { 140 | tt.error(err); 141 | tt.equal(books.length, 2); 142 | books.forEach(function(book) { 143 | // because both books were updated in the updateAll call 144 | // our updatedAt1 and updatedAt2 dates have to be less than the current 145 | tt.ok(updatedAt1.getTime() < book.updatedAt.getTime()); 146 | tt.ok(updatedAt2.getTime() < book.updatedAt.getTime()); 147 | }); 148 | tt.end(); 149 | }); 150 | }); 151 | }, 1); 152 | }); 153 | }, 1); 154 | }); 155 | }); 156 | }); 157 | 158 | t.end(); 159 | 160 | }); 161 | 162 | tap.test('boot options', function(t) { 163 | 164 | var dataSource = app.models.Widget.getDataSource(); 165 | 166 | t.test('should use createdOn and updatedOn instead', function(tt) { 167 | 168 | var Book = dataSource.createModel('Book', 169 | { name: String, type: String }, 170 | { mixins: { TimeStamp: { createdAt:'createdOn', updatedAt:'updatedOn' } } } 171 | ); 172 | Book.destroyAll(function() { 173 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 174 | tt.error(err); 175 | 176 | tt.type(book.createdAt, 'undefined'); 177 | tt.type(book.updatedAt, 'undefined'); 178 | 179 | tt.type(book.createdOn, Date); 180 | tt.type(book.updatedOn, Date); 181 | 182 | tt.end(); 183 | }); 184 | }); 185 | }); 186 | 187 | t.test('should default required on createdAt and updatedAt ', function(tt) { 188 | var Book = dataSource.createModel('Book', 189 | { name: String, type: String }, 190 | { mixins: { TimeStamp: true } } 191 | ); 192 | tt.equal(Book.definition.properties.createdAt.required, true); 193 | tt.equal(Book.definition.properties.updatedAt.required, true); 194 | tt.end(); 195 | }); 196 | 197 | t.test('should have optional createdAt and updatedAt', function(tt) { 198 | var Book = dataSource.createModel('Book', 199 | { name: String, type: String }, 200 | { mixins: { TimeStamp: { required: false } } } 201 | ); 202 | tt.equal(Book.definition.properties.createdAt.required, false); 203 | tt.equal(Book.definition.properties.updatedAt.required, false); 204 | tt.end(); 205 | }); 206 | 207 | t.test('should turn on validation and upsert fails', function(tt) { 208 | 209 | var Book = dataSource.createModel('Book', 210 | { name: String, type: String }, 211 | { 212 | validateUpsert: true, // set this to true for the Model 213 | mixins: { TimeStamp: { validateUpsert: true } } 214 | } 215 | ); 216 | Book.destroyAll(function() { 217 | Book.create({name:'book 1', type:'fiction'}, function(err, book) { 218 | tt.error(err); 219 | // this upsert call should fail because we have turned on validation 220 | Book.updateOrCreate({id:book.id, type: 'historical-fiction'}, function(err) { 221 | tt.equal(err.name, 'ValidationError'); 222 | tt.equal(err.details.context, 'Book'); 223 | tt.ok(err.details.codes.createdAt.indexOf('presence') >= 0); 224 | tt.end(); 225 | }); 226 | }); 227 | }); 228 | }); 229 | 230 | t.end(); 231 | 232 | }); 233 | 234 | tap.test('operation hook options', function(t) { 235 | 236 | t.test('should skip changing updatedAt when option passed', function(tt) { 237 | Widget.destroyAll(function() { 238 | Widget.create({name:'book 1', type:'fiction'}, function(err, book1) { 239 | tt.error(err); 240 | 241 | tt.type(book1.updatedAt, Date); 242 | 243 | var book = {id: book1.id, name:'book 2'}; 244 | 245 | Widget.updateOrCreate(book, {skipUpdatedAt: true}, function(err, book2) { 246 | tt.error(err); 247 | 248 | tt.type(book2.updatedAt, Date); 249 | tt.equal(book1.updatedAt.getTime(), book2.updatedAt.getTime()); 250 | tt.end(); 251 | }); 252 | 253 | }); 254 | }); 255 | }); 256 | 257 | t.end(); 258 | 259 | }); 260 | 261 | tap.end(); 262 | 263 | }); 264 | --------------------------------------------------------------------------------