From ca766f1437dbef7e02096f9b3002debe61ffbe45 Mon Sep 17 00:00:00 2001
From: Amir Jafarian <jafarian@ca.ibm.com>
Date: Fri, 4 Dec 2015 13:49:00 -0500
Subject: [PATCH] Implementtaion of replace

This includes:
*implementation of replaceAttributes
*implementtaion of replaceOrCreate
---
 lib/connectors/memory.js        |  58 +++
 lib/dao.js                      | 354 +++++++++++++++++
 test/manipulation.test.js       | 305 ++++++++++++++
 test/persistence-hooks.suite.js | 684 ++++++++++++++++++++++++++++++++
 4 files changed, 1401 insertions(+)

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 1c0d809e..ba0193f7 100644
--- a/lib/dao.js
+++ b/lib/dao.js
@@ -627,6 +627,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.
@@ -2397,6 +2579,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 452472f6..0f558d42 100644
--- a/test/persistence-hooks.suite.js
+++ b/test/persistence-hooks.suite.js
@@ -1372,6 +1372,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) {
@@ -1825,6 +2071,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());