diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index b545e968..973d6739 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -723,6 +723,64 @@ Memory.prototype.updateAttributes = function updateAttributes(model, id, data, o } }; +Memory.prototype.replaceById = function(model, id, data, options, cb) { + var self = this; + if (!id) { + var err = new Error('You must provide an id when replacing!'); + return process.nextTick(function() { cb(err); }); + } + // Do not modify the data object passed in arguments + data = Object.create(data); + this.setIdValue(model, data, id); + var cachedModels = this.collection(model); + var modelData = cachedModels && this.collection(model)[id]; + if (!modelData) { + var msg = 'Could not replace. Object with id ' + id + ' does not exist!'; + return process.nextTick(function() { cb(new Error(msg)); }); + } + + var newModelData = {}; + for(var key in data) { + var val = data[key]; + if(typeof val === 'function') { + continue; // Skip methods + } + newModelData[key] = val; + } + + this.collection(model)[id] = serialize(newModelData); + this.saveToFile(newModelData, function (err) { + cb(err, self.fromDb(model, newModelData)); + }); +}; + +Memory.prototype.replaceOrCreate = function(model, data, options, callback) { + var self = this; + var idName = self.idNames(model)[0]; + var idValue = self.getIdValue(model, data); + var filter = {where: {}}; + filter.where[idName] = idValue; + var nodes = self._findAllSkippingIncludes(model, filter); + var found = nodes[0]; + + if (!found) { + // Calling _createSync to update the collection in a sync way and + // to guarantee to create it in the same turn of even loop + return self._createSync(model, data, function(err, id) { + if (err) return process.nextTick(function() { cb(err); }); + self.saveToFile(id, function(err, id) { + self.setIdValue(model, data, id); + callback(err, self.fromDb(model, data), { isNewInstance: true }); + }); + }); + } + var id = self.getIdValue(model, data); + self.collection(model)[id] = serialize(data); + self.saveToFile(data, function(err) { + callback(err, self.fromDb(model, data), {isNewInstance: false}); + }); +}; + Memory.prototype.transaction = function () { return new Memory(this); }; diff --git a/lib/dao.js b/lib/dao.js index bbc9bb71..b1137172 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -628,6 +628,188 @@ DataAccessObject.updateOrCreate = DataAccessObject.upsert = function upsert(data return cb.promise; }; +/** + * Replace or insert a model instance: replace exiting record if one is found, such that parameter `data.id` matches `id` of model instance; + * otherwise, insert a new record. + * + * @param {Object} data The model instance data + * @param {Object} [options] Options for replaceOrCreate + * @param {Function} cb The callback function (optional). + */ + +DataAccessObject.replaceOrCreate = function replaceOrCreate(data, options, cb) { + var connectionPromise = stillConnecting(this.getDataSource(), this, arguments); + if (connectionPromise) { + return connectionPromise; + } + + if (cb === undefined) { + if (typeof options === 'function') { + // replaceOrCreta(data,cb) + cb = options; + options = {}; + } + } + + cb = cb || utils.createPromiseCallback(); + data = data || {}; + options = options || {}; + + assert(typeof data === 'object', 'The data argument must be an object'); + assert(typeof options === 'object', 'The options argument must be an object'); + assert(typeof cb === 'function', 'The cb argument must be a function'); + + var hookState = {}; + + var self = this; + var Model = this; + var connector = Model.getConnector(); + + var id = getIdValue(this, data); + if (id === undefined || id === null) { + return this.create(data, options, cb); + } + + var inst; + if (data instanceof Model) { + inst = data; + } else { + inst = new Model(data); + } + + var strict = inst.__strict; + var context = { + Model: Model, + query: byIdQuery(Model, id), + hookState: hookState, + options: options + }; + Model.notifyObserversOf('access', context, doReplaceOrCreate); + + function doReplaceOrCreate(err, ctx) { + if (err) return cb(err); + + var isOriginalQuery = isWhereByGivenId(Model, ctx.query.where, id); + var where = ctx.query.where; + if (connector.replaceOrCreate && isOriginalQuery) { + var context = { + Model: Model, + instance: inst, + hookState: hookState, + options: options + }; + Model.notifyObserversOf('before save', context, function(err, ctx) { + if (err) return cb(err); + var update = inst.toObject(false); + if (strict) { + applyStrictCheck(Model, strict, update, inst, validateAndCallConnector); + } else { + validateAndCallConnector(); + } + + function validateAndCallConnector(err){ + if (err) return cb(err); + Model.applyProperties(update, inst); + Model = Model.lookupModel(update); + + var connector = self.getConnector(); + + if (options.validate === false) { + return callConnector(); + } + + // only when options.validate is not set, take model-setting into consideration + if (options.validate === undefined && Model.settings.automaticValidation === false) { + return callConnector(); + } + + inst.isValid(function(valid) { + if (!valid) return cb(new ValidationError(inst), inst); + callConnector(); + }, update); + + function callConnector() { + update = removeUndefined(update); + context = { + Model: Model, + where: where, + data: update, + currentInstance: inst, + hookState: ctx.hookState, + options: options + }; + Model.notifyObserversOf('persist', context, function(err) { + if (err) return done(err); + connector.replaceOrCreate(Model.modelName, context.data, options, done); + }); + } + function done(err, data, info) { + if (err) return cb(err); + var context = { + Model: Model, + data: data, + isNewInstance: info ? info.isNewInstance : undefined, + hookState: ctx.hookState, + options: options + }; + Model.notifyObserversOf('loaded', context, function(err) { + if (err) return cb(err); + + var obj; + if (data && !(data instanceof Model)) { + inst._initProperties(data, { persisted: true }); + obj = inst; + } else { + obj = data; + } + if (err) { + cb(err, obj); + } else { + var context = { + Model: Model, + instance: obj, + isNewInstance: info ? info.isNewInstance : undefined, + hookState: hookState, + options: options + }; + + Model.notifyObserversOf('after save', context, function(err) { + if (!err) Model.emit('changed', inst); + + cb(err, obj, info); + }); + } + }); + } + } + }); + } else { + var opts = {notify: false}; + if (ctx.options && ctx.options.transaction) { + opts.transaction = ctx.options.transaction; + } + Model.findOne({where: ctx.query.where}, opts, function (err, found){ + if (err) return cb(err); + if (!isOriginalQuery) { + // The custom query returned from a hook may hide the fact that + // there is already a model with `id` value `data[idName(Model)]` + var pkName = idName(Model); + delete data[pkName]; + if (found) id = found[pkName]; + } + if (found) { + self.replaceById(id, data, options, cb); + } else { + Model = self.lookupModel(data); + var obj = new Model(data); + obj.save(options, cb); + } + }); + } + } + return cb.promise; +}; + /** * Find one record that matches specified query criteria. Same as `find`, but limited to one record, and this function returns an * object, not a collection. @@ -2406,6 +2588,178 @@ DataAccessObject.prototype.unsetAttribute = function unsetAttribute(name, nullif } }; +/** + * Replace set of attributes. + * Performs validation before replacing. + * + * @trigger `validation`, `save` and `update` hooks + * @param {Object} data Data to replace + * @param {Object} [options] Options for replace + * @param {Function} cb Callback function called with (err, instance) + */ +DataAccessObject.prototype.replaceAttributes = function(data, options, cb) { + var Model = this.constructor; + var id = getIdValue(this.constructor, this); + return Model.replaceById(id, data, options, cb); +}; + +DataAccessObject.replaceById = function(id, data, options, cb) { + var connectionPromise = stillConnecting(this.getDataSource(), this, arguments); + if (connectionPromise) { + return connectionPromise; + } + + if (cb === undefined) { + if (typeof options === 'function') { + cb = options; + options = {}; + } + } + + cb = cb || utils.createPromiseCallback(); + options = options || {}; + + assert((typeof data === 'object') && (data !== null), + 'The data argument must be an object'); + assert(typeof options === 'object', 'The options argument must be an object'); + assert(typeof cb === 'function', 'The cb argument must be a function'); + + var connector = this.getConnector(); + assert(typeof connector.replaceById === 'function', + 'replaceById() must be implemented by the connector'); + + var pkName = idName(this); + if (!data[pkName]) data[pkName] = id; + + var Model = this; + var inst = new Model(data); + var enforced = {}; + this.applyProperties(enforced, inst); + inst.setAttributes(enforced); + Model = this.lookupModel(data); // data-specific + if (Model !== inst.constructor) inst = new Model(data); + var strict = inst.__strict; + + if (isPKMissing(Model, cb)) + return cb.promise; + + var model = Model.modelName; + var hookState = {}; + + if (id !== data[pkName]) { + var err = new Error('id property (' + pkName + ') ' + + 'cannot be updated from ' + inst[pkName] + ' to ' + data[pkName]); + err.statusCode = 400; + process.nextTick(function() { cb(err); }); + return cb.promise; + } + + var context = { + Model: Model, + instance: inst, + isNewInstance: false, + hookState: hookState, + options: options + }; + + Model.notifyObserversOf('before save', context, function(err, ctx) { + if (err) return cb(err); + + data = inst.toObject(false); + + if (strict) { + applyStrictCheck(Model, strict, data, inst, validateAndCallConnector); + } else { + validateAndCallConnector(null, data); + } + + function validateAndCallConnector(err, data) { + if (err) return cb(err); + data = removeUndefined(data); + // update instance's properties + inst.setAttributes(data); + + var doValidate = true; + if (options.validate === undefined) { + if (Model.settings.automaticValidation !== undefined) { + doValidate = Model.settings.automaticValidation; + } + } else { + doValidate = options.validate; + } + + if (doValidate){ + inst.isValid(function (valid) { + if (!valid) return cb(new ValidationError(inst), inst); + + callConnector(); + }, data); + } else { + callConnector(); + } + + function callConnector() { + var idNames = Model.definition.idNames(); + var propKeys = Object.keys(Model.definition.properties); + var nonIdsPropKeys = propKeys.filter(function(i) {return idNames.indexOf(i) < 0;}); + for (var i = 0; i < nonIdsPropKeys.length; i++) { + var p = nonIdsPropKeys[i]; + inst[p] = null; + } + copyData(data, inst); + var typedData = convertSubsetOfPropertiesByType(inst, data); + context.data = typedData; + + function replaceCallback(err, data) { + if (err) return cb(err); + + var ctx = { + Model: Model, + hookState: hookState, + data: context.data, + isNewInstance:false, + options: options + }; + Model.notifyObserversOf('loaded', ctx, function(err) { + if (err) return cb(err); + + inst.__persisted = true; + inst.setAttributes(ctx.data); + + var context = { + Model: Model, + instance: inst, + isNewInstance: false, + hookState: hookState, + options: options + }; + Model.notifyObserversOf('after save', context, function(err) { + if (!err) Model.emit('changed', inst); + + cb(err, inst); + }); + }); + } + + var ctx = { + Model: Model, + where: byIdQuery(Model, id).where, + data: context.data, + isNewInstance:false, + currentInstance: inst, + hookState: hookState, + options: options + }; + Model.notifyObserversOf('persist', ctx, function(err) { + connector.replaceById(model, id, + inst.constructor._forDB(context.data), options, replaceCallback); + }); + } + } + }); + return cb.promise; +}; + /** * Update set of attributes. * Performs validation before updating. diff --git a/test/manipulation.test.js b/test/manipulation.test.js index 4ff4143f..05d212ab 100644 --- a/test/manipulation.test.js +++ b/test/manipulation.test.js @@ -688,6 +688,311 @@ describe('manipulation', function () { }); }); + if (!getSchema().connector.replaceById) { + describe.skip('replaceById - not implemented', function(){}); + } else { + describe('replaceOrCreate', function() { + var Post; + var ds = getSchema(); + before(function() { + Post = ds.define('Post', { + title: { type: String, length: 255, index: true }, + content: { type: String }, + comments: [String] + }); + }); + + it('works without options on create (promise variant)', function(done) { + var post = {id: 123, title: 'a', content: 'AAA'}; + Post.replaceOrCreate(post) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Post); + p.id.should.be.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal(post.title); + p.content.should.equal(post.content); + return Post.findById(p.id) + .then(function (p) { + p.id.should.equal(post.id); + p.id.should.not.have.property('_id'); + p.title.should.equal(p.title); + p.content.should.equal(p.content); + done(); + }); + }) + .catch(done); + }); + + it('works with options on create (promise variant)', function(done) { + var post = {id: 123, title: 'a', content: 'AAA'}; + Post.replaceOrCreate(post, {validate: false}) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Post); + p.id.should.be.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal(post.title); + p.content.should.equal(post.content); + return Post.findById(p.id) + .then(function (p) { + p.id.should.equal(post.id); + p.id.should.not.have.property('_id'); + p.title.should.equal(p.title); + p.content.should.equal(p.content); + done(); + }); + }) + .catch(done); + }); + + it('works without options on update (promise variant)', function(done) { + var post = {title: 'a', content: 'AAA', comments: ['Comment1']}; + Post.create(post) + .then(function(created) { + created = created.toObject(); + delete created.comments; + delete created.content; + created.title = 'b'; + return Post.replaceOrCreate(created) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Post); + p.id.should.equal(created.id); + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.not.have.property(p.content); + p.should.not.have.property(p.comments); + return Post.findById(created.id) + .then(function (p) { + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.have.property('content', undefined); + p.should.have.property('comments', undefined); + done(); + }); + }); + }) + .catch(done); + }); + + it('works with options on update (promise variant)', function(done) { + var post = {title: 'a', content: 'AAA', comments: ['Comment1']}; + Post.create(post) + .then(function(created) { + created = created.toObject(); + delete created.comments; + delete created.content; + created.title = 'b'; + return Post.replaceOrCreate(created, {validate: false}) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Post); + p.id.should.equal(created.id); + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.not.have.property(p.content); + p.should.not.have.property(p.comments); + return Post.findById(created.id) + .then(function (p) { + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.have.property('content', undefined); + p.should.have.property('comments', undefined); + done(); + }); + }); + }) + .catch(done); + }); + + it('works without options on update (callback variant)', function(done) { + Post.create({title: 'a', content: 'AAA', comments: ['Comment1']}, + function(err, post) { + if (err) return done(err); + post = post.toObject(); + delete post.comments; + delete post.content; + post.title = 'b'; + Post.replaceOrCreate(post, function(err, p) { + if (err) return done(err); + p.id.should.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.not.have.property(p.content); + p.should.not.have.property(p.comments); + Post.findById(post.id, function(err, p) { + if (err) return done(err); + p.id.should.eql(post.id); + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.have.property('content', undefined); + p.should.have.property('comments', undefined); + done(); + }); + }); + }); + }); + + it('works with options on update (callback variant)', function(done) { + Post.create({title: 'a', content: 'AAA', comments: ['Comment1']}, + {validate: false}, + function(err, post) { + if (err) return done(err); + post = post.toObject(); + delete post.comments; + delete post.content; + post.title = 'b'; + Post.replaceOrCreate(post, function(err, p) { + if (err) return done(err); + p.id.should.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.not.have.property(p.content); + p.should.not.have.property(p.comments); + Post.findById(post.id, function(err, p) { + if (err) return done(err); + p.id.should.eql(post.id); + p.should.not.have.property('_id'); + p.title.should.equal('b'); + p.should.have.property('content', undefined); + p.should.have.property('comments', undefined); + done(); + }); + }); + }); + }); + + it('works without options on create (callback variant)', function(done) { + var post = {id: 123, title: 'a', content: 'AAA'}; + Post.replaceOrCreate(post, function(err, p) { + if (err) return done(err); + p.id.should.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal(post.title); + p.content.should.equal(post.content); + Post.findById(p.id, function(err, p) { + if (err) return done(err); + p.id.should.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal(post.title); + p.content.should.equal(post.content); + done(); + }); + }); + }); + + it('works with options on create (callback variant)', function(done) { + var post = {id: 123, title: 'a', content: 'AAA'}; + Post.replaceOrCreate(post, {validate: false}, function (err, p) { + if (err) return done(err); + p.id.should.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal(post.title); + p.content.should.equal(post.content); + Post.findById(p.id, function(err, p) { + if (err) return done(err); + p.id.should.equal(post.id); + p.should.not.have.property('_id'); + p.title.should.equal(post.title); + p.content.should.equal(post.content); + done(); + }); + }); + }); + }); + } + + if (!getSchema().connector.replaceById) { + describe.skip('replaceAttributes/replaceById - not implemented', function(){}); + } else { + describe('replaceAttributes', function() { + var postInstance; + var Post; + var ds = getSchema(); + before(function () { + Post = ds.define('Post', { + title: {type: String, length: 255, index: true}, + content: {type: String}, + comments: [String] + }); + }); + beforeEach(function (done) { + Post.destroyAll(function () { + Post.create({title: 'a', content: 'AAA'}, function (err, p) { + if (err) return done(err); + postInstance = p; + done(); + }); + }); + }); + + it('works without options(promise variant)', function(done) { + Post.findById(postInstance.id) + .then(function(p){ + p.replaceAttributes({title: 'b'}) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Post); + p.title.should.equal('b'); + p.should.not.have.property('content', undefined); + return Post.findById(postInstance.id) + .then(function (p) { + p.title.should.equal('b'); + p.should.have.property('content', undefined); + done(); + }); + }); + }) + .catch(done); + }); + + it('works with options(promise variant)', function(done) { + Post.findById(postInstance.id) + .then(function(p){ + p.replaceAttributes({title: 'b'}, {validate: false}) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Post); + p.title.should.equal('b'); + p.should.not.have.property('content', undefined); + return Post.findById(postInstance.id) + .then(function (p) { + p.title.should.equal('b'); + p.should.have.property('content', undefined); + done(); + }); + }); + }) + .catch(done); + }); + + it('works without options(callback variant)', function(done) { + Post.findById(postInstance.id, function(err, p) { + if (err) return done(err); + p.replaceAttributes({title: 'b'}, function(err, p) { + if (err) return done(err); + p.should.not.have.property('content', undefined); + p.title.should.equal('b'); + done(); + }); + }); + }); + + it('works with options(callback variant)', function(done) { + Post.findById(postInstance.id, function(err, p) { + if (err) return done(err); + p.replaceAttributes({title: 'b'}, {validate: false}, function(err, p) { + if (err) return done(err); + p.should.not.have.property('content', undefined); + p.title.should.equal('b'); + done(); + }); + }); + }); + }); + } + describe('findOrCreate', function() { it('should create a record with if new', function(done) { Person.findOrCreate({ name: 'Zed', gender: 'male' }, diff --git a/test/persistence-hooks.suite.js b/test/persistence-hooks.suite.js index 050a7fa2..8987bec0 100644 --- a/test/persistence-hooks.suite.js +++ b/test/persistence-hooks.suite.js @@ -1374,6 +1374,252 @@ module.exports = function(dataSource, should) { }); }); }); + + if (!getSchema().connector.replaceById) { + describe.skip('replaceById - not implemented', function(){}); + } else { + describe('PersistedModel.prototype.replaceAttributes', function() { + it('triggers hooks in the correct order', function(done) { + monitorHookExecution(); + + existingInstance.replaceAttributes( + { name: 'replaced' }, + function(err, record, created) { + if (err) return done(err); + triggered.should.eql([ + 'before save', + 'persist', + 'loaded', + 'after save' + ]); + done(); + }); + }); + + it('triggers `before save` hook', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + existingInstance.replaceAttributes({ name: 'changed' }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + instance: { + id: existingInstance.id, + name: 'changed', + extra: undefined, + }, + isNewInstance: false + })); + done(); + }); + }); + + it('aborts when `before save` hook fails', function(done) { + TestModel.observe('before save', nextWithError(expectedError)); + + existingInstance.replaceAttributes({ name: 'replaced' }, function(err) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `before save` hook', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.instance.extra = 'extra data'; + ctx.instance.name = 'hooked name'; + next(); + }); + + existingInstance.replaceAttributes({ name: 'updated' }, function(err) { + if (err) return done(err); + TestModel.findById(existingInstance.id, function(err, instance) { + if (err) return done(err); + should.exists(instance); + instance.toObject(true).should.eql({ + id: existingInstance.id, + name: 'hooked name', + extra: 'extra data' + }); + done(); + }); + }); + }); + + it('validates model after `before save` hook', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + existingInstance.replaceAttributes({ name: 'updated' }, function(err) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('triggers `persist` hook', function(done) { + TestModel.observe('persist', pushContextAndNext()); + existingInstance.replaceAttributes({ name: 'replacedName' }, function(err) { + if (err) return done(err); + + observedContexts.should.eql(aTestModelCtx({ + where: { id: existingInstance.id }, + data: { + name: 'replacedName', + id: existingInstance.id + }, + currentInstance: { + id: existingInstance.id, + name: 'replacedName', + extra: null + }, + isNewInstance: false + })); + + done(); + }); + }); + + it('applies delete from `persist` hook', function(done) { + TestModel.observe('persist', pushContextAndNext(function(ctx){ + delete ctx.data.extra; + })); + + existingInstance.replaceAttributes({ name: 'changed' }, function(err, instance) { + if (err) return done(err); + instance.should.not.have.property('extra', 'hook data'); + done(); + }); + }); + + it('applies updates from `persist` hook - for nested model instance', function(done) { + var Address = dataSource.createModel('NestedAddress', { + id: { type: String, id: true, default: 1 }, + city: { type: String, required: true }, + country: { type: String, required: true } + }); + + var User = dataSource.createModel('UserWithAddress', { + id: { type: String, id: true, default: uid() }, + name: { type: String, required: true }, + address: {type: Address, required: false}, + extra: {type: String} + }); + + dataSource.automigrate(['UserWithAddress', 'NestedAddress'], function(err) { + if (err) return done(err); + User.create({name: 'Joe'}, function(err, instance) { + if (err) return done(err); + + var existingUser = instance; + + User.observe('persist', pushContextAndNext(function(ctx) { + should.exist(ctx.data.address) + ctx.data.address.should.be.type('object'); + ctx.data.address.should.not.be.instanceOf(Address); + + ctx.data.extra = 'hook data'; + })); + + existingUser.replaceAttributes( + {name: 'John', address: new Address({city: 'Springfield', country: 'USA'})}, + function(err, inst) { + if (err) return done(err); + + inst.should.have.property('extra', 'hook data'); + + User.findById(existingUser.id, function(err, dbInstance) { + if (err) return done(err); + dbInstance.toObject(true).should.eql({ + id: existingUser.id, + name: 'John', + address: {id: '1', city: 'Springfield', country: 'USA'}, + extra: 'hook data' + }); + done(); + }); + }); + }); + }); + }); + + it('triggers `loaded` hook', function(done) { + TestModel.observe('loaded', pushContextAndNext()); + existingInstance.replaceAttributes({ name: 'changed' }, function(err, data) { + if (err) return done(err); + + observedContexts.should.eql(aTestModelCtx({ + data: { + name: 'changed', + id: data.id + }, + isNewInstance : false + })); + done(); + }); + }); + + it('emits error when `loaded` hook fails', function(done) { + TestModel.observe('loaded', nextWithError(expectedError)); + existingInstance.replaceAttributes( + { name: 'replaced' }, + function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `loaded` hook replaceAttributes', function(done) { + TestModel.observe('loaded', pushContextAndNext(function(ctx){ + ctx.data.name = 'changed in hook'; + })); + + existingInstance.replaceAttributes({ name: 'changed' }, function(err, instance) { + if (err) return done(err); + instance.should.have.property('name', 'changed in hook'); + done(); + }); + }); + + it('triggers `after save` hook', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + existingInstance.name = 'replaced'; + existingInstance.replaceAttributes({ name: 'replaced' }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + instance: { + id: existingInstance.id, + name: 'replaced', + extra: undefined + }, + isNewInstance: false + })); + done(); + }); + }); + + it('aborts when `after save` hook fails', function(done) { + TestModel.observe('after save', nextWithError(expectedError)); + + existingInstance.replaceAttributes({ name: 'replaced' }, function(err) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `after save` hook', function(done) { + TestModel.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + existingInstance.replaceAttributes({ name: 'updated' }, function(err, instance) { + if (err) return done(err); + instance.should.have.property('extra', 'hook data'); + done(); + }); + }); + }); + } describe('PersistedModel.updateOrCreate', function() { it('triggers hooks in the correct order on create', function(done) { @@ -1828,6 +2074,444 @@ module.exports = function(dataSource, should) { }); }); + if (!getSchema().connector.replaceById) { + describe.skip('replaceById - not implemented', function(){}); + } else { + describe('PersistedModel.replaceOrCreate', function() { + it('triggers hooks in the correct order on create', function(done) { + monitorHookExecution(); + + TestModel.replaceOrCreate( + { id: 'not-found', name: 'not found' }, + function(err, record, created) { + if (err) return done(err); + triggered.should.eql([ + 'access', + 'before save', + 'persist', + 'loaded', + 'after save' + ]); + done(); + }); + }); + + it('triggers hooks in the correct order on replace', function(done) { + monitorHookExecution(); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, record, created) { + if (err) return done(err); + if (dataSource.connector.replaceOrCreate) { + triggered.should.eql([ + 'access', + 'before save', + 'persist', + 'loaded', + 'after save' + ]); + } else { + // TODO: Please see loopback-datasource-juggler/issues#836 + // + // loaded hook is triggered twice in non-atomic version: + // 1) It gets triggered once by "find()" in this chain: + // "replaceORCreate()->findOne()->find()", + // which is a bug; Please see this ticket: + // loopback-datasource-juggler/issues#836. + // 2) It, also, gets triggered in "replaceAttributes()" + // in this chain replaceORCreate()->replaceAttributes() + triggered.should.eql([ + 'access', + 'loaded', + 'before save', + 'persist', + 'loaded', + 'after save' + ]); + }; + done(); + }); + }); + + it('triggers `access` hook on create', function(done) { + TestModel.observe('access', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: 'not-found', name: 'not found' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { id: 'not-found' } + }})); + done(); + }); + }); + + it('triggers `access` hook on replace', function(done) { + TestModel.observe('access', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { id: existingInstance.id } + }})); + done(); + }); + }); + + it('does not trigger `access` on missing id', function(done) { + TestModel.observe('access', pushContextAndNext()); + + TestModel.replaceOrCreate( + { name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.equal('hook not called'); + done(); + }); + }); + + it('applies updates from `access` hook when found', function(done) { + TestModel.observe('access', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, instance) { + if (err) return done(err); + findTestModels({ fields: ['id', 'name' ] }, function(err, list) { + if (err) return done(err); + (list||[]).map(toObject).should.eql([ + { id: existingInstance.id, name: existingInstance.name, extra: undefined }, + { id: instance.id, name: 'new name', extra: undefined } + ]); + done(); + }); + }); + }); + + it('applies updates from `access` hook when not found', function(done) { + TestModel.observe('access', function(ctx, next) { + ctx.query = { where: { id: 'not-found' } }; + next(); + }); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, instance) { + if (err) return done(err); + findTestModels({ fields: ['id', 'name' ] }, function(err, list) { + if (err) return done(err); + (list||[]).map(toObject).should.eql([ + { id: existingInstance.id, name: existingInstance.name, extra: undefined }, + { id: list[1].id, name: 'second', extra: undefined }, + { id: instance.id, name: 'new name', extra: undefined } + ]); + done(); + }); + }); + }); + + it('triggers hooks only once', function(done) { + TestModel.observe('access', pushNameAndNext('access')); + TestModel.observe('before save', pushNameAndNext('before save')); + + TestModel.observe('access', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + TestModel.replaceOrCreate( + { id: 'ignored', name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observersCalled.should.eql(['access', 'before save']); + done(); + }); + }); + + it('triggers `before save` hookon create', function(done) { + TestModel.observe('before save', pushContextAndNext()); + TestModel.replaceOrCreate({id: existingInstance.id, name: 'new name'}, + function(err, instance) { + if (err) + return done(err); + + var expectedContext = aTestModelCtx({ + instance: instance + }); + + if (!dataSource.connector.replaceOrCreate) { + expectedContext.isNewInstance = false; + } + done(); + }); + }); + + it('triggers `before save` hook on replace', function(done) { + TestModel.observe('before save', pushContextAndNext()); + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'replaced name' }, + function(err, instance) { + if (err) return done(err); + + var expectedContext = aTestModelCtx({ + instance: { + id: existingInstance.id, + name: 'replaced name', + extra: undefined + } + }); + + if (!dataSource.connector.replaceOrCreate) { + expectedContext.isNewInstance = false; + } + observedContexts.should.eql(expectedContext); + + done(); + }); + }); + + it('triggers `before save` hook on create', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + + var expectedContext = aTestModelCtx({ + instance: { + id: 'new-id', + name: 'a name', + extra: undefined + } + }); + + if (!dataSource.connector.replaceOrCreate) { + expectedContext.isNewInstance = true; + } + observedContexts.should.eql(expectedContext); + + done(); + }); + }); + + it('applies updates from `before save` hook on create', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.instance.name = 'hooked'; + next(); + }); + + TestModel.replaceOrCreate( + { id: 'new-id', name: 'new name' }, + function(err, instance) { + if (err) return done(err); + instance.name.should.equal('hooked'); + done(); + }); + }); + + it('validates model after `before save` hook on create', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.replaceOrCreate( + { id: 'new-id', name: 'new name' }, + function(err, instance) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('triggers `persist` hook on create', function(done) { + TestModel.observe('persist', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + + var expectedContext = aTestModelCtx({ + currentInstance: { + id: 'new-id', + name: 'a name', + extra: undefined + }, data: { + id: 'new-id', + name: 'a name' + } + }); + + + + if (dataSource.connector.replaceOrCreate) { + expectedContext.where = { id: 'new-id' }; + } else { + // non-atomic implementation does not provide ctx.where + // because a new instance is being created, so there + // are not records to match where filter. + expectedContext.isNewInstance = true; + } + observedContexts.should.eql(expectedContext); + done(); + }); + }); + + it('triggers `persist` hook on replace', function(done) { + TestModel.observe('persist', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'replaced name' }, + function(err, instance) { + if (err) return done(err); + + var expected = { + where: { id: existingInstance.id }, + data: { + id: existingInstance.id, + name: 'replaced name' + }, + currentInstance: { + id: existingInstance.id, + name: 'replaced name', + extra: undefined + } + }; + + var expectedContext = aTestModelCtx(expected); + + var expectedContext; + if (!dataSource.connector.replaceOrCreate) { + expectedContext.isNewInstance = false; + } + + observedContexts.should.eql(expectedContext); + done(); + }); + }); + + it('triggers `loaded` hook on create', function(done) { + TestModel.observe('loaded', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + data: { + id: 'new-id', + name: 'a name' + }, + isNewInstance: true + })); + done(); + }); + }); + + it('triggers `loaded` hook on replace', function(done) { + TestModel.observe('loaded', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'replaced name' }, + function(err, instance) { + if (err) return done(err); + + if (dataSource.connector.replaceOrCreate) { + observedContexts.should.eql(aTestModelCtx({ + data: { + id: existingInstance.id, + name: 'replaced name' + }, + isNewInstance: false + })); + } else { + // TODO: Please see loopback-datasource-juggler/issues#836 + // + // loaded hook is triggered twice in non-atomic version: + // 1) It gets triggered once by "find()" in this chain: + // "replaceORCreate()->findOne()->find()", + // which is a bug; Please see this ticket: + // loopback-datasource-juggler/issues#836. + // 2) It, also, gets triggered in "replaceAttributes()" + // in this chain replaceORCreate()->replaceAttributes() + observedContexts.should.eql([ + aTestModelCtx({ + data: { + id: existingInstance.id, + name: 'first' + }, + isNewInstance: false, + options: { notify: false } + }), + aTestModelCtx({ + data: { + id: existingInstance.id, + name: 'replaced name' + }, + isNewInstance: false + }) + ]); + } + done(); + }); + }); + + it('emits error when `loaded` hook fails', function(done) { + TestModel.observe('loaded', nextWithError(expectedError)); + TestModel.replaceOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('triggers `after save` hook on replace', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: existingInstance.id, name: 'replaced name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + instance: { + id: existingInstance.id, + name: 'replaced name', + extra: undefined + }, + isNewInstance: false + })); + done(); + }); + }); + + it('triggers `after save` hook on create', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.replaceOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + instance: { + id: instance.id, + name: 'a name', + extra: undefined + }, + isNewInstance: true + })); + done(); + }); + }); + }); + } + describe('PersistedModel.deleteAll', function() { it('triggers `access` hook with query', function(done) { TestModel.observe('access', pushContextAndNext());