diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index df38a387..a4b85f1a 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -252,6 +252,36 @@ Memory.prototype.updateOrCreate = function(model, data, options, callback) { }); }; +Memory.prototype.patchOrCreateWithWhere = +Memory.prototype.upsertWithWhere = function(model, where, data, options, callback) { + var self = this; + var primaryKey = this.idName(model); + var filter = { where: where }; + var nodes = self._findAllSkippingIncludes(model, filter); + if (nodes.length === 0) { + return self._createSync(model, data, function(err, id) { + if (err) return process.nextTick(function() { callback(err); }); + self.saveToFile(id, function(err, id) { + self.setIdValue(model, data, id); + callback(err, self.fromDb(model, data), { isNewInstance: true }); + }); + }); + } + if (nodes.length === 1) { + var primaryKeyValue = nodes[0][primaryKey]; + self.updateAttributes(model, primaryKeyValue, data, options, function(err, data) { + callback(err, data, { isNewInstance: false }); + }); + } else { + process.nextTick(function() { + var error = new Error('There are multiple instances found.' + + 'Upsert Operation will not be performed!'); + error.statusCode = 400; + callback(error); + }); + } +}; + Memory.prototype.findOrCreate = function(model, filter, data, callback) { var self = this; var nodes = self._findAllSkippingIncludes(model, filter); diff --git a/lib/dao.js b/lib/dao.js index 00b33782..74603b4a 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -630,7 +630,173 @@ DataAccessObject.upsert = function(data, options, cb) { } return cb.promise; }; +/** + * Update or insert a model instance based on the search criteria. + * If there is a single instance retrieved, update the retrieved model. + * Creates a new model if no model instances were found. + * Returns an error if multiple instances are found. + * * @param {Object} [where] `where` filter, like + * ``` + * { key: val, key2: {gt: 'val2'}, ...} + * ``` + *
see + * [Where filter](https://docs.strongloop.com/display/LB/Where+filter#Wherefilter-Whereclauseforothermethods). + * @param {Object} data The model instance data to insert. + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} model Updated model instance. + */ +DataAccessObject.patchOrCreateWithWhere = +DataAccessObject.upsertWithWhere = function(where, data, options, cb) { + var connectionPromise = stillConnecting(this.getDataSource(), this, arguments); + if (connectionPromise) { return connectionPromise; } + if (cb === undefined) { + if (typeof options === 'function') { + // upsertWithWhere(where, data, cb) + cb = options; + options = {}; + } + } + cb = cb || utils.createPromiseCallback(); + options = options || {}; + assert(typeof where === 'object', 'The where argument must be an object'); + 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'); + if (Object.keys(data).length === 0) { + var err = new Error('data object cannot be empty!'); + err.statusCode = 400; + process.nextTick(function() { cb(err); }); + return cb.promise; + } + var hookState = {}; + var self = this; + var Model = this; + var connector = Model.getConnector(); + var modelName = Model.modelName; + var query = { where: where }; + var context = { + Model: Model, + query: query, + hookState: hookState, + options: options, + }; + Model.notifyObserversOf('access', context, doUpsertWithWhere); + function doUpsertWithWhere(err, ctx) { + if (err) return cb(err); + ctx.data = data; + if (connector.upsertWithWhere) { + var context = { + Model: Model, + where: ctx.query.where, + data: ctx.data, + hookState: hookState, + options: options, + }; + Model.notifyObserversOf('before save', context, function(err, ctx) { + if (err) return cb(err); + data = ctx.data; + var update = data; + var inst = data; + if (!(data instanceof Model)) { + inst = new Model(data, { applyDefaultValues: false }); + } + update = inst.toObject(false); + Model.applyScope(query); + Model.applyProperties(update, inst); + Model = Model.lookupModel(update); + if (options.validate === false) { + return callConnector(); + } + if (options.validate === undefined && Model.settings.automaticValidation === false) { + return callConnector(); + } + inst.isValid(function(valid) { + if (!valid) return cb(new ValidationError(inst), inst); + callConnector(); + }, update, options); + function callConnector() { + try { + ctx.where = removeUndefined(ctx.where); + ctx.where = Model._coerce(ctx.where); + update = removeUndefined(update); + update = Model._coerce(update); + } catch (err) { + return process.nextTick(function() { + cb(err); + }); + } + context = { + Model: Model, + where: ctx.where, + data: update, + currentInstance: inst, + hookState: ctx.hookState, + options: options, + }; + Model.notifyObserversOf('persist', context, function(err) { + if (err) return done(err); + connector.upsertWithWhere(modelName, ctx.where, update, options, done); + }); + } + function done(err, data, info) { + if (err) return cb(err); + var contxt = { + Model: Model, + data: data, + isNewInstance: info && info.isNewInstance, + hookState: ctx.hookState, + options: options, + }; + Model.notifyObserversOf('loaded', contxt, function(err) { + if (err) return cb(err); + var obj; + if (contxt.data && !(contxt.data instanceof Model)) { + inst._initProperties(contxt.data, { persisted: true }); + obj = inst; + } else { + obj = contxt.data; + } + var context = { + Model: Model, + instance: obj, + isNewInstance: info ? info.isNewInstance : undefined, + hookState: hookState, + options: options, + }; + Model.notifyObserversOf('after save', context, function(err) { + cb(err, obj); + }); + }); + } + }); + } else { + var opts = { notify: false }; + if (ctx.options && ctx.options.transaction) { + opts.transaction = ctx.options.transaction; + } + self.find({ where: ctx.query.where }, opts, function(err, instances) { + if (err) return cb(err); + var modelsLength = instances.length; + if (modelsLength === 0) { + self.create(data, options, cb); + } else if (modelsLength === 1) { + var modelInst = instances[0]; + modelInst.updateAttributes(data, options, cb); + } else { + process.nextTick(function() { + var error = new Error('There are multiple instances found.' + + 'Upsert Operation will not be performed!'); + error.statusCode = 400; + cb(error); + }); + } + }); + } + } + 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. diff --git a/test/crud-with-options.test.js b/test/crud-with-options.test.js index 870d8229..b5ddf216 100644 --- a/test/crud-with-options.test.js +++ b/test/crud-with-options.test.js @@ -539,6 +539,69 @@ describe('crud-with-options', function() { }); +describe('upsertWithWhere', function() { + beforeEach(seed); + it('rejects upsertWithWhere (options,cb)', function(done) { + try { + User.upsertWithWhere({}, function(err) { + if (err) return done(err); + }); + } catch (ex) { + ex.message.should.equal('The data argument must be an object'); + done(); + } + }); + + it('rejects upsertWithWhere (cb)', function(done) { + try { + User.upsertWithWhere(function(err) { + if (err) return done(err); + }); + } catch (ex) { + ex.message.should.equal('The where argument must be an object'); + done(); + } + }); + + it('allows upsertWithWhere by accepting where,data and cb as arguments', function(done) { + User.upsertWithWhere({ name: 'John Lennon' }, { name: 'John Smith' }, function(err) { + if (err) return done(err); + User.find({ where: { name: 'John Lennon' }}, function(err, data) { + if (err) return done(err); + data.length.should.equal(0); + User.find({ where: { name: 'John Smith' }}, function(err, data) { + if (err) return done(err); + data.length.should.equal(1); + data[0].name.should.equal('John Smith'); + data[0].email.should.equal('john@b3atl3s.co.uk'); + data[0].role.should.equal('lead'); + data[0].order.should.equal(2); + data[0].vip.should.equal(true); + done(); + }); + }); + }); + }); + + it('allows upsertWithWhere by accepting where, data, options, and cb as arguments', function(done) { + options = {}; + User.upsertWithWhere({ name: 'John Lennon' }, { name: 'John Smith' }, options, function(err) { + if (err) return done(err); + User.find({ where: { name: 'John Smith' }}, function(err, data) { + if (err) return done(err); + data.length.should.equal(1); + data[0].name.should.equal('John Smith'); + data[0].seq.should.equal(0); + data[0].email.should.equal('john@b3atl3s.co.uk'); + data[0].role.should.equal('lead'); + data[0].order.should.equal(2); + data[0].vip.should.equal(true); + done(); + }); + }); + }); +}); + function seed(done) { var beatles = [ { diff --git a/test/manipulation.test.js b/test/manipulation.test.js index 6b9eb4e9..6857190a 100644 --- a/test/manipulation.test.js +++ b/test/manipulation.test.js @@ -1045,6 +1045,275 @@ describe('manipulation', function() { }); } + describe('upsertWithWhere', function() { + var ds = getSchema(); + var Person; + before('prepare "Person" model', function(done) { + Person = ds.define('Person', { + id: { type: Number, id: true }, + name: { type: String }, + city: { type: String }, + }); + ds.automigrate('Person', done); + }); + + it('has an alias "patchOrCreateWithWhere"', function() { + StubUser.upsertWithWhere.should.equal(StubUser.patchOrCreateWithWhere); + }); + + it('should preserve properties with dynamic setters on create', function(done) { + StubUser.upsertWithWhere({ password: 'foo' }, { password: 'foo' }, function(err, created) { + if (err) return done(err); + created.password.should.equal('foo-FOO'); + StubUser.findById(created.id, function(err, found) { + if (err) return done(err); + found.password.should.equal('foo-FOO'); + done(); + }); + }); + }); + + it('should preserve properties with dynamic setters on update', function(done) { + StubUser.create({ password: 'foo' }, function(err, created) { + if (err) return done(err); + var data = { password: 'bar' }; + StubUser.upsertWithWhere({ id: created.id }, data, function(err, updated) { + if (err) return done(err); + updated.password.should.equal('bar-BAR'); + StubUser.findById(created.id, function(err, found) { + if (err) return done(err); + found.password.should.equal('bar-BAR'); + done(); + }); + }); + }); + }); + + it('should preserve properties with "undefined" value', function(done) { + Person.create( + { id: 10, name: 'Ritz', city: undefined }, + function(err, instance) { + if (err) return done(err); + instance.toObject().should.have.properties({ + id: 10, + name: 'Ritz', + city: undefined, + }); + + Person.upsertWithWhere({ id: 10 }, + { name: 'updated name' }, + function(err, updated) { + if (err) return done(err); + var result = updated.toObject(); + result.should.have.properties({ + id: instance.id, + name: 'updated name', + }); + should.equal(result.city, null); + done(); + }); + }); + }); + + it('updates specific instances when PK is not an auto-generated id', function(done) { + Person.create([ + { name: 'nameA', city: 'cityA' }, + { name: 'nameB', city: 'cityB' }, + ], function(err, instance) { + if (err) return done(err); + + Person.upsertWithWhere({ name: 'nameA' }, + { city: 'newCity' }, + function(err, instance) { + if (err) return done(err); + var result = instance.toObject(); + result.should.have.properties({ + name: 'nameA', + city: 'newCity', + }); + + Person.find(function(err, persons) { + if (err) return done(err); + persons.should.have.length(3); + persons[1].name.should.equal('nameA'); + persons[1].city.should.equal('newCity'); + persons[2].name.should.equal('nameB'); + persons[2].city.should.equal('cityB'); + done(); + }); + }); + }); + }); + + it('should allow save() of the created instance', function(done) { + Person.upsertWithWhere({ id: 999 }, + { name: 'a-name' }, + function(err, inst) { + if (err) return done(err); + inst.save(done); + }); + }); + + it('works without options on create (promise variant)', function(done) { + var person = { id: 123, name: 'a', city: 'city a' }; + Person.upsertWithWhere({ id: 123 }, person) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Person); + p.id.should.be.equal(person.id); + p.should.not.have.property('_id'); + p.name.should.equal(person.name); + p.city.should.equal(person.city); + return Person.findById(p.id) + .then(function(p) { + p.id.should.equal(person.id); + p.id.should.not.have.property('_id'); + p.name.should.equal(person.name); + p.city.should.equal(person.city); + done(); + }); + }) + .catch(done); + }); + + it('works with options on create (promise variant)', function(done) { + var person = { id: 234, name: 'b', city: 'city b' }; + Person.upsertWithWhere({ id: 234 }, person, { validate: false }) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Person); + p.id.should.be.equal(person.id); + p.should.not.have.property('_id'); + p.name.should.equal(person.name); + p.city.should.equal(person.city); + return Person.findById(p.id) + .then(function(p) { + p.id.should.equal(person.id); + p.id.should.not.have.property('_id'); + p.name.should.equal(person.name); + p.city.should.equal(person.city); + done(); + }); + }) + .catch(done); + }); + + it('works without options on update (promise variant)', function(done) { + var person = { id: 456, name: 'AAA', city: 'city AAA' }; + Person.create(person) + .then(function(created) { + created = created.toObject(); + delete created.city; + created.name = 'BBB'; + return Person.upsertWithWhere({ id: 456 }, created) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Person); + p.id.should.equal(created.id); + p.should.not.have.property('_id'); + p.name.should.equal('BBB'); + p.should.have.property('city', 'city AAA'); + return Person.findById(created.id) + .then(function(p) { + p.should.not.have.property('_id'); + p.name.should.equal('BBB'); + p.city.should.equal('city AAA'); + done(); + }); + }); + }) + .catch(done); + }); + + it('works with options on update (promise variant)', function(done) { + var person = { id: 789, name: 'CCC', city: 'city CCC' }; + Person.create(person) + .then(function(created) { + created = created.toObject(); + delete created.city; + created.name = 'Carlton'; + return Person.upsertWithWhere({ id: 789 }, created, { validate: false }) + .then(function(p) { + should.exist(p); + p.should.be.instanceOf(Person); + p.id.should.equal(created.id); + p.should.not.have.property('_id'); + p.name.should.equal('Carlton'); + p.should.have.property('city', 'city CCC'); + return Person.findById(created.id) + .then(function(p) { + p.should.not.have.property('_id'); + p.name.should.equal('Carlton'); + p.city.should.equal('city CCC'); + done(); + }); + }); + }) + .catch(done); + }); + + it('fails the upsertWithWhere operation when data object is empty', function(done) { + options = {}; + Person.upsertWithWhere({ name: 'John Lennon' }, {}, options, + function(err) { + err.message.should.equal('data object cannot be empty!'); + done(); + }); + }); + + it('creates a new record when no matching instance is found', function(done) { + Person.upsertWithWhere({ city: 'Florida' }, { name: 'Nick Carter', id: 1, city: 'Florida' }, + function(err, created) { + if (err) return done(err); + Person.findById(1, function(err, data) { + if (err) return done(err); + data.id.should.equal(1); + data.name.should.equal('Nick Carter'); + data.city.should.equal('Florida'); + done(); + }); + }); + }); + + it('fails the upsertWithWhere operation when multiple instances are ' + + 'retrieved based on the filter criteria', function(done) { + Person.create([ + { id: '2', name: 'Howie', city: 'Florida' }, + { id: '3', name: 'Kevin', city: 'Florida' }, + ], function(err, instance) { + if (err) return done(err); + Person.upsertWithWhere({ city: 'Florida' }, { + id: '4', name: 'Brian', + }, function(err) { + err.message.should.equal('There are multiple instances found.' + + 'Upsert Operation will not be performed!'); + done(); + }); + }); + }); + + it('updates the record when one matching instance is found ' + + 'based on the filter criteria', function(done) { + Person.create([ + { id: '5', name: 'Howie', city: 'Kentucky' }, + ], function(err, instance) { + if (err) return done(err); + Person.upsertWithWhere({ city: 'Kentucky' }, { + name: 'Brian', + }, { validate: false }, function(err, instance) { + if (err) return done(err); + Person.findById(5, function(err, data) { + if (err) return done(err); + data.id.should.equal(5); + data.name.should.equal('Brian'); + data.city.should.equal('Kentucky'); + done(); + }); + }); + }); + }); + }); + if (!getSchema().connector.replaceById) { describe.skip('replaceAttributes/replaceById - not implemented', function() {}); } else { diff --git a/test/persistence-hooks.suite.js b/test/persistence-hooks.suite.js index 87aa2f70..3e5cc48f 100644 --- a/test/persistence-hooks.suite.js +++ b/test/persistence-hooks.suite.js @@ -2946,6 +2946,366 @@ module.exports = function(dataSource, should, connectorCapabilities) { }); + describe('PersistedModel.upsertWithWhere', function() { + it('triggers hooks in the correct order on create', function(done) { + monitorHookExecution(); + TestModel.upsertWithWhere({ extra: 'not-found' }, + { id: 'not-found', name: 'not found', extra: 'not-found' }, + function(err, record, created) { + if (err) return done(err); + hookMonitor.names.should.eql([ + 'access', + 'before save', + 'persist', + 'loaded', + 'after save', + ]); + TestModel.findById('not-found', function(err, data) { + if (err) return done(err); + data.name.should.equal('not found'); + data.extra.should.equal('not-found'); + done(); + }); + }); + }); + + it('triggers hooks in the correct order on update', function(done) { + monitorHookExecution(); + TestModel.upsertWithWhere({ id: existingInstance.id }, + { name: 'new name', extra: 'new extra' }, + function(err, record, created) { + if (err) return done(err); + hookMonitor.names.should.eql([ + 'access', + 'before save', + 'persist', + 'loaded', + 'after save', + ]); + TestModel.findById(existingInstance.id, function(err, data) { + if (err) return done(err); + data.name.should.equal('new name'); + data.extra.should.equal('new extra'); + done(); + }); + }); + }); + + it('triggers `access` hook on create', function(done) { + TestModel.observe('access', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ extra: 'not-found' }, + { id: 'not-found', name: 'not found' }, + function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { query: { + where: { extra: 'not-found' }, + }})); + done(); + }); + }); + + it('triggers `access` hook on update', function(done) { + TestModel.observe('access', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { name: 'new name', extra: 'new extra' }, + function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { query: { + where: { id: existingInstance.id }, + }})); + done(); + }); + }); + + it('triggers hooks only once', function(done) { + monitorHookExecution(['access', 'before save']); + + TestModel.observe('access', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id }}}; + next(); + }); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { id: 'ignored', name: 'new name' }, + function(err, instance) { + if (err) return done(err); + hookMonitor.names.should.eql(['access', 'before save']); + 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.upsertWithWhere({ 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.upsertWithWhere({ 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 `before save` hook on update', function(done) { + TestModel.observe('before save', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + var expectedContext = aCtxForModel(TestModel, { + where: { id: existingInstance.id }, + data: { + id: existingInstance.id, + name: 'updated name', + }, + }); + if (!dataSource.connector.upsertWithWhere) { + expectedContext.currentInstance = existingInstance; + } + ctxRecorder.records.should.eql(expectedContext); + done(); + }); + }); + + it('triggers `before save` hook on create', function(done) { + TestModel.observe('before save', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: 'new-id' }, + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + var expectedContext = aCtxForModel(TestModel, { + where: { id: 'new-id' }, + data: { id: 'new-id', name: 'a name' }, + }); + if (!dataSource.connector.upsertWithWhere) { + ctxRecorder.records.should.eql(expectedContext.isNewInstance = true); + } + ctxRecorder.records.should.eql(expectedContext); + done(); + }); + }); + + it('applies updates from `before save` hook on update', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.data.name = 'hooked'; + next(); + }); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + instance.name.should.equal('hooked'); + done(); + }); + }); + + it('applies updates from `before save` hook on create', function(done) { + TestModel.observe('before save', function(ctx, next) { + if (ctx.instance) { + ctx.instance.name = 'hooked'; + } else { + ctx.data.name = 'hooked'; + } + next(); + }); + + TestModel.upsertWithWhere({ id: 'new-id' }, + { 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.upsertWithWhere({ id: 'new-id' }, + { id: 'new-id', name: 'new name' }, + function(err, instance) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('validates model after `before save` hook on update', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { id: existingInstance.id, name: 'updated 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', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: 'new-id' }, + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { + where: { id: 'new-id' }, + data: { id: 'new-id', name: 'a name' }, + currentInstance: { + id: 'new-id', + name: 'a name', + extra: undefined, + }, + })); + done(); + }); + }); + + it('triggers persist hook on update', function(done) { + TestModel.observe('persist', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + var expectedContext = aCtxForModel(TestModel, { + where: { id: existingInstance.id }, + data: { + id: existingInstance.id, + name: 'updated name', + }, + currentInstance: { + id: existingInstance.id, + name: 'updated name', + extra: undefined, + }, + }); + if (!dataSource.connector.upsertWithWhere) { + expectedContext.isNewInstance = false; + } + ctxRecorder.records.should.eql(expectedContext); + done(); + }); + }); + + it('triggers `loaded` hook on create', function(done) { + TestModel.observe('loaded', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: 'new-id' }, + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { + data: { id: 'new-id', name: 'a name' }, + isNewInstance: true, + })); + done(); + }); + }); + + it('triggers `loaded` hook on update', function(done) { + TestModel.observe('loaded', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { + data: { + id: existingInstance.id, + name: 'updated name', + }, + isNewInstance: false, + })); + done(); + }); + }); + + it('emits error when `loaded` hook fails', function(done) { + TestModel.observe('loaded', nextWithError(expectedError)); + TestModel.upsertWithWhere({ id: 'new-id' }, + { id: 'new-id', name: 'a name' }, + function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('triggers `after save` hook on update', function(done) { + TestModel.observe('after save', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: existingInstance.id }, + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { + instance: { + id: existingInstance.id, + name: 'updated name', + extra: undefined, + }, + isNewInstance: false, + })); + done(); + }); + }); + + it('triggers `after save` hook on create', function(done) { + TestModel.observe('after save', ctxRecorder.recordAndNext()); + + TestModel.upsertWithWhere({ id: 'new-id' }, + { id: 'new-id', name: 'a name' }, function(err, instance) { + if (err) return done(err); + ctxRecorder.records.should.eql(aCtxForModel(TestModel, { + instance: { + id: instance.id, + name: 'a name', + extra: undefined, + }, + isNewInstance: true, + })); + done(); + }); + }); + }); + function nextWithError(err) { return function(context, next) { next(err);