diff --git a/CHANGELOG b/CHANGELOG index fb448edf7d..0f8ec73419 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -140,6 +140,8 @@ v2.6.0 (XXXX-XX-XX) * added `extendible` package. +* added Foxx model lifecycle events to repositories. See #1257. + v2.5.2 (XXXX-XX-XX) ------------------- diff --git a/Documentation/Books/Users/Foxx/Develop/Model.mdpp b/Documentation/Books/Users/Foxx/Develop/Model.mdpp index 0e0c87e737..4b90acfde7 100644 --- a/Documentation/Books/Users/Foxx/Develop/Model.mdpp +++ b/Documentation/Books/Users/Foxx/Develop/Model.mdpp @@ -37,6 +37,18 @@ var PersonModel = Foxx.Model.extend({ exports.model = PersonModel; ``` +You can also use `joi.object` schemas directly: + +```js +var PersonModel = Foxx.Model.extend({ + schema: joi.object().keys({ + name: joi.string().required(), + age: joi.number().integer(), + active: joi.boolean().default(true) + }) +}); +``` + This has two effects: On the one hand it provides documentation. If you annotated your model, you can use it in the **bodyParam** method for documentation. On the other hand it will influence the behavior of the constructor: If you provide @@ -66,6 +78,8 @@ The following events are emitted by a model: - beforeRemove - afterRemove +Equivalent events will also be emitted by the repository handling the model. + Model lifecycle: ```js diff --git a/Documentation/Books/Users/Foxx/Develop/Repository.mdpp b/Documentation/Books/Users/Foxx/Develop/Repository.mdpp index 7ddc9a79c3..332bcd6510 100644 --- a/Documentation/Books/Users/Foxx/Develop/Repository.mdpp +++ b/Documentation/Books/Users/Foxx/Develop/Repository.mdpp @@ -11,6 +11,47 @@ var TodosRepository = Foxx.Repository.extend({ exports.repository = TodosRepository; ``` +The following events are emitted by a repository: + +- beforeCreate +- afterCreate +- beforeSave +- afterSave +- beforeUpdate +- afterUpdate +- beforeRemove +- afterRemove + +Model lifecycle: + +```js +var person = new PersonModel(); +person.on('beforeCreate', function() { + var model = this; + model.fancyMethod(); // Do something fancy with the model +}); +var people = new Repository(appContext.collection("people"), { model: PersonModel }); + +people.save(person); +// beforeCreate(person) +// beforeSave(person) +// The model is created at db +// afterSave(person) +// afterCreate(person) + +people.update(person, data); +// beforeUpdate(person, data) +// beforeSave(person, data) +// The model is updated at db +// afterSave(person, data) +// afterUpdate(person, data) + +people.remove(person); +// beforeRemove(person) +// The model is deleted at db +// afterRemove(person) +``` + !SUBSECTION Initialize @startDocuBlock JSF_foxx_repository_initializer diff --git a/UnitTests/Makefile.unittests b/UnitTests/Makefile.unittests index 98f69af9aa..0a2f7ff64b 100755 --- a/UnitTests/Makefile.unittests +++ b/UnitTests/Makefile.unittests @@ -488,6 +488,7 @@ SHELL_SERVER_ONLY = \ @top_srcdir@/js/server/tests/shell-database-noncluster.js \ @top_srcdir@/js/server/tests/shell-foxx.js \ @top_srcdir@/js/server/tests/shell-foxx-repository-spec.js \ + @top_srcdir@/js/server/tests/shell-foxx-repository-events-spec.js \ @top_srcdir@/js/server/tests/shell-foxx-query-spec.js \ @top_srcdir@/js/server/tests/shell-foxx-model.js \ @top_srcdir@/js/server/tests/shell-foxx-model-events-spec.js \ diff --git a/js/server/modules/org/arangodb/foxx/model.js b/js/server/modules/org/arangodb/foxx/model.js index df7290c39b..3f7b5aaf02 100644 --- a/js/server/modules/org/arangodb/foxx/model.js +++ b/js/server/modules/org/arangodb/foxx/model.js @@ -62,16 +62,15 @@ var Model, /// @endDocuBlock //////////////////////////////////////////////////////////////////////////////// -excludeExtraAttributes = function (attributes, Model) { +excludeExtraAttributes = function (attributes, model) { 'use strict'; - var extraAttributeNames; - if (Model.prototype.schema) { - extraAttributeNames = _.difference( - _.keys(metadataSchema), - _.keys(Model.prototype.schema) - ); + if (!model.schema) { + return _.clone(attributes); } - return _.omit(attributes, extraAttributeNames); + return _.omit(attributes, _.difference( + _.keys(metadataSchema), + _.keys(model.schema) + )); }; Model = function (attributes) { @@ -111,25 +110,32 @@ Model = function (attributes) { this.errors = {}; - var instance = this; - if (instance.schema) { + if (this.schema) { + if (this.schema.isJoi) { + this.schema = _.object(_.map(this.schema._inner.children, function (prop) { + return [prop.key, prop.schema]; + })); + } _.each( - _.union(_.keys(instance.schema), _.keys(attributes)), - function (attributeName) { - instance.set(attributeName, attributes ? attributes[attributeName] : undefined); - } + _.union(_.keys(this.schema), _.keys(attributes)), + function (key) { + this.set(key, attributes && attributes[key]); + }, + this ); } else if (attributes) { - instance.attributes = _.clone(attributes); + this.attributes = _.clone(attributes); } - EventEmitter.call(instance); + EventEmitter.call(this); }; util.inherits(Model, EventEmitter); Model.fromClient = function (attributes) { 'use strict'; - return new this(excludeExtraAttributes(attributes, this)); + var model = new this(); + model.set(excludeExtraAttributes(attributes, model)); + return model; }; // Instance Properties @@ -187,18 +193,12 @@ _.extend(Model.prototype, { _.each(attributeName, function (value, key) { this.set(key, value); }, this); - return; + return this; } if (this.schema) { - var schema = ( - this.schema[attributeName] || - metadataSchema[attributeName] || - joi.forbidden() - ), - result = ( - schema.isJoi ? schema : joi.object().keys(schema) - ).validate(value); + var schema = this.schema[attributeName] || metadataSchema[attributeName] || joi.forbidden(); + var result = schema.validate(value); if (result.error) { this.errors[attributeName] = result.error; @@ -209,6 +209,7 @@ _.extend(Model.prototype, { this.isValid = true; } } + if (result.value === undefined) { delete this.attributes[attributeName]; } else { @@ -217,6 +218,8 @@ _.extend(Model.prototype, { } else { this.attributes[attributeName] = value; } + + return this; }, //////////////////////////////////////////////////////////////////////////////// @@ -270,7 +273,7 @@ _.extend(Model.prototype, { forClient: function () { 'use strict'; - return excludeExtraAttributes(this.attributes, this.constructor); + return excludeExtraAttributes(this.attributes, this); } }); diff --git a/js/server/modules/org/arangodb/foxx/repository.js b/js/server/modules/org/arangodb/foxx/repository.js index c33b50a3d7..e7488a636d 100644 --- a/js/server/modules/org/arangodb/foxx/repository.js +++ b/js/server/modules/org/arangodb/foxx/repository.js @@ -34,7 +34,9 @@ var Repository, ArangoError = arangodb.ArangoError, ArangoCollection = arangodb.ArangoCollection, errors = arangodb.errors, - extend = require('extendible'); + extend = require('extendible'), + EventEmitter = require('events').EventEmitter, + util = require('util'); //////////////////////////////////////////////////////////////////////////////// /// @startDocuBlock JSF_foxx_repository_initializer @@ -127,8 +129,12 @@ Repository = function (collection, opts) { this.collection.ensureIndex(index); }, this); } + + EventEmitter.call(this); }; +util.inherits(Repository, EventEmitter); + // ----------------------------------------------------------------------------- // --SECTION-- Methods // ----------------------------------------------------------------------------- @@ -155,11 +161,15 @@ _.extend(Repository.prototype, { //////////////////////////////////////////////////////////////////////////////// save: function (model) { 'use strict'; + this.emit('beforeCreate', model); model.emit('beforeCreate'); + this.emit('beforeSave', model); model.emit('beforeSave'); var id_and_rev = this.collection.save(model.forDB()); model.set(id_and_rev); + this.emit('afterSave', model); model.emit('afterSave'); + this.emit('afterCreate', model); model.emit('afterCreate'); return model; }, @@ -316,9 +326,11 @@ _.extend(Repository.prototype, { //////////////////////////////////////////////////////////////////////////////// remove: function (model) { 'use strict'; + this.emit('beforeRemove', model); model.emit('beforeRemove'); var id = model.get('_id'), result = this.collection.remove(id); + this.emit('afterRemove', model); model.emit('afterRemove'); return result; }, @@ -457,13 +469,17 @@ _.extend(Repository.prototype, { //////////////////////////////////////////////////////////////////////////////// update: function (model, data) { 'use strict'; + this.emit('beforeUpdate', model, data); model.emit('beforeUpdate', data); + this.emit('beforeSave', model, data); model.emit('beforeSave', data); var id = model.get("_id") || model.get("_key"), id_and_rev = this.collection.update(id, data); model.set(data); model.set(id_and_rev); + this.emit('afterSave', model, data); model.emit('afterSave', data); + this.emit('afterUpdate', model, data); model.emit('afterUpdate', data); return model; }, diff --git a/js/server/tests/shell-foxx-model.js b/js/server/tests/shell-foxx-model.js index c52b5b4263..12398ac93e 100644 --- a/js/server/tests/shell-foxx-model.js +++ b/js/server/tests/shell-foxx-model.js @@ -194,6 +194,37 @@ function ModelSpec () { assertTrue(instance.isValid); }, + testJoiObject: function () { + var Model = FoxxModel.extend({ + schema: joi.object().keys({ + lol: joi.string() + }) + }); + + instance = new Model({lol: 5}); + assertEqual(_.keys(instance.attributes).length, 1); + assertEqual(instance.get("lol"), 5); + assertEqual(_.keys(instance.errors).length, 1); + assertFalse(instance.isValid); + }, + + testAttributeDefaults: function () { + var special = function () { + return 42; + }; + + var Model = FoxxModel.extend({ + schema: { + aString: joi.any().default("potato"), + special: joi.any().default(special, "current date") + } + }); + + instance = new Model(); + assertEqual(instance.get("aString"), "potato"); + assertEqual(instance.get("special"), special()); + }, + testCoerceAttributes: function () { var Model = FoxxModel.extend({ schema: { diff --git a/js/server/tests/shell-foxx-repository-events-spec.js b/js/server/tests/shell-foxx-repository-events-spec.js new file mode 100644 index 0000000000..03e4ad32f4 --- /dev/null +++ b/js/server/tests/shell-foxx-repository-events-spec.js @@ -0,0 +1,85 @@ +/*global require, describe, expect, it, beforeEach, createSpyObj */ + +var FoxxRepository = require("org/arangodb/foxx/repository").Repository, + Model = require("org/arangodb/foxx/model").Model; + +describe('Model Events', function () { + 'use strict'; + + var collection, instance, repository; + + beforeEach(function () { + collection = createSpyObj('collection', [ + 'update', + 'save', + 'remove' + ]); + instance = new Model(); + repository = new FoxxRepository(collection, {model: Model, random: '', beforeCalled: false, afterCalled: false}); + }); + + it('should be possible to subscribe and emit events', function () { + expect(repository.on).toBeDefined(); + expect(repository.emit).toBeDefined(); + }); + + it('should emit beforeCreate and afterCreate events when creating the model', function () { + addHooks(repository, instance, 'Create'); + expect(repository.save(instance)).toEqual(instance); + expect(repository.beforeCalled).toBe(true); + expect(repository.afterCalled).toBe(true); + }); + + it('should emit beforeSave and afterSave events when creating the model', function () { + addHooks(repository, instance, 'Save'); + expect(repository.save(instance)).toEqual(instance); + expect(repository.beforeCalled).toBe(true); + expect(repository.afterCalled).toBe(true); + }); + + it('should emit beforeUpdate and afterUpdate events when updating the model', function () { + var newData = { newAttribute: 'test' }; + addHooks(repository, instance, 'Update', newData); + expect(repository.update(instance, newData)).toEqual(instance); + expect(repository.beforeCalled).toBe(true); + expect(repository.afterCalled).toBe(true); + }); + + it('should emit beforeSave and afterSave events when updating the model', function () { + var newData = { newAttribute: 'test' }; + addHooks(repository, instance, 'Save', newData); + expect(repository.update(instance, newData)).toEqual(instance); + expect(repository.beforeCalled).toBe(true); + expect(repository.afterCalled).toBe(true); + }); + + it('should emit beforeRemove and afterRemove events when removing the model', function () { + addHooks(repository, instance, 'Remove'); + repository.remove(instance); + expect(repository.beforeCalled).toBe(true); + expect(repository.afterCalled).toBe(true); + }); + +}); + +function addHooks(repo, model, ev, dataToReceive) { + 'use strict'; + + var random = String(Math.floor(Math.random() * 1000)); + + repo.on('before' + ev, function (self, data) { + expect(this).toEqual(repo); + expect(self).toEqual(model); + expect(data).toEqual(dataToReceive); + this.random = random; + this.beforeCalled = true; + }); + repo.on('after' + ev, function (self, data) { + expect(this).toEqual(repo); + expect(self).toEqual(model); + expect(data).toEqual(dataToReceive); + this.afterCalled = true; + expect(this.beforeCalled).toBe(true); + expect(this.random).toEqual(random); + }); +} \ No newline at end of file