Merge pull request #788 from strongloop/replace

Implementation of replaceOrCreate and replace
This commit is contained in:
Amir-61 2016-02-04 15:17:14 -05:00
commit 3b9d3d0212
4 changed files with 1401 additions and 0 deletions

View File

@ -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);
};

View File

@ -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.

View File

@ -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' },

View File

@ -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());