├── .gitignore ├── index.js ├── example ├── public │ ├── images │ │ └── back.png │ └── css │ │ ├── reset.css │ │ └── global.css ├── views │ ├── users │ │ ├── show.jade │ │ └── _form.jade │ ├── posts │ │ ├── _comment_form.jade │ │ ├── show.jade │ │ └── _form.jade │ ├── layout.jade │ └── public │ │ └── index.jade ├── controllers │ ├── public.js │ ├── users.js │ └── posts.js ├── models │ ├── comment.js │ ├── post.js │ └── user.js └── app.js ├── Makefile ├── package.json ├── lib ├── utils.js ├── helpers │ ├── mapper.js │ ├── update_document.js │ ├── namespacer.js │ └── collection_proxy.js ├── validator.js └── model.js ├── test ├── helpers_mapper_test.js ├── helpers_namespacer_test.js ├── validator_test.js ├── integration_test.js ├── helpers_update_document_test.js ├── model_test.js └── helpers_collection_proxy_test.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | exports.model = require('./lib/model'); 2 | exports.validator = require('./lib/validator'); 3 | -------------------------------------------------------------------------------- /example/public/images/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/masylum/mongolia/HEAD/example/public/images/back.png -------------------------------------------------------------------------------- /example/views/users/show.jade: -------------------------------------------------------------------------------- 1 | #forms 2 | != partial('form', {user: user, action: '/users/' + user._id + '/'}) 3 | 4 | #lists 5 | h2 User Posts 6 | -if (posts) 7 | ul.list 8 | -each post in posts 9 | li 10 | a(href: '/posts/' + post._id + '/')= post.title 11 | -------------------------------------------------------------------------------- /example/views/posts/_comment_form.jade: -------------------------------------------------------------------------------- 1 | -var post = post || {}; 2 | 3 | form#comment_post(method: 'POST', action: '/posts/' + post._id + '/comments') 4 | h2 Comment 5 | fieldset 6 | .field 7 | label(for: 'comment_body') comment body 8 | textarea(id: 'comment_body', name: 'comment[body]') 9 | 10 | .operations 11 | input(type: 'submit', value: 'submit', class: 'button') 12 | -------------------------------------------------------------------------------- /example/views/posts/show.jade: -------------------------------------------------------------------------------- 1 | #forms 2 | != partial('form', {post: post, action: '/posts/' + post._id + '/'}) 3 | != partial('comment_form', {post: post}) 4 | 5 | #lists 6 | h2 Author 7 | -if (post.author) 8 | a(href: '/users/' + post.author._id + '/')= post.author.name 9 | 10 | h2 Comments 11 | -if (post.comments) 12 | ul.list 13 | -each comment in post.comments 14 | li= comment.body 15 | -------------------------------------------------------------------------------- /example/views/layout.jade: -------------------------------------------------------------------------------- 1 | !!! 2 | html 3 | head 4 | title= title || '' 5 | link(rel: 'stylesheet', href: '/css/reset.css' ) 6 | link(rel: 'stylesheet', href: '/css/global.css' ) 7 | 8 | -var css 9 | -if(css) 10 | -each file in css 11 | link(rel: 'stylesheet', href: '/css/' + file + '.css' ) 12 | body 13 | #wrapper 14 | #sticky 15 | nav 16 | h1 17 | a(href: '/') Mongolia blog example 18 | .clearer 19 | 20 | #content 21 | != body 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MOCHA = ./node_modules/.bin/mocha --reporter dot 2 | 3 | test: 4 | @$(MOCHA) 5 | 6 | test_model: 7 | @$(MOCHA) test/model_test 8 | 9 | test_validator: 10 | @$(MOCHA) test/validator_test.js 11 | 12 | test_update_document: 13 | @$(MOCHA) test/helpers_update_document_test.js 14 | 15 | test_namespacer: 16 | @$(MOCHA) test/helpers_namespacer_test.js 17 | 18 | test_mapper: 19 | @$(MOCHA) test/helpers_mapper_test.js 20 | 21 | test_proxy: 22 | @$(MOCHA) test/helpers_collection_proxy_test.js 23 | 24 | test_integration: 25 | @$(MOCHA) test/integration_test.js 26 | 27 | .PHONY: test 28 | -------------------------------------------------------------------------------- /example/views/public/index.jade: -------------------------------------------------------------------------------- 1 | #forms 2 | != partial('users/form') 3 | != partial('posts/form') 4 | 5 | #lists 6 | h2 List users 7 | -if(users) 8 | ul.list 9 | -each user in users 10 | li 11 | a(href: '/users/' + user._id + '/')= user.name 12 | 13 | h2 List posts 14 | -if(posts) 15 | ul.list 16 | -each post in posts 17 | li 18 | a(href: '/posts/' + post._id + '/')= post.title 19 | 20 | h2 List comments 21 | -if(comments) 22 | ul.list 23 | -each comment in comments 24 | li 25 | span= comment.body 26 | | 27 | a(href: '/posts/' + comment.post._id + '/')= comment.post.title 28 | -------------------------------------------------------------------------------- /example/views/posts/_form.jade: -------------------------------------------------------------------------------- 1 | -var post = post || {}; 2 | -var action = action || '/posts/'; 3 | 4 | form#create_post(method: 'POST', action: action) 5 | h2 Post 6 | fieldset 7 | .field 8 | label(for: 'post_title) Post title 9 | input(id: 'post_title', name: 'post[title]', value: post.title) 10 | 11 | .field 12 | label(for: 'post_body') Post body 13 | textarea(id: 'post_body', name: 'post[body]')= post.body || '' 14 | 15 | .field 16 | label(for: 'post_author_id') Post author 17 | select(id: 'post_author_id', name: 'post[author][_id]') 18 | -each user in users 19 | option(value: user._id)= user.name 20 | 21 | .operations 22 | input(type: 'submit', value: 'submit', class: 'button') 23 | -------------------------------------------------------------------------------- /example/controllers/public.js: -------------------------------------------------------------------------------- 1 | module.exports = function (APP) { 2 | var app = APP.app 3 | , User = APP.loadModel('user') 4 | , Post = APP.loadModel('post') 5 | , Comment = APP.loadModel('comment'); 6 | 7 | app.get('/', function (req, res) { 8 | var funk = require('funk')(); 9 | 10 | Post().mongo('findArray', {}, funk.result('posts')); 11 | User().mongo('findArray', {}, funk.result('users')); 12 | Comment().mongo('findArray', {}, funk.result('comments')); 13 | 14 | funk.parallel(function () { 15 | if (this.errors) throw Error(this.errors[0]); 16 | 17 | res.render('public/index', { 18 | title: 'home' 19 | , users: this.users 20 | , posts: this.posts 21 | , comments: this.comments 22 | }); 23 | }); 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongolia", 3 | "description": "Layer on top of the mongodb driver to implement data logic.", 4 | "version": "1.5.1", 5 | "author": "Pau Ramon ", 6 | "contributors": [ 7 | {"name": "Pau Ramon", "email": "masylum@gmail.com"}, 8 | {"name": "Josep M. Bach", "email": "josep.m.bach@gmail.com"} 9 | ], 10 | "keywords": ["mongo", "mongodb", "models", "database", "db"], 11 | "main": "./index", 12 | "dependencies": { 13 | "mongodb": "=1.0.1", 14 | "funk": "1.0.1", 15 | "underscore": "1.1.6" 16 | }, 17 | "devDependencies": { 18 | "mocha": "*", 19 | "testosterone": "1.2.0", 20 | "sinon": "1.3.4" 21 | }, 22 | "repository" : {"type": "git" , "url": "http://github.com/masylum/mongolia.git" }, 23 | "engines": { "node": ">= 0.4.0" } 24 | } 25 | -------------------------------------------------------------------------------- /example/models/comment.js: -------------------------------------------------------------------------------- 1 | module.exports = function (APP) { 2 | var COMMENT = APP.model(APP.db, 'comments'), 3 | Post = APP.loadModel('post'); 4 | 5 | COMMENT.skeletons = { 6 | post: ['_id', 'title'] 7 | }; 8 | 9 | COMMENT.validate = function (document, update, callback) { 10 | var validator = APP.validator(document, update); 11 | 12 | validator.validateExistence({ 13 | body: 'Body is mandatory', 14 | post: 'A comment needs a post' 15 | }); 16 | 17 | callback(null, validator); 18 | }; 19 | 20 | COMMENT.afterInsert = function (documents, callback) { 21 | var funk = require('funk')(); 22 | 23 | documents.forEach(function (document) { 24 | Post().pushEmbeddedDocument({_id: document.post._id}, 'comments', document, funk.nothing()); 25 | }); 26 | 27 | funk.parallel(callback); 28 | }; 29 | 30 | return COMMENT; 31 | }; 32 | -------------------------------------------------------------------------------- /example/views/users/_form.jade: -------------------------------------------------------------------------------- 1 | -var user = user || {}; 2 | -var action = action || '/users/'; 3 | 4 | form#create_user(method: 'POST', action: action) 5 | h2 User 6 | fieldset 7 | .field 8 | label(for: 'user_name') User name 9 | input(id: 'user_name', name: 'user[name]', type: 'text', value: user.name) 10 | 11 | .field 12 | label(for: 'user_email') User email 13 | input(id: 'user_email', name: 'user[email]', type: 'text', value: user.email) 14 | 15 | .field 16 | label(for: 'user_password') User password 17 | input(id: 'user_password', name: 'user[password]', type: 'password', value: user.password) 18 | 19 | .field 20 | label(for: 'user_password_confirmation') User password 21 | input(id: 'user_password_confirmation', name: 'user[password_confirmation]', type: 'password', value: user.password_confirmation) 22 | 23 | .operations 24 | input(type: 'submit', value: 'submit', class: 'button') 25 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | var UTILS = {}; 2 | 3 | /* explores an object given an attribute 4 | * or a `dot_notation` attribute 5 | * 6 | * @param {Object} obj 7 | * @param {String} attr 8 | * @param {Boolean} create 9 | * 10 | * @return {Array} [obj, attr] 11 | */ 12 | UTILS.inspect = function (obj, attr, create) { 13 | var attr_parts = attr.split('.') 14 | , last, current; 15 | 16 | if (attr_parts.length === 1) { 17 | return [obj, attr]; 18 | } else { 19 | last = obj; 20 | for (;attr_parts.length > 0;) { 21 | current = attr_parts.shift(); 22 | 23 | if (!last) { 24 | return [undefined, current]; 25 | } 26 | 27 | if (create && !last[current]) { 28 | last[current] = attr_parts.length ? {} : undefined; 29 | } 30 | 31 | if (!attr_parts.length) { 32 | return [last, current]; 33 | } 34 | 35 | last = last[current]; 36 | } 37 | } 38 | }; 39 | 40 | //exports 41 | module.exports = UTILS; 42 | -------------------------------------------------------------------------------- /example/models/post.js: -------------------------------------------------------------------------------- 1 | module.exports = function (APP) { 2 | var POST = APP.model(APP.db, 'posts'), 3 | Comment = APP.loadModel('comment'), 4 | ObjectID = APP.ObjectID; 5 | 6 | POST.skeletons = { 7 | author: ['_id', 'name'], 8 | comments: ['_id', 'body'] 9 | }; 10 | 11 | POST.validate = function (document, update, callback) { 12 | var validator = APP.validator(document, update); 13 | 14 | validator.validateExistence({ 15 | title: 'Title is mandatory', 16 | body: 'Body is mandatory', 17 | author: 'A Post needs an author' 18 | }); 19 | 20 | callback(null, validator); 21 | }; 22 | 23 | POST.addComment = function (post, comment, callback) { 24 | comment.post = Comment().getEmbeddedDocument('post', post); 25 | Comment().validateAndInsert(comment, callback); 26 | }; 27 | 28 | POST.afterUpdate = function (query, update, callback) { 29 | Comment().updateEmbeddedDocument({_id: query._id}, 'post', update, {}, callback); 30 | }; 31 | 32 | return POST; 33 | }; 34 | -------------------------------------------------------------------------------- /example/models/user.js: -------------------------------------------------------------------------------- 1 | module.exports = function (APP) { 2 | var USER = APP.model(APP.db, 'users'), 3 | Post = APP.loadModel('post'); 4 | 5 | USER.validate = function (document, update, callback) { 6 | var validator = APP.validator(document, update); 7 | 8 | validator.validateRegex({ 9 | name: [/[a-zA-Z_\s]{3,20}/, 'Incorrect name'], 10 | email: [validator.regex.email, 'Incorrect email'], 11 | password: [validator.regex.password, 'Incorrect password'] 12 | }); 13 | 14 | if (validator.attrChanged('password')) { 15 | validator.validateConfirmation({ 16 | password: ['password_confirmation', 'Passwords must match'] 17 | }); 18 | } 19 | 20 | if (!validator.isUpdating()) { 21 | validator.validateQuery({ 22 | name: [this, {name: update.name}, false, 'There is already a user with this name'], 23 | email: [this, {email: update.email}, false, 'There is already a user with this email'] 24 | }, function () { 25 | callback(null, validator); 26 | }); 27 | } else { 28 | callback(null, validator); 29 | } 30 | }; 31 | 32 | USER.afterUpdate = function (query, update, callback) { 33 | Post().updateEmbeddedDocument({'_id': query._id}, 'author', update, callback); 34 | }; 35 | 36 | return USER; 37 | }; 38 | -------------------------------------------------------------------------------- /example/app.js: -------------------------------------------------------------------------------- 1 | require('colors'); 2 | 3 | var mongodb = require('mongodb') 4 | , Db = mongodb.Db 5 | , Server = mongodb.Server 6 | , express = require('express') 7 | 8 | , model = require('./../lib/model') 9 | , validator = require('./../lib/validator') 10 | 11 | , db = new Db('blog', new Server('localhost', 27017, {auto_reconnect: true, native_parser: true}), {}) 12 | , ObjectID = db.bson_serializer.ObjectID 13 | 14 | , app = express.createServer() 15 | 16 | , APP = { 17 | app: app 18 | , db: db 19 | , ObjectID: ObjectID 20 | , options: {port: 3000} 21 | , model: model 22 | , validator: validator 23 | }; 24 | 25 | APP.loadModel = function (str) { 26 | return function () { 27 | return require('./models/' + str)(APP); 28 | }; 29 | }; 30 | 31 | // Configure 32 | app.configure(function () { 33 | app.set('views', __dirname + '/views'); 34 | app.set('view engine', 'jade'); 35 | 36 | // middlewares 37 | app.use(express.favicon()); 38 | app.use(express['static'](__dirname + '/public')); 39 | app.use(express.bodyParser()); 40 | app.use(app.router); 41 | }); 42 | 43 | // Controllers 44 | require('./controllers/public')(APP); 45 | require('./controllers/users')(APP); 46 | require('./controllers/posts')(APP); 47 | 48 | console.log('Opening database '.blue); 49 | db.open(function () { 50 | app.listen(APP.options.port); 51 | console.log('Listening port '.green + (APP.options.port).toString().yellow); 52 | }); 53 | -------------------------------------------------------------------------------- /example/controllers/users.js: -------------------------------------------------------------------------------- 1 | module.exports = function (APP) { 2 | var app = APP.app 3 | , User = APP.loadModel('user') 4 | , ObjectID = APP.ObjectID; 5 | 6 | // show user 7 | app.get('/users/:id', function (req, res) { 8 | var id = req.param('id'); 9 | 10 | User().mongo('findOne', {_id: new ObjectID(id)}, function (error, user) { 11 | if (error) throw error; 12 | 13 | res.render('users/show', { 14 | title: 'Paco' 15 | , user: user 16 | , posts: user.posts 17 | }); 18 | }); 19 | }); 20 | 21 | // create user 22 | app.post('/users', function (req, res) { 23 | var document = req.param('user'); 24 | 25 | User().getCollection(function (error, collection) { 26 | document._id = collection.pkFactory.createPk(); 27 | 28 | User().validateAndInsert(document, function (error, validator) { 29 | if (error) throw error; 30 | 31 | if (validator.hasErrors()) { 32 | // Show flash error 33 | console.log(validator.errors); 34 | res.redirect('/'); 35 | } else { 36 | res.redirect('/users/' + document._id + '/'); 37 | } 38 | }); 39 | }); 40 | }); 41 | 42 | // update user 43 | app.post('/users/:id', function (req, res) { 44 | var id = req.param('id') 45 | , update = req.param('user'); 46 | 47 | User().validateAndUpdate({_id: new ObjectID(id)}, {'$set': update}, function (error, validator) { 48 | if (error) throw error; 49 | if (validator.hasErrors()) console.log(validator.errors); 50 | 51 | res.redirect('/users/' + id + '/'); 52 | }); 53 | }); 54 | }; 55 | -------------------------------------------------------------------------------- /test/helpers_mapper_test.js: -------------------------------------------------------------------------------- 1 | /*global describe, it*/ 2 | var assert = require('assert') 3 | , Mapper = require('./../lib/helpers/mapper'); 4 | 5 | describe('mapper', function () { 6 | it('`mapDocument` should filter documents before being inserted or updated', function () { 7 | var test = Mapper.mapDocument 8 | , arg 9 | , toUpper = function (val) { 10 | return val.toUpperCase(); 11 | } 12 | , update = {zemba: 'FOO', nested: {id: 123, name: '300'}, list: [1, 2, 3], foo: true} 13 | , maps = { zemba: toUpper 14 | , nested: { id: Number 15 | , name: String 16 | } 17 | , list: Number 18 | , foo: Boolean 19 | }; 20 | 21 | // document 22 | arg = {zemba: 'foo', nested: {id: '123', name: 300}, list: [1, 2, 3], foo: 'true'}; 23 | test(maps, arg); 24 | assert.deepEqual(arg, update); 25 | assert.equal(typeof arg.nested.id, typeof update.nested.id); 26 | assert.equal(typeof arg.nested.name, typeof update.nested.name); 27 | 28 | // array 29 | arg = [{zemba: 'fleiba'}, {zemba: 'foo', nested: {id: '123', name: 300}, list: [1, 2, 3], foo: 'true'}]; 30 | test(maps, arg); 31 | assert.deepEqual(arg[0], {zemba: 'FLEIBA'}); 32 | assert.deepEqual(arg[1], update); 33 | assert.equal(typeof arg[1].nested.id, typeof update.nested.id); 34 | assert.equal(typeof arg[1].nested.name, typeof update.nested.name); 35 | 36 | // with sepcial ops 37 | arg = {'$set': {zemba: 'foo', nested: {id: '123', name: 300}, list: [1, 2, 3], foo: 'true'}}; 38 | test(maps, arg); 39 | assert.deepEqual(arg, {'$set': update}); 40 | assert.equal(typeof arg.$set.nested.id, typeof update.nested.id); 41 | assert.equal(typeof arg.$set.nested.name, typeof update.nested.name); 42 | 43 | // dot notation 44 | arg = {'$set': {'nested.id': '123', 'nested.name': 300, foo: 'true'}}; 45 | test(maps, arg); 46 | assert.equal(typeof arg.$set['nested.id'], typeof update.nested.id); 47 | assert.equal(typeof arg.$set['nested.name'], typeof update.nested.name); 48 | 49 | // array values 50 | arg = {list: ['1', '2', 3]}; 51 | test(maps, arg); 52 | assert.equal(typeof arg.list[0], typeof update.list[0]); 53 | assert.equal(typeof arg.list[1], typeof update.list[1]); 54 | assert.equal(typeof arg.list[2], typeof update.list[2]); 55 | }); 56 | }); 57 | 58 | -------------------------------------------------------------------------------- /lib/helpers/mapper.js: -------------------------------------------------------------------------------- 1 | var MAPPER = {} 2 | , _ = require('underscore'); 3 | 4 | MAPPER.mapDocument = function (maps, arg) { 5 | 6 | /* 0: dont match 7 | * 1: partial matching, needs more inspection 8 | * 2: fully matching 9 | */ 10 | function isMatching(maps, attr, level) { 11 | var ret = 0; 12 | 13 | Object.keys(maps).some(function (el) { 14 | var match; 15 | if (attr.indexOf('.') !== -1) { 16 | match = attr.match(new RegExp(el + "(\..*)?")); 17 | if (match) { 18 | ret = 2; 19 | } 20 | } else { 21 | match = el === attr; 22 | if (match) { 23 | ret = typeof maps[el] === 'object' ? 1 : 2; 24 | } 25 | } 26 | return match; 27 | }); 28 | 29 | return ret; 30 | } 31 | 32 | function getObject(obj, attr) { 33 | // dot notation 34 | if (attr.indexOf('.') !== -1) { 35 | return attr.split('.').reduce(function (memo, part) { 36 | memo = memo[part]; 37 | return memo; 38 | }, obj); 39 | } else { 40 | return obj[attr]; 41 | } 42 | } 43 | 44 | function filter(maps, el, level) { 45 | var attr, is_matching; 46 | 47 | for (attr in el) { 48 | if (el.hasOwnProperty(attr)) { 49 | // special op 50 | if (attr[0] === '$') { 51 | filter(maps, el[attr], level); 52 | } else { 53 | is_matching = isMatching(maps, attr, level); 54 | if (is_matching === 2) { 55 | if (Array.isArray(el[attr])) { 56 | el[attr].forEach(function(value, i) { 57 | el[attr][i] = getObject(maps, attr)(el[attr][i]); 58 | }); 59 | } else { 60 | el[attr] = getObject(maps, attr)(el[attr]); 61 | } 62 | } else { 63 | if (is_matching === 1 && typeof el[attr] === 'object') { 64 | filter(maps[attr], el[attr], level + 1); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | } 71 | 72 | if (!_.isEmpty(arg)) { 73 | if (Array.isArray(arg)) { 74 | arg.forEach(function (el) { 75 | filter(maps, el, 0); 76 | }); 77 | } else { 78 | filter(maps, arg, 0); 79 | } 80 | } 81 | }; 82 | 83 | MAPPER.map = function (maps, fn, args) { 84 | if (fn === 'insert') { 85 | MAPPER.mapDocument(maps, args[0]); 86 | } else if (fn === 'update') { 87 | MAPPER.mapDocument(maps, args[1]); 88 | } 89 | }; 90 | 91 | module.exports = MAPPER; 92 | -------------------------------------------------------------------------------- /example/controllers/posts.js: -------------------------------------------------------------------------------- 1 | module.exports = function (APP) { 2 | var app = APP.app 3 | , User = APP.loadModel('user') 4 | , Post = APP.loadModel('post') 5 | , ObjectID = APP.ObjectID 6 | 7 | , embedAuthor = function (document, callback) { 8 | User().mongo('findOne', {_id: new ObjectID(document.author._id)}, function (error, user) { 9 | if (error) callback(error); 10 | 11 | document.author = Post().getEmbeddedDocument('author', user); 12 | callback(null, document); 13 | }); 14 | }; 15 | 16 | // show post 17 | app.get('/posts/:id', function (req, res) { 18 | var id = req.param('id') 19 | , funk = require('funk')(); 20 | 21 | User().mongo('findArray', {}, funk.result('users')); 22 | Post().mongo('findOne', {_id: new ObjectID(id)}, funk.result('post')); 23 | 24 | funk.parallel(function () { 25 | res.render('posts/show', { 26 | title: this.post.title 27 | , post: this.post 28 | , users: this.users 29 | }); 30 | }); 31 | }); 32 | 33 | // create post 34 | app.post('/posts', function (req, res) { 35 | var document = req.param('post') 36 | , funk = require('funk')(); 37 | 38 | Post().getCollection(funk.add(function (error, collection) { 39 | document._id = collection.pkFactory.createPk(); 40 | })); 41 | embedAuthor(document, funk.nothing()); 42 | 43 | funk.parallel(function () { 44 | Post().validateAndInsert(document, function (error, validator) { 45 | if (error) throw error; 46 | 47 | if (validator.hasErrors()) { 48 | // Show flash error 49 | console.log(validator.errors); 50 | res.redirect('/'); 51 | } else { 52 | res.redirect('/posts/' + document._id + '/'); 53 | } 54 | }); 55 | }); 56 | }); 57 | 58 | // update post 59 | app.post('/posts/:id', function (req, res) { 60 | var id = req.param('id') 61 | , update = req.param('post'); 62 | 63 | embedAuthor(update, function (error, update) { 64 | Post().validateAndUpdate({_id: new ObjectID(id)}, {'$set': update}, function (error, validator) { 65 | if (error) throw error; 66 | if (validator.hasErrors()) console.log(validator.errors); 67 | 68 | res.redirect('/posts/' + id + '/'); 69 | }); 70 | }); 71 | }); 72 | 73 | // create comment 74 | app.post('/posts/:id/comments', function (req, res) { 75 | var id = req.param('id') 76 | , comment = req.param('comment'); 77 | 78 | Post().mongo('findOne', {_id: new ObjectID(id)}, function (error, post) { 79 | if (error) throw error; 80 | Post().addComment(post, comment, function (error, document) { 81 | if (error) throw error; 82 | res.redirect('/posts/' + id + '/'); 83 | }); 84 | }); 85 | }); 86 | }; 87 | -------------------------------------------------------------------------------- /lib/helpers/update_document.js: -------------------------------------------------------------------------------- 1 | /* updates a document client side 2 | * so you can have a representation of that document 3 | * after being updated */ 4 | var _ = require('underscore') 5 | , utils = require('../utils'); 6 | 7 | function _strategy(op, ret, key, value) { 8 | var is_destructive = ['$unset', '$pop', '$pull', '$pullAll', '$rename'].indexOf(op) !== -1 9 | , parsed = utils.inspect(ret, key, !is_destructive); 10 | 11 | ret = parsed[0]; 12 | key = parsed[1]; 13 | 14 | switch (op) { 15 | case '$inc': 16 | ret[key] = ret[key] ? ret[key] + value : value; 17 | break; 18 | case '$set': 19 | ret[key] = value; 20 | break; 21 | case '$unset': 22 | if (typeof ret !== 'undefined') { 23 | delete ret[key]; 24 | } 25 | break; 26 | case '$push': 27 | if (ret[key]) { 28 | ret[key].push(value); 29 | } else { 30 | ret[key] = [value]; 31 | } 32 | break; 33 | case '$pushAll': 34 | if (ret[key]) { 35 | value.forEach(function (el) { 36 | ret[key].push(el); 37 | }); 38 | } else { 39 | ret[key] = value; 40 | } 41 | break; 42 | case '$addToSet': 43 | if (ret[key]) { 44 | if (ret[key].indexOf(value) === -1) { 45 | ret[key].push(value); 46 | } 47 | } else { 48 | ret[key] = [value]; 49 | } 50 | break; 51 | case '$pop': 52 | if (typeof ret !== 'undefined') { 53 | if (value === 1) { 54 | ret[key].pop(value); 55 | } else { 56 | ret[key].shift(value); 57 | } 58 | } 59 | break; 60 | case '$pull': 61 | if (typeof ret !== 'undefined') { 62 | ret[key] = ret[key].filter(function (f) { 63 | return f !== value; 64 | }); 65 | } 66 | break; 67 | case '$pullAll': 68 | if (typeof ret !== 'undefined') { 69 | ret[key] = ret[key].filter(function (f) { 70 | return value.indexOf(f) === -1; 71 | }); 72 | } 73 | break; 74 | case '$rename': 75 | if (typeof ret !== 'undefined') { 76 | ret[value] = ret[key]; 77 | delete ret[key]; 78 | } 79 | break; 80 | } 81 | } 82 | 83 | module.exports = function (VALIDATOR) { 84 | var keys = Object.keys(VALIDATOR.update) 85 | , special_op = keys[0] && keys[0][0] === '$' 86 | , ret = special_op ? _.clone(VALIDATOR.document) : _.clone(VALIDATOR.update) 87 | , op, key; 88 | 89 | 90 | if (special_op) { 91 | for (op in VALIDATOR.update) { 92 | if (VALIDATOR.update.hasOwnProperty(op)) { 93 | for (key in VALIDATOR.update[op]) { 94 | if (VALIDATOR.update[op].hasOwnProperty(key)) { 95 | _strategy(op, ret, key, VALIDATOR.update[op][key]); 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | return ret; 103 | }; 104 | -------------------------------------------------------------------------------- /lib/helpers/namespacer.js: -------------------------------------------------------------------------------- 1 | var NAMESPACER = {} 2 | , _ = require('underscore') 3 | , _getFields = function (visibility) { 4 | return visibility.reduce(function (memo, el) { 5 | memo[el] = 1; 6 | return memo; 7 | }, {}); 8 | }; 9 | 10 | NAMESPACER.resolveNamespace = function (namespaces, namespace) { 11 | var ret = [] 12 | , ns = namespaces[namespace] 13 | , attr; 14 | 15 | if (Array.isArray(ns)) { 16 | return ns; 17 | } else { 18 | if (ns.extend) { 19 | ret = namespaces[ns.extend]; 20 | } 21 | 22 | if (ns.add) { 23 | ret = ret.concat(ns.add); 24 | } 25 | 26 | if (ns.remove) { 27 | ret = _.filter(ret, function (value) { 28 | return !_.include(ns.remove, value); 29 | }); 30 | } 31 | return ret; 32 | } 33 | }; 34 | 35 | NAMESPACER.addFieldFind = function (visibility, args) { 36 | var current_fields = args.length >= 3 ? args[1] : undefined 37 | , fields = _getFields(visibility) 38 | , tests = ['limit', 'sort', 'fields', 'skip', 'hint', 'explain', 'snapshot', 'timeout', 'tailable', 'batchSize'] 39 | , is_option = args.length === 3 && tests.some(function (test) { 40 | return test in current_fields; 41 | }); 42 | 43 | // selector, callback 44 | if (args.length < 3) { 45 | args.splice(1, 0, fields); 46 | } else if (args.length === 3 && is_option) { 47 | args.splice(1, 0, fields); 48 | // selector, fields, options, callback 49 | } else if (args.length >= 3) { 50 | args[1] = _.defaults(fields, args[1]); 51 | } 52 | }; 53 | 54 | NAMESPACER.addFieldFindOne = function (visibility, args) { 55 | var fields = _getFields(visibility); 56 | 57 | // selector, callback 58 | if (args.length <= 2) { 59 | args.splice(1, 0, { fields: fields }); 60 | // selector, fields, callback 61 | } else { 62 | args[1] = _.defaults({ fields: fields }, args[1]); 63 | } 64 | }; 65 | 66 | NAMESPACER.addFieldFindAndModify = function (visibility, args) { 67 | var fields = _getFields(visibility); 68 | 69 | if (args.length === 2) { 70 | args.splice(1, 0, []); 71 | } 72 | 73 | if (args.length === 3) { 74 | args.splice(2, 0, null); 75 | } 76 | 77 | if (args.length === 4) { 78 | args.splice(3, 0, {}); 79 | } 80 | 81 | args[3].fields = _.defaults(fields, args[3].fields); 82 | }; 83 | 84 | NAMESPACER.filterUpdate = function (visibility, arg) { 85 | 86 | /* 0: not visible 87 | * 1: partial visible, needs more inspection 88 | * 2: fully visible 89 | */ 90 | function isVisible(visibility, attr, level) { 91 | var ret = 0; 92 | 93 | visibility.some(function (el) { 94 | var match; 95 | if (attr.indexOf('.') !== -1) { 96 | match = attr.match(new RegExp(el + "(\..*)?")); 97 | if (match) { 98 | ret = 2; 99 | } 100 | } else { 101 | el = el.split('.'); 102 | match = el[level] === attr; 103 | if (match) { 104 | ret = el.length === level + 1 ? 2 : 1; 105 | } 106 | } 107 | return match; 108 | }); 109 | 110 | return ret; 111 | } 112 | 113 | function filter(el, level) { 114 | var attr, is_visible; 115 | 116 | for (attr in el) { 117 | if (el.hasOwnProperty(attr)) { 118 | // special op 119 | if (attr[0] === '$') { 120 | filter(el[attr], level); 121 | } else { 122 | is_visible = isVisible(visibility, attr, level); 123 | if (is_visible === 0) { 124 | delete el[attr]; 125 | } else { 126 | if (is_visible === 1 && typeof el[attr] === 'object') { 127 | filter(el[attr], level + 1); 128 | } 129 | } 130 | } 131 | } 132 | } 133 | } 134 | 135 | if (!_.isEmpty(arg)) { 136 | if (Array.isArray(arg)) { 137 | arg.forEach(function (el) { 138 | filter(el, 0); 139 | }); 140 | } else { 141 | filter(arg, 0); 142 | } 143 | } 144 | }; 145 | 146 | NAMESPACER.filter = function (namespaces, namespace, fn, args) { 147 | var visibility = NAMESPACER.resolveNamespace(namespaces, namespace); 148 | 149 | if (fn === 'findAndModify') { 150 | NAMESPACER.addFieldFindAndModify(visibility, args); 151 | NAMESPACER.filterUpdate(visibility, args[2]); 152 | } else if (fn === 'findOne') { 153 | NAMESPACER.addFieldFindOne(visibility, args); 154 | } else if (fn.match(/^find/)) { 155 | NAMESPACER.addFieldFind(visibility, args); 156 | } else if (fn === 'insert') { 157 | NAMESPACER.filterUpdate(visibility, args[0]); 158 | } else if (fn === 'update') { 159 | NAMESPACER.filterUpdate(visibility, args[1]); 160 | } 161 | }; 162 | 163 | module.exports = NAMESPACER; 164 | -------------------------------------------------------------------------------- /example/public/css/reset.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, figure, footer, header, hgroup, nav, section, menu, time, mark, audio, video { 2 | margin: 0; 3 | padding: 0; 4 | border: 0; 5 | outline: 0; 6 | font-size: 100%; 7 | vertical-align: baseline; 8 | background: transparent; 9 | } 10 | 11 | article, aside, figure, footer, header, hgroup, nav, section { display: block; } 12 | fieldset {border: none;} 13 | 14 | menu, ul { list-style: none; } 15 | ul, ol { list-style-position: inside; } 16 | blockquote, q { quotes: none; } 17 | blockquote:before, blockquote:after, q:before, q:after { content: ''; content: none; } 18 | a { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | vertical-align: baseline; 24 | background: transparent; 25 | } 26 | 27 | ins { background-color: #ff9; color: #000; text-decoration: none; } 28 | 29 | mark { 30 | background-color: #ff9; 31 | color: #000; 32 | font-style: italic; 33 | font-weight: bold; 34 | } 35 | del {text-decoration: line-through;} 36 | 37 | abbr[title], dfn[title] {border-bottom: 1px dotted #000; cursor: help;} 38 | 39 | /* tables still need cellspacing="0" in the markup */ 40 | table {border-collapse: collapse; border-spacing: 0;} 41 | hr { 42 | display: block; 43 | height: 1px; 44 | border: 0; 45 | border-top: 1px solid #ccc; 46 | margin: 1em 0; 47 | padding: 0; 48 | } 49 | input, select { 50 | vertical-align: middle; 51 | } 52 | /* 53 | fonts.css from the YUI Library: developer.yahoo.com/yui/ 54 | Please refer to developer.yahoo.com/yui/fonts/ for font sizing percentages 55 | 56 | There are two custom edits: 57 | * make the line-height relative and unit-less 58 | * remove the pre,code styles as we use a recommendation from Eric Meyer instead 59 | */ 60 | body { 61 | font: 13px arial, helvetica, "Nimbus Sans L", "Bitstream Vera Sans", sans-serif; 62 | *font-size: small; 63 | *font: x-small; 64 | line-height: 1.22; 65 | } 66 | table { font-size: inherit; font: 100%; } 67 | select, input, textarea { font: 99% arial, helvetica, "Nimbus Sans L", "Bitstream Vera Sans", sans-serif; outline: none; } 68 | /* normalize monospace sizing 69 | * meyerweb.com/eric/thoughts/2010/02/12/fixed-monospace-sizing/ 70 | */ 71 | code, pre { font-family: "Courier New", monospace, serif; font-size: 1em; } 72 | /* 73 | * minimal base styles 74 | */ 75 | /* #444 looks better than black: twitter.com/H_FJ/statuses/11800719859 */ 76 | body, select, input, textarea { color: #444; } 77 | /* Headers (h1,h2,etc) have no default font-size or margin, 78 | you'll want to define those yourself. */ 79 | /* www.aestheticallyloyal.com/public/optimize-legibility/ */ 80 | h1, h2, h3, h4, h5, h6 { font-weight: bold; text-rendering: optimizeLegibility; } 81 | /* maxvoltar.com/archive/-webkit-font-smoothing */ 82 | html { -webkit-font-smoothing: antialiased; } 83 | /* Accessible focus treatment: people.opera.com/patrickl/experiments/keyboard/test */ 84 | a:active { outline: none; } 85 | a:focus { outline: none; } 86 | small { font-size: 85%; } 87 | strong, th { font-weight: bold; } 88 | td, td img { vertical-align: top; } 89 | sub { vertical-align: sub; font-size: smaller; } 90 | sup { vertical-align: super; font-size: smaller; } 91 | pre { 92 | padding: 15px; 93 | /* www.pathf.com/blogs/2008/05/formatting-quoted-code-in-blog-posts-css21-white-space-pre-wrap/ */ 94 | 95 | white-space: pre; 96 | /* CSS2 */ 97 | 98 | white-space: pre-wrap; 99 | /* CSS 2.1 */ 100 | 101 | white-space: pre-line; 102 | /* CSS 3 (and 2.1 as well, actually) */ 103 | 104 | word-wrap: break-word; 105 | /* IE */ 106 | } 107 | /* align checkboxes, radios, text inputs with their label 108 | by: Thierry Koblentz tjkdesign.com/ez-css/css/base.css */ 109 | input[type="radio"] { vertical-align: text-bottom; } 110 | input[type="checkbox"] { vertical-align: bottom; *vertical-align: baseline; } 111 | .ie6 input { vertical-align: text-bottom; } 112 | /* hand cursor on clickable input elements */ 113 | label, input[type=button], input[type=submit], button { cursor: pointer; } 114 | /* always force a scrollbar in non-IE */ 115 | html { overflow-y: scroll; } 116 | /* make buttons play nice in IE: 117 | www.viget.com/inspire/styling-the-button-element-in-internet-explorer/ */ 118 | button { width: auto; overflow: visible; } 119 | /* bicubic resizing for non-native sized IMG: 120 | code.flickr.com/blog/2008/11/12/on-ui-quality-the-little-things-client-side-image-resizing/ */ 121 | .ie7 img { -ms-interpolation-mode: bicubic; } 122 | /* 123 | * Non-semantic helper classes 124 | */ 125 | /* Hide for both screenreaders and browsers 126 | css-discuss.incutio.com/wiki/Screenreader_Visibility */ 127 | .hidden { display: none; visibility: hidden; } 128 | /* Hide only visually, but have it available for screenreaders 129 | www.webaim.org/techniques/css/invisiblecontent/ 130 | Solution from: j.mp/visuallyhidden - Thanks Jonathan Neal! */ 131 | .visuallyhidden { 132 | position: absolute !important; 133 | clip: rect(1px 1px 1px 1px); 134 | /* IE6, IE7 */ 135 | 136 | clip: rect(1px, 1px, 1px, 1px); 137 | } 138 | /* Hide visually and from screenreaders, but maintain layout */ 139 | .invisible { visibility: hidden; } 140 | .clearer { clear: both; } 141 | -------------------------------------------------------------------------------- /test/helpers_namespacer_test.js: -------------------------------------------------------------------------------- 1 | /*globals describe, it*/ 2 | var assert = require('assert') 3 | , Namespacer = require('./../lib/helpers/namespacer'); 4 | 5 | describe('namespacer', function () { 6 | it('`resolveNamespace` implements the whole namespace API', function () { 7 | var test = Namespacer.resolveNamespace 8 | , namespaces = { jazz: ['mile.davis', 'coltrane', 'ellington', 'fitzgerald'] 9 | , modern: { extend: 'jazz' 10 | , add: ['cohen', 'corea'] 11 | , remove: ['ellington', 'fitzgerald'] }}; 12 | 13 | assert.deepEqual(test(namespaces, 'jazz'), namespaces.jazz); 14 | assert.deepEqual(test(namespaces, 'modern'), ['mile.davis', 'coltrane', 'cohen', 'corea']); 15 | }); 16 | 17 | it('`addFieldFindOne` adds `fields` to findOne method if using a namespace', function () { 18 | var test = Namespacer.addFieldFindOne 19 | , args 20 | , cb = function () {} 21 | , fields = {fields: {zemba: 1, fleiba: 1, 'nested.attribute': 1}} 22 | , visibility = ['nested.attribute', 'zemba', 'fleiba']; 23 | 24 | args = [{_id: 1}, cb]; 25 | test(visibility, args); 26 | assert.deepEqual(args[1], fields); 27 | 28 | args = [{_id: 1}, {timeout: 1}, cb]; 29 | test(visibility, args); 30 | assert.deepEqual(args[1].fields, fields.fields); 31 | assert.deepEqual(args[1].timeout, 1); 32 | 33 | args = [{_id: 1}, {fields: {zemba: -1}}, cb]; 34 | test(visibility, args); 35 | assert.deepEqual(args[1], fields); 36 | }); 37 | 38 | it('`addFieldFind` adds `fields` to find methods if using a namespace', function () { 39 | var test = Namespacer.addFieldFind 40 | , args 41 | , cb = function () {} 42 | , fields = {zemba: 1, fleiba: 1, 'nested.attribute': 1} 43 | , visibility = ['nested.attribute', 'zemba', 'fleiba']; 44 | 45 | args = [{_id: 1}, cb]; 46 | test(visibility, args); 47 | assert.deepEqual(args[1], fields); 48 | 49 | args = [{_id: 1}, {skip: 1}, cb]; 50 | test(visibility, args); 51 | assert.deepEqual(args[1], fields); 52 | 53 | args = [{_id: 1}, {zemba: -1}, cb]; 54 | test(visibility, args); 55 | assert.deepEqual(args[1], fields); 56 | 57 | args = [{_id: 1}, {zemba: -1}, {skip: 1}, cb]; 58 | test(visibility, args); 59 | assert.deepEqual(args[1], fields); 60 | }); 61 | 62 | it('`addFieldFindAndModify` adds `fields` to findAndModify method if using a namespace', function () { 63 | var test = Namespacer.addFieldFindAndModify 64 | , args 65 | , cb = function () {} 66 | , fields = {fields: {zemba: 1, fleiba: 1, 'nested.attribute': 1}} 67 | , visibility = ['nested.attribute', 'zemba', 'fleiba']; 68 | 69 | args = [{_id: 1}, cb]; 70 | test(visibility, args); 71 | assert.deepEqual(args[3], fields); 72 | 73 | args = [{_id: 1}, [], cb]; 74 | test(visibility, args); 75 | assert.deepEqual(args[3], fields); 76 | 77 | args = [{_id: 1}, [], {foo: 'bar'}, cb]; 78 | test(visibility, args); 79 | assert.deepEqual(args[3], fields); 80 | 81 | args = [{_id: 1}, [], {foo: 'bar'}, {fields: {zemba: -1}}, cb]; 82 | test(visibility, args); 83 | assert.deepEqual(args[3], fields); 84 | }); 85 | 86 | it('`filterUpdate` should filter documents before being inserted or updated', function () { 87 | var test = Namespacer.filterUpdate 88 | , arg 89 | , insert = {django: {reinhardt: 'guitar', framework: null}, charlie: {parker: 'saxophone'}} 90 | , update = {django: {reinhardt: 'guitar', framework: null}, 'charlie.parker': 'saxophone'} 91 | , visibility = ['django', 'charlie.parker']; 92 | 93 | // insert/update 94 | arg = {django: {reinhardt: 'guitar', framework: null}, charlie: {christian: 'guitar', parker: 'saxophone'}}; 95 | test(visibility, arg); 96 | assert.deepEqual(arg, insert); 97 | 98 | // insert/update 99 | arg = { django: {reinhardt: 'guitar', framework: null}, will_not: 'be_inserted', charlie: { christian: 'guitar', parker: 'saxophone'}}; 100 | test(visibility, arg); 101 | assert.deepEqual(arg, insert); 102 | 103 | // insert/update 104 | arg = [ 105 | {django: {reinhardt: 'guitar', framework: null}, will_not: 'be_inserted', charlie: { christian: 'guitar', parker: 'saxophone'}} 106 | , {django: {reinhardt: 'guitar', framework: null}, will_not: 'be_inserted'} 107 | ]; 108 | test(visibility, arg); 109 | assert.deepEqual(arg[0], {django: {reinhardt: 'guitar', framework: null}, charlie: {parker: 'saxophone'}}); 110 | assert.deepEqual(arg[1], {django: {reinhardt: 'guitar', framework: null}}); 111 | 112 | // update with sepcial ops 113 | arg = {'$set': {django: {reinhardt: 'guitar', framework: null}, 'charlie.christian': 'guitar', 'charlie.parker': 'saxophone'}}; 114 | test(visibility, arg); 115 | assert.deepEqual(arg, {'$set': update}); 116 | 117 | // update with sepcial ops dot notation 118 | arg = {'$set': {'django.reinhardt': 'piano'}}; 119 | test(visibility, arg); 120 | assert.deepEqual(arg, {'$set': {'django.reinhardt': 'piano'}}); 121 | 122 | // update with sepcial ops 123 | arg = {'$set': {django: {reinhardt: 'guitar', framework: null}, will_not: 'be_update', charlie: { christian: 'guitar', parker: 'saxophone'}}}; 124 | test(visibility, arg); 125 | assert.deepEqual(arg, {'$set': {django: {reinhardt: 'guitar', framework: null}, charlie: {parker: 'saxophone'}}}); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /lib/helpers/collection_proxy.js: -------------------------------------------------------------------------------- 1 | var PROXY = {} 2 | , _ = require('underscore'); 3 | 4 | function _apply(collection, fn, args) { 5 | collection[fn].apply(collection, args); 6 | } 7 | 8 | Object.defineProperty(PROXY, 'namespacer', {value: require('./namespacer'), writable: true}); 9 | Object.defineProperty(PROXY, 'mapper', {value: require('./mapper'), writable: true}); 10 | 11 | /** 12 | * Proxies calls 13 | * 14 | * @param {Object} model 15 | * @param {Object} options 16 | * @param {Array} args 17 | */ 18 | PROXY.proxy = function (model, options, args) { 19 | var fn = options.method 20 | , callback 21 | , arguments_length = arguments.length; 22 | 23 | model.getCollection(function (error, collection) { 24 | var is_overwritten = typeof PROXY[fn] !== 'undefined' 25 | , is_native = typeof collection[fn] !== 'undefined'; 26 | 27 | if (typeof args[args.length - 1] === 'function') { 28 | callback = args[args.length - 1]; 29 | } else { 30 | callback = function () {}; 31 | } 32 | 33 | if (error) { 34 | return callback(error, null); 35 | } 36 | 37 | if (options.namespacing && model.namespaces && model.namespaces[options.namespace]) { 38 | // has side effects, alters args 39 | PROXY.namespacer.filter(model.namespaces, options.namespace, fn, args); 40 | } 41 | 42 | if (options.mapping && model.maps) { 43 | // has side effects, alters args 44 | PROXY.mapper.map(model.maps, fn, args); 45 | } 46 | 47 | // overwritten method with hooks, or custom method 48 | if (is_overwritten && (is_native ? options.hooks : true)) { 49 | PROXY[fn](model, collection, args, callback); 50 | // driver method or hooks disabled 51 | } else { 52 | _apply(collection, fn, args); 53 | } 54 | }); 55 | }; 56 | 57 | /** 58 | * Calls `find` + `toArray` 59 | * 60 | * @param {Object} model 61 | * @param {Object} collection 62 | * @param {Array} args 63 | * @param {Function} callback 64 | */ 65 | PROXY.findArray = function (model, collection, args, callback) { 66 | args[args.length - 1] = function (error, cursor) { 67 | cursor.toArray(callback); 68 | }; 69 | _apply(collection, 'find', args); 70 | }; 71 | 72 | /** 73 | * Calls `insert` 74 | * and triggers the `beforeInsert` and `afterInsert` hooks 75 | * 76 | * @param {Object} model 77 | * @param {Object} collection 78 | * @param {Array} args 79 | * @param {Function} callback 80 | */ 81 | PROXY.insert = function (model, collection, args, callback) { 82 | args[args.length - 1] = function (error, ret) { 83 | if (error) return callback(error, null); 84 | 85 | model.afterInsert(args[0], function (error, _) { 86 | callback(error, ret); 87 | }); 88 | }; 89 | 90 | if (!Array.isArray(args[0])) { 91 | args[0] = [args[0]]; 92 | } 93 | 94 | model.beforeInsert(args[0], function (error, documents) { 95 | if (error) return callback(error, null); 96 | 97 | args[0] = documents; 98 | _apply(collection, 'insert', args); 99 | }); 100 | }; 101 | 102 | /** 103 | * Calls `findAndModify` or `update` 104 | * and triggers the `beforeUpdate` and `afterUpdate` hooks 105 | * 106 | * @param {Object} model 107 | * @param {Object} collection 108 | * @param {Array} args 109 | * @param {Function} callback 110 | */ 111 | ['findAndModify', 'update'].forEach(function (method) { 112 | PROXY[method] = function (model, collection, args, callback) { 113 | var update_index = method === 'update' ? 1 : 2; 114 | 115 | args[args.length - 1] = function (error, ret) { 116 | if (error) { 117 | return callback(error, null); 118 | } 119 | model.afterUpdate(args[0], args[update_index], function (error, _) { 120 | callback(error, ret); 121 | }); 122 | }; 123 | 124 | model.beforeUpdate(args[0], args[update_index], function (error, _query, _update) { 125 | if (error) { 126 | return callback(error, null); 127 | } 128 | 129 | args[0] = _query; 130 | args[update_index] = _update; 131 | 132 | _apply(collection, method, args); 133 | }); 134 | }; 135 | }); 136 | 137 | /** 138 | * Calls `remove` 139 | * and triggers the `beforeRemove` and `afterRemove` hooks 140 | * 141 | * @param {Object} model 142 | * @param {Object} collection 143 | * @param {Array} args 144 | * @param {Function} callback 145 | */ 146 | PROXY.remove = function (model, collection, args, callback) { 147 | args[args.length - 1] = function (error, ret) { 148 | if (error) { 149 | return callback(error, null); 150 | } 151 | 152 | model.afterRemove(args[0], function (error, _) { 153 | callback(error, ret); 154 | }); 155 | }; 156 | 157 | model.beforeRemove(args[0], function (error, _query) { 158 | if (error) { 159 | return callback(error, null); 160 | } 161 | 162 | args[0] = _query; 163 | 164 | _apply(collection, 'remove', args); 165 | }); 166 | }; 167 | 168 | /** 169 | * Calls `mapReduce` + `find` 170 | * 171 | * @param {Object} model 172 | * @param {Object} collection 173 | * @param {Array} args 174 | * @param {Function} callback 175 | */ 176 | PROXY.mapReduceCursor = function (model, collection, args, callback) { 177 | args[args.length - 1] = function (error, collection) { 178 | if (error) { 179 | callback(error, null); 180 | } else { 181 | collection.find(callback); 182 | } 183 | }; 184 | _apply(collection, 'mapReduce', args); 185 | }; 186 | 187 | /** 188 | * Calls `mapReduce` + `find` + `toArray` 189 | * 190 | * @param {Object} model 191 | * @param {Object} collection 192 | * @param {Array} args 193 | * @param {Function} callback 194 | */ 195 | PROXY.mapReduceArray = function (model, collection, args, callback) { 196 | PROXY.mapReduceCursor(model, collection, args, function (error, cursor) { 197 | if (error) { 198 | callback(error, null); 199 | } else { 200 | cursor.toArray(callback); 201 | } 202 | }); 203 | }; 204 | 205 | module.exports = PROXY; 206 | -------------------------------------------------------------------------------- /lib/validator.js: -------------------------------------------------------------------------------- 1 | module.exports = function (document, update) { 2 | var VALIDATOR = {}, 3 | _ = require('underscore'), 4 | utils = require('./utils'), 5 | UpdateDocument = require('./helpers/update_document'); 6 | 7 | // private methods 8 | function _getKeys(object) { 9 | return _.intersect(Object.keys(object), Object.keys(VALIDATOR.updated_document)); 10 | } 11 | 12 | if (!document) { 13 | throw Error("Must provide a document"); 14 | } 15 | 16 | if (!update) { 17 | throw Error("Must provide an update object"); 18 | } 19 | 20 | VALIDATOR.document = document || {}; 21 | VALIDATOR.update = update || {}; 22 | VALIDATOR.updated_document = UpdateDocument(VALIDATOR); 23 | VALIDATOR.errors = {}; 24 | 25 | VALIDATOR.regex = { 26 | login: /^[A-Za-z](?=[A-Za-z0-9_.]{3,11}$)[a-zA-Z0-9_]*\.?[a-zA-Z0-9_]*$/, 27 | username: /^[A-Za-z0-9][A-Za-z0-9_@&$. \-]{3,31}[a-zA-Z0-9_]$/, 28 | title: /^[A-Za-z0-9].{3,50}/, 29 | description: /.{10,300}/, 30 | email: /^\S+@\S+\.\S+$/, 31 | password: /.{6,20}/, 32 | url: /((http|https|ftp):\/\/(\S*?\.\S*?))(\s|\;|\)|\]|\[|\{|\}|,|\"|'|:|\<|$|\.\s)/i 33 | }; 34 | 35 | /* is the model being updated? 36 | * 37 | * @return {Boolean} 38 | */ 39 | VALIDATOR.isUpdating = function () { 40 | return !_.isEmpty(this.document); 41 | }; 42 | 43 | /* is the model being inserted? 44 | * 45 | * @return {Boolean} 46 | */ 47 | VALIDATOR.isInserting = function () { 48 | return _.isEmpty(this.document); 49 | }; 50 | 51 | /* did the attribute change? 52 | * 53 | * @param {String} 54 | * @return {Boolean} 55 | */ 56 | VALIDATOR.attrChanged = function (attr) { 57 | return this.document[attr] !== this.updated_document[attr]; 58 | }; 59 | 60 | /* gets or creates an error bucket fot the given attribute 61 | * 62 | * @param {String} attr 63 | * @param {Boolean} create 64 | * 65 | * @return {Object} 66 | */ 67 | VALIDATOR.getErrorBucket = function (attr, create) { 68 | var attr_parts = attr.split('.') 69 | , error_bucket, key; 70 | 71 | if (attr_parts.length === 1) { 72 | if (create && !this.errors[attr]) { 73 | this.errors[attr] = []; 74 | } 75 | return this.errors[attr] || null; 76 | } else { 77 | error_bucket = this.errors; 78 | for (;attr_parts.length > 0;) { 79 | key = attr_parts.shift(); 80 | 81 | if (!error_bucket[key]) { 82 | if (create) { 83 | error_bucket[key] = attr_parts.length === 0 ? [] : {}; 84 | } else { 85 | return null; 86 | } 87 | } 88 | error_bucket = error_bucket[key]; 89 | } 90 | } 91 | return error_bucket; 92 | }; 93 | 94 | /* does attr have any error? 95 | * 96 | * @param {String} 97 | * @return {Boolean} 98 | */ 99 | VALIDATOR.hasError = function (attr) { 100 | return this.hasErrors() && this.getErrorBucket(attr) !== null; 101 | }; 102 | 103 | /* does the model have any error? 104 | * 105 | * @return {Boolean} 106 | */ 107 | VALIDATOR.hasErrors = function () { 108 | return !_.isEmpty(this.errors); 109 | }; 110 | 111 | /* validates existence of some attributes 112 | * 113 | * {attr1: 'error message', attr2: ...} 114 | * 115 | * @param {Object} validations 116 | * @return self 117 | */ 118 | VALIDATOR.validateExistence = function (validations) { 119 | Object.keys(validations).forEach(function (key, i) { 120 | var parsed = utils.inspect(this.updated_document, key) 121 | , obj = parsed[0] 122 | , attr = parsed[1]; 123 | 124 | if (!obj[attr]) { 125 | this.addError(key, validations[key]); 126 | } 127 | }, this); 128 | return VALIDATOR; 129 | }; 130 | 131 | /* validates regex matching of some attributes 132 | * 133 | * {attr1: [/regex/, 'error message'], attr2: ...} 134 | * 135 | * @param {Object} validations 136 | * @return self 137 | */ 138 | VALIDATOR.validateRegex = function (validations) { 139 | Object.keys(validations).forEach(function (key, i) { 140 | var parsed = utils.inspect(this.updated_document, key) 141 | , obj = parsed[0] 142 | , attr = parsed[1]; 143 | 144 | if (!obj[attr] || !validations[key][0].test(obj[attr])) { 145 | this.addError(key, validations[key][1]); 146 | } 147 | }, this); 148 | return VALIDATOR; 149 | }; 150 | 151 | /* validates confirmation of some attributes 152 | * 153 | * {attr1: ['confirmation_key', 'error message'], attr2: ...} 154 | * 155 | * @param {Object} validations 156 | * @return self 157 | */ 158 | VALIDATOR.validateConfirmation = function (validations) { 159 | Object.keys(validations).forEach(function (key, i) { 160 | var parsed = utils.inspect(this.updated_document, key) 161 | , obj = parsed[0], attr = parsed[1] 162 | , parsed2 = utils.inspect(this.updated_document, validations[key][0]) 163 | , obj2 = parsed2[0], attr2 = parsed2[1]; 164 | 165 | if (!obj[attr] || !obj2[attr2] || obj[attr] !== obj2[attr2]) { 166 | this.addError(key, validations[key][1]); 167 | this.addError(validations[key][0], validations[key][1]); 168 | } 169 | }, this); 170 | return VALIDATOR; 171 | }; 172 | 173 | /* validates that a query returns something or nothing 174 | * 175 | * {attr1: [Model, query, true|false, 'error message'], attr2: ...} 176 | * 177 | * @param {Object} validations 178 | * @param {Function} callback 179 | * @return self 180 | */ 181 | VALIDATOR.validateQuery = function (validations, callback) { 182 | var funk = require('funk')(); 183 | 184 | _getKeys(validations).forEach(function (key, i) { 185 | validations[key][0].mongo('findOne', validations[key][1], funk.add(function (errors, doc) { 186 | if ((validations[key][2] === true && !doc || validations[key][2] === false && doc)) { 187 | VALIDATOR.addError(key, validations[key][3]); 188 | } 189 | })); 190 | }); 191 | 192 | funk.parallel(callback); 193 | return VALIDATOR; 194 | }; 195 | 196 | /* adds an error to the error bucket 197 | * 198 | * @param {String} name 199 | * @param {*} value 200 | * @return self 201 | */ 202 | VALIDATOR.addError = function (name, value) { 203 | var error_bucket = this.getErrorBucket(name, true); 204 | 205 | error_bucket.push(value); 206 | return VALIDATOR; 207 | }; 208 | 209 | return VALIDATOR; 210 | }; 211 | -------------------------------------------------------------------------------- /test/validator_test.js: -------------------------------------------------------------------------------- 1 | /*globals describe, beforeEach, it*/ 2 | var assert = require('assert') 3 | , Validator = require('./../lib/validator'); 4 | 5 | describe('validators', function () { 6 | 7 | it('initial status', function () { 8 | var update = {'$set': {foo: 'bar'}} 9 | , doc = {foo: 'zemba', hey: 'joe'} 10 | , val = Validator({}, update) 11 | , val2 = Validator(doc, update); 12 | 13 | assert.deepEqual(val.errors, {}); 14 | assert.deepEqual(val.document, {}); 15 | assert.deepEqual(val.update, update); 16 | assert.deepEqual(val.updated_document, {foo: 'bar'}); 17 | 18 | assert.deepEqual(val2.errors, {}); 19 | assert.deepEqual(val2.document, doc); 20 | assert.deepEqual(val2.update, update); 21 | assert.deepEqual(val2.updated_document, {foo: 'bar', hey: 'joe'}); 22 | }); 23 | 24 | it('`addError` adds a validation error', function () { 25 | var val = Validator({}, {foo: 'bar'}) 26 | , error = 'foo error'; 27 | 28 | val.addError('foo', error); 29 | assert.equal(val.errors.foo[0], error); 30 | }); 31 | 32 | it('`hasErrors` returns true if the validator has errors', function () { 33 | var val = Validator({}, {foo: 'bar'}); 34 | 35 | val.addError('foo', 'foo error'); 36 | assert.ok(val.hasErrors()); 37 | }); 38 | 39 | it('`hasError` returns true if the validator has a particular error', function () { 40 | var val = Validator({}, {foo: 'bar'}); 41 | 42 | val.addError('foo', 'foo error'); 43 | assert.ok(val.hasError('foo')); 44 | }); 45 | 46 | it('nested `addError` adds a nested error', function () { 47 | var val = Validator({}, {foo: 'bar'}); 48 | 49 | val.addError('foo.zemba', 'foo error'); 50 | assert.equal(val.errors.foo.zemba[0], 'foo error'); 51 | }); 52 | 53 | it('`hasErrors` returns whether the validator has nested errors', function () { 54 | var val = Validator({}, {foo: 'bar'}); 55 | 56 | val.addError('foo.zemba', 'foo error'); 57 | assert.ok(val.hasErrors()); 58 | }); 59 | 60 | it('`hasError` returns whether the validator has a particular nested error', function () { 61 | var val = Validator({}, {foo: 'bar'}); 62 | 63 | val.addError('foo.zemba', 'foo error'); 64 | assert.ok(val.hasError('foo.zemba')); 65 | }); 66 | 67 | it('`hasError` and `hasErrors` return false when there are no errors', function () { 68 | var val = Validator({}, {foo: 'bar'}); 69 | 70 | assert.equal(val.hasError('foo.zemba'), false); 71 | assert.equal(val.hasErrors(), false); 72 | }); 73 | 74 | it('multiple errors per field', function () { 75 | var val = Validator({}, {foo: 'bar'}); 76 | 77 | val.addError('foo.zemba', 'error1'); 78 | assert.ok(val.hasError('foo.zemba')); 79 | 80 | val.addError('foo.zemba', 'error2'); 81 | assert.deepEqual(val.errors.foo.zemba, ['error1', 'error2']); 82 | assert.equal(val.hasError('foo.zemba'), true); 83 | 84 | val.addError('foo.bla', 'error3'); 85 | assert.deepEqual(val.errors.foo.zemba, ['error1', 'error2']); 86 | assert.deepEqual(val.errors.foo.bla, ['error3']); 87 | }); 88 | 89 | it('`isUpdating` returns whether the model is being updated', function () { 90 | var update = {foo: 'bar'} 91 | , val = Validator({}, update) 92 | , val2 = Validator({zemba: 'fleiba'}, update); 93 | 94 | assert.equal(val.isUpdating(), false); 95 | assert.equal(val2.isUpdating(), true); 96 | }); 97 | 98 | it('`isInserting` returns whether the model is being inserted', function () { 99 | var update = {foo: 'bar'} 100 | , val = Validator({}, update) 101 | , val2 = Validator({zemba: 'fleiba'}, update); 102 | 103 | assert.equal(val.isInserting(), true); 104 | assert.equal(val2.isInserting(), false); 105 | }); 106 | 107 | it('`attrChanged` returns whether the attribute changed', function () { 108 | var update = {foo: 'bar'} 109 | , val = Validator({}, update) 110 | , val2 = Validator({foo: 'zemba'}, update) 111 | , val3 = Validator({foo: 'bar'}, update); 112 | 113 | assert.equal(val.attrChanged('foo'), true); 114 | assert.equal(val2.attrChanged('foo'), true); 115 | assert.equal(val3.attrChanged('foo'), false); 116 | }); 117 | 118 | it('`getErrorBucket` gets or sets the error bucket for the attribute', function () { 119 | var update = {foo: 'bar'} 120 | , val = Validator({}, update); 121 | 122 | assert.deepEqual(val.errors, {}); 123 | val.getErrorBucket('foo', true); 124 | assert.deepEqual(val.errors, {foo: []}); 125 | assert.deepEqual(val.getErrorBucket('foo'), []); 126 | val.addError('foo', 'ERROR'); 127 | assert.deepEqual(val.getErrorBucket('foo'), ['ERROR']); 128 | }); 129 | 130 | it('`getErrorBucket` also works with nested attributes', function () { 131 | var update = {'$set': {'foo.bar': 'zemba'}} 132 | , val = Validator({}, update); 133 | 134 | assert.deepEqual(val.errors, {}); 135 | val.getErrorBucket('foo.bar', true); 136 | assert.deepEqual(val.errors, {foo: {bar: []}}); 137 | assert.deepEqual(val.getErrorBucket('foo.bar'), []); 138 | val.addError('foo.bar', 'ERROR'); 139 | assert.deepEqual(val.getErrorBucket('foo.bar'), ['ERROR']); 140 | }); 141 | 142 | it('`validateExistence` validates existence of attributes', function () { 143 | var update = {'$set': {hey: 'joe', 'foo.bar': 'zemba'}} 144 | , val = Validator({}, update); 145 | 146 | assert.deepEqual(val.hasErrors(), false); 147 | 148 | val.validateExistence({ 149 | hey: 'hey error' 150 | , 'foo.bar': 'foo.bar error' 151 | , 'foo.boo': 'foo.boo error' 152 | , inexistant: 'inexistant error' 153 | }); 154 | 155 | assert.ok(val.hasError('foo.boo')); 156 | assert.ok(val.hasError('inexistant')); 157 | assert.deepEqual(val.getErrorBucket('foo.boo'), ['foo.boo error']); 158 | assert.deepEqual(val.getErrorBucket('inexistant'), ['inexistant error']); 159 | }); 160 | 161 | it('`validateRegex` validates attributes matching regexes', function () { 162 | var update = {'$set': {hey: 'joe', 'foo.bar': 'zemba'}} 163 | , val = Validator({}, update); 164 | 165 | assert.deepEqual(val.hasErrors(), false); 166 | 167 | val.validateRegex({ 168 | hey: [/joe/, 'hey error'] 169 | , 'foo.bar': [/[0-9]+/, 'foo.bar error'] 170 | , 'foo.boo': [/.*/, 'foo.boo error'] 171 | , inexistant: [/.*/, 'inexistant error'] 172 | }); 173 | 174 | assert.equal(val.hasError('hey'), false); 175 | assert.ok(val.hasError('foo.bar')); 176 | assert.ok(val.hasError('foo.boo')); 177 | assert.ok(val.hasError('inexistant')); 178 | assert.deepEqual(val.getErrorBucket('foo.bar'), ['foo.bar error']); 179 | assert.deepEqual(val.getErrorBucket('foo.boo'), ['foo.boo error']); 180 | assert.deepEqual(val.getErrorBucket('inexistant'), ['inexistant error']); 181 | }); 182 | 183 | it('`validateConfirmation` validates attributes being equal', function () { 184 | var update = {'$set': {hey: 'joe', 'foo.bar': 'zemba', 'foo.boo': 'zemba'}} 185 | , val = Validator({}, update); 186 | 187 | assert.deepEqual(val.hasErrors(), false); 188 | 189 | val.validateConfirmation({ 190 | hey: ['foo.cla', 'hey error'] 191 | , 'foo.bar': ['foo.boo', 'foo.bar error'] 192 | , inexistant: ['another inexistant', 'inexistant error'] 193 | }); 194 | 195 | assert.ok(val.hasError('hey')); 196 | assert.equal(val.hasError('foo.bar'), false); 197 | assert.ok(val.hasError('inexistant')); 198 | assert.deepEqual(val.getErrorBucket('hey'), ['hey error']); 199 | assert.deepEqual(val.getErrorBucket('inexistant'), ['inexistant error']); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /example/public/css/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Helvetica, serif; 3 | font-size: 12pt; 4 | color: #aa99aa; 5 | background: #18171a url(/images/back.png) repeat-x top left; 6 | } 7 | h1 { 8 | font-size: 42pt; 9 | font-weight: normal; 10 | margin: 10px 0px 20px; 11 | font-family: Monaco, console; 12 | float: left; 13 | } 14 | h1 a:hover { 15 | text-decoration: none; 16 | } 17 | h2 { 18 | font-size: 28pt; 19 | font-weight: normal; 20 | color: #aa99aa; 21 | margin-bottom: 10px; 22 | } 23 | a { 24 | color: #ffaadd; 25 | text-decoration: none; 26 | text-shadow: 1px 1px 0px #dd0085; 27 | } 28 | a:hover { 29 | text-decoration: underline; 30 | color: #ffddf1; 31 | text-shadow: 1px 1px 0px #ff44b4; 32 | } 33 | table thead { 34 | text-shadow: 0px 1px 0px #fff; 35 | } 36 | table th, table td { 37 | padding: 5px 10px; 38 | border: 1px solid #9b869b; 39 | } 40 | .section { 41 | margin-bottom: 20px; 42 | } 43 | .floated { 44 | float: left; 45 | } 46 | html, body { 47 | height: 100%; 48 | } 49 | #wrapper { 50 | width: 960px; 51 | margin: 0px auto; 52 | min-height: 100%; 53 | position: relative; 54 | } 55 | #wrapper #sticky { 56 | padding-bottom: 90px; 57 | } 58 | #wrapper #sticky #login { 59 | position: absolute; 60 | top: 8px; 61 | right: 10px; 62 | } 63 | #wrapper #sticky > nav { 64 | -moz-box-shadow: 0px 1px 0px #443344; 65 | -webkit-box-shadow: 0px 1px 0px #443344; 66 | box-shadow: 0px 1px 0px #443344; 67 | height: 75px; 68 | margin-bottom: 25px; 69 | padding-bottom: 5px; 70 | border-bottom: 1px solid #27252a; 71 | } 72 | #wrapper #sticky > nav #logo { 73 | display: block; 74 | text-indent: -999px; 75 | overflow: hidden; 76 | background-repeat: none; 77 | background: url(/images/sprite.png) no-repeat 0px 5px; 78 | height: 45px; 79 | width: 120px; 80 | margin-right: 20px; 81 | float: left; 82 | } 83 | #wrapper #sticky > nav menu#header_left { 84 | float: left; 85 | width: 512px; 86 | } 87 | #wrapper #sticky > nav menu#header_right { 88 | float: right; 89 | font-size: 10pt; 90 | text-align: right; 91 | width: 310px; 92 | } 93 | #wrapper #sticky > nav #logged_email { 94 | color: #aa99aa; 95 | } 96 | #wrapper #sticky > nav #logged_email, #wrapper #sticky > nav #edit_profile, #wrapper #sticky > nav #logout { 97 | margin-left: 20px; 98 | line-height: 45px; 99 | } 100 | form { 101 | width: 100%; 102 | } 103 | form fieldset { 104 | padding: 10px; 105 | } 106 | form fieldset.floated { 107 | float: left; 108 | width: 250px; 109 | } 110 | form fieldset h2 { 111 | margin-bottom: 10px; 112 | } 113 | form fieldset .field { 114 | border-bottom: 1px solid #18171a; 115 | -moz-box-shadow: 0px 1px 0px #443344; 116 | -webkit-box-shadow: 0px 1px 0px #443344; 117 | box-shadow: 0px 1px 0px #443344; 118 | padding: 10px; 119 | width: 562px; 120 | } 121 | form fieldset .field.floated { 122 | float: left; 123 | width: 250px; 124 | } 125 | form fieldset .field.errors { 126 | -webkit-border-radius: 8px; 127 | -moz-border-radius: 8px; 128 | border-radius: 8px; 129 | background: #b33c3c; 130 | border-bottom-color: #8c2f2f; 131 | position: relative; 132 | } 133 | form fieldset .field.errors input, form fieldset .field.errors textarea { 134 | border-color: #662222; 135 | border-top-color: #531c1c; 136 | border-left-color: #531c1c; 137 | color: #662222; 138 | } 139 | form fieldset .field.errors input:hover, form fieldset .field.errors textarea:hover { 140 | border-color: #2d0f0f; 141 | } 142 | form fieldset .field.errors input:focus, form fieldset .field.errors textarea:focus { 143 | border-color: #060202; 144 | } 145 | form fieldset .field.errors label { 146 | color: #190808; 147 | } 148 | form fieldset .field.errors ul.error { 149 | -webkit-border-radius: 8px; 150 | -moz-border-radius: 8px; 151 | border-radius: 8px; 152 | position: absolute; 153 | right: -315px; 154 | top: 0px; 155 | width: 275px; 156 | padding: 10px; 157 | background: #401515; 158 | color: #fff; 159 | } 160 | form label { 161 | text-align: right; 162 | padding-right: 20px; 163 | float: left; 164 | width: 100px; 165 | height: 34px; 166 | font-size: 108%; 167 | color: #aa99aa; 168 | font-size: 11pt; 169 | } 170 | form label.disabled { 171 | color: #665566; 172 | } 173 | form input, form textarea { 174 | -webkit-border-radius: 8px; 175 | -moz-border-radius: 8px; 176 | border-radius: 8px; 177 | -moz-box-shadow: 1px 1px 0px #533e53; 178 | -webkit-box-shadow: 1px 1px 0px #533e53; 179 | box-shadow: 1px 1px 0px #533e53; 180 | border: 1px solid #18171a; 181 | padding: 7px 10px; 182 | color: #aa99aa; 183 | background: #312f35; 184 | } 185 | form input:hover, form textarea:hover { 186 | -moz-box-shadow: 1px 1px 0px #614961; 187 | -webkit-box-shadow: 1px 1px 0px #614961; 188 | box-shadow: 1px 1px 0px #614961; 189 | background: #3d3b43; 190 | color: #aa99aa; 191 | } 192 | form input:focus, form textarea:focus { 193 | background: #4a4750; 194 | color: #aa99aa; 195 | } 196 | form textarea { 197 | width: 400px; 198 | } 199 | form .operations { 200 | text-align: right; 201 | padding: 10px; 202 | width: 562px; 203 | } 204 | 205 | #forms { 206 | float: left; 207 | margin-right: 40px; 208 | } 209 | 210 | #lists { 211 | float: left; 212 | } 213 | 214 | .button { 215 | -webkit-border-radius: 8px; 216 | -moz-border-radius: 8px; 217 | border-radius: 8px; 218 | -moz-box-shadow: 1px 1px 0px #7e5f7e inset; 219 | -webkit-box-shadow: 1px 1px 0px #7e5f7e inset; 220 | box-shadow: 1px 1px 0px #7e5f7e inset; 221 | background: #614961; 222 | border: 1px solid #271d27; 223 | border-bottom-color: #000000; 224 | border-right-color: #000000; 225 | cursor: pointer; 226 | display: inline-block; 227 | } 228 | .button:hover { 229 | background: #7e5f7e; 230 | -moz-box-shadow: 1px 1px 0px #997799 inset; 231 | -webkit-box-shadow: 1px 1px 0px #997799 inset; 232 | box-shadow: 1px 1px 0px #997799 inset; 233 | border-bottom-color: #000000; 234 | border-right-color: #000000; 235 | text-decoration: none; 236 | } 237 | .button:hover input { 238 | -moz-box-shadow: 0px 0px 0px #7e5f7e; 239 | -webkit-box-shadow: 0px 0px 0px #7e5f7e; 240 | box-shadow: 0px 0px 0px #7e5f7e; 241 | text-shadow: 0px 1px 0px #997799; 242 | background: #7e5f7e; 243 | color: #18171a; 244 | } 245 | .button.desactivated { 246 | cursor: default; 247 | } 248 | .button.desactivated:hover { 249 | background: url(/images/sprite_x.png) repeat-x 0px -28px #dddddd; 250 | border: 1px solid #ccc; 251 | } 252 | .button.desactivated span { 253 | opacity: 0.3; 254 | } 255 | .button span, .button input { 256 | -moz-box-shadow: 0px 0px 0px #614961; 257 | -webkit-box-shadow: 0px 0px 0px #614961; 258 | box-shadow: 0px 0px 0px #614961; 259 | display: inline-block; 260 | line-height: 18px; 261 | padding: 5px 10px 3px; 262 | text-shadow: 0px 1px 0px #7e5f7e; 263 | color: #18171a; 264 | font-weight: bold; 265 | border: none; 266 | background-color: transparent; 267 | } 268 | .button span.icon, .button input.icon { 269 | background-position: 6px; 270 | padding-left: 28px; 271 | } 272 | .button.small span, .button.small input { 273 | padding: 3px 7px 0; 274 | } 275 | .button.small span.icon, .button.small input.icon { 276 | background-position: 2px 2px; 277 | padding-left: 21px; 278 | } 279 | table.tabular { 280 | border: none; 281 | background: none; 282 | } 283 | table.tabular th, table.tabular td { 284 | border: none; 285 | background: none; 286 | } 287 | table.tabular th { 288 | text-align: right; 289 | } 290 | .box { 291 | -webkit-border-radius: 8px; 292 | -moz-border-radius: 8px; 293 | border-radius: 8px; 294 | padding: 10px 20px; 295 | margin-bottom: 10px; 296 | background: #443344; 297 | color: #9e8b9e; 298 | } 299 | .box.info { 300 | background: #d2f8cb; 301 | color: #6a8; 302 | } 303 | .box.info h2 { 304 | color: #7fd56e; 305 | border-bottom: 1px solid #bff4b5; 306 | } 307 | .box.info table { 308 | background: #e6fbe1; 309 | } 310 | .box.info table th, .box.info table td { 311 | border-color: #d2f8cb; 312 | } 313 | .box.error { 314 | background: #9f3535; 315 | color: #fff; 316 | text-shadow: 0px -1px 0px #662222; 317 | } 318 | .box.error h2 { 319 | color: #401515; 320 | text-shadow: 0px 1px 0px #c14646; 321 | } 322 | .box.warning { 323 | background: #fffad6; 324 | color: #999988; 325 | } 326 | .box.warning h2 { 327 | color: #c6b853; 328 | border-bottom: 1px solid #fff080; 329 | } 330 | .box h2 { 331 | margin-bottom: 5px; 332 | } 333 | 334 | ul.list { 335 | margin-bottom: 50px; 336 | } 337 | 338 | ul.list li{ 339 | margin: 10px 0px; 340 | border-left: 4px solid #625; 341 | padding-left: 10px; 342 | } 343 | -------------------------------------------------------------------------------- /test/integration_test.js: -------------------------------------------------------------------------------- 1 | /*globals describe, it, before*/ 2 | var assert = require('assert') 3 | 4 | , Model = require('./../lib/model') 5 | , mongodb = require('mongodb') 6 | , Db = mongodb.Db 7 | , Server = mongodb.Server 8 | , db = new Db('mongolia_test', new Server('localhost', 27017, {auto_reconnect: true, native_parser: true}), {}) 9 | 10 | , remove_users = function (cb) { 11 | db.collection('users', function (error, collection) { 12 | collection.remove({}, function () { 13 | db.collection('countries', function (error, collection) { 14 | collection.remove({}, cb); 15 | }); 16 | }); 17 | }); 18 | }; 19 | 20 | describe('Integration test', function () { 21 | 22 | before(function (done) { 23 | db.open(function (error) { 24 | if (error) { 25 | throw error; 26 | } 27 | 28 | remove_users(done); 29 | }); 30 | }); 31 | 32 | it('`Insert` documents with `before/afterInsert` hooks', function (done) { 33 | var User = Model(db, 'users'), 34 | Country = Model(db, 'countries'); 35 | 36 | User.beforeInsert = function (documents, callback) { 37 | documents.forEach(function (document) { 38 | document.has_country = true; 39 | }); 40 | callback(null, documents); 41 | }; 42 | 43 | User.afterInsert = function (documents, callback) { 44 | documents.forEach(function (document) { 45 | document.comments = []; 46 | }); 47 | callback(null, documents); 48 | }; 49 | 50 | User.namespaces = { 51 | 'public': ['name', 'country'] 52 | }; 53 | 54 | Country.maps = { 55 | name: function capitalize(val) { 56 | return val.charAt(0).toUpperCase() + val.slice(1); 57 | } 58 | , iso: {id: Number} 59 | }; 60 | 61 | Country.mongo('insert', {name: 'andorra', iso: {id: '123'}}, function (error, docs) { 62 | var doc = docs[0]; 63 | assert.equal(doc.name, 'Andorra'); 64 | assert.ok(doc.created_at); 65 | assert.ok(doc.iso.id, 123); 66 | 67 | User.mongo('insert:public', {name: 'zemba', country: doc, password: 'malicious'}, function (error, docs) { 68 | var doc = docs[0]; 69 | assert.equal(doc.name, 'zemba'); 70 | assert.equal(doc.has_country, true); 71 | assert.deepEqual(doc.country.name, 'Andorra'); 72 | assert.equal(doc.password, null); // namespaced! 73 | assert.deepEqual(doc.comments, []); 74 | done(); 75 | }); 76 | }); 77 | }); 78 | 79 | it('`Update embedded` documents', function (done) { 80 | var User = Model(db, 'users'), 81 | Country = Model(db, 'countries'); 82 | 83 | Country.findOne({name: 'Andorra'}, function (error, doc) { 84 | User.updateEmbeddedDocument({_id: doc._id}, 'country', {name: 'France'}, {}, function (error) { 85 | User.mongo('findOne', {name: 'zemba'}, function (error, doc) { 86 | assert.equal(doc.country.name, 'France'); 87 | assert.equal(doc.country.iso.id, '123'); 88 | done(); 89 | }); 90 | }); 91 | }); 92 | }); 93 | 94 | it('`Push embedded` documents', function (done) { 95 | var User = Model(db, 'users'), 96 | funk = require('funk')(), 97 | query = {name: 'zemba'}; 98 | 99 | User.pushEmbeddedDocument(query, 'comments', {body: 'bla bla bla'}, {}, funk.nothing()); 100 | User.pushEmbeddedDocument(query, 'comments', {body: 'trolling bla'}, {}, funk.nothing()); 101 | 102 | funk.parallel(function () { 103 | User.mongo('findOne', {name: 'zemba'}, function (error, doc) { 104 | assert.deepEqual(doc.comments, [ 105 | {body: 'bla bla bla'} 106 | , {body: 'trolling bla'} 107 | ]); 108 | done(); 109 | }); 110 | }); 111 | }); 112 | 113 | it('`findAndModify` documents with `before/afterUpdate` hooks', function (done) { 114 | var User = Model(db, 'users'), 115 | Country = Model(db, 'countries'), 116 | query = {name: 'zemba'}, 117 | update = {'$set': {name: 'fleiba'}}, 118 | calledBefore = false, 119 | calledAfter = false; 120 | 121 | 122 | User.beforeUpdate = function (_query, _update, _callback) { 123 | calledBefore = true; 124 | 125 | _update.$set.updated_at = new Date(); 126 | 127 | Country.mongo('findOne', {name: 'Andorra'}, function (error, doc) { 128 | _update.$set['country.name'] = doc.name; 129 | 130 | assert.deepEqual(_query, query); 131 | assert.deepEqual(_update.$set.name, update.$set.name); 132 | assert.deepEqual(_update.$set['country.name'], 'Andorra'); 133 | 134 | _callback(error, _query, _update); 135 | }); 136 | }; 137 | 138 | User.afterUpdate = function (_query, _update, _callback) { 139 | calledAfter = true; 140 | 141 | assert.deepEqual(_query, query); 142 | assert.deepEqual(_update.$set.name, update.$set.name); 143 | assert.deepEqual(_update.$set['country.name'], 'Andorra'); 144 | 145 | _callback(null, _query, _update); 146 | }; 147 | 148 | User.findAndModify(query, [], update, {'new': true}, function (error, doc) { 149 | assert.ok(calledBefore); 150 | assert.ok(calledAfter); 151 | assert.deepEqual(doc.country.name, 'Andorra'); 152 | assert.deepEqual(doc.name, 'fleiba'); 153 | done(); 154 | }); 155 | }); 156 | 157 | it('`update` documents with `before/afterUpdate` hooks', function (done) { 158 | var User = Model(db, 'users'), 159 | Country = Model(db, 'countries'), 160 | query = {name: 'fleiba'}, 161 | update = {'$set': {name: 'zemba'}}, 162 | calledBefore = false, 163 | calledAfter = false; 164 | 165 | 166 | User.beforeUpdate = function (_query, _update, _callback) { 167 | calledBefore = true; 168 | 169 | _update.$set.updated_at = new Date(); 170 | 171 | Country.mongo('findOne', {name: 'Andorra'}, function (error, doc) { 172 | _update.$set['country.name'] = 'France'; 173 | 174 | assert.deepEqual(_query, query); 175 | assert.deepEqual(_update.$set.name, update.$set.name); 176 | 177 | _callback(error, _query, _update); 178 | }); 179 | }; 180 | 181 | User.afterUpdate = function (_query, _update, _callback) { 182 | calledAfter = true; 183 | 184 | assert.deepEqual(_query, query); 185 | assert.deepEqual(_update.$set.name, update.$set.name); 186 | assert.deepEqual(_update.$set['country.name'], 'France'); 187 | 188 | _callback(null, _query, _update); 189 | }; 190 | 191 | User.update(query, update, function (error, doc) { 192 | assert.ok(calledBefore); 193 | assert.ok(calledAfter); 194 | User.mongo('findArray', update.$set, function (error, docs) { 195 | var doc = docs[0]; 196 | assert.deepEqual(doc.country.name, 'France'); 197 | assert.deepEqual(doc.name, 'zemba'); 198 | done(); 199 | }); 200 | }); 201 | }); 202 | 203 | it('`Remove` documents with `before/afterRemove` hooks', function (done) { 204 | var User = Model(db, 'users'), 205 | query = {name: 'zemba'}, 206 | calledBefore = false, 207 | calledAfter = false; 208 | 209 | User.beforeRemove = function (_query, callback) { 210 | calledBefore = true; 211 | assert.deepEqual(query, _query); 212 | callback(null, _query); 213 | }; 214 | 215 | User.afterRemove = function (_query, callback) { 216 | calledAfter = true; 217 | assert.deepEqual(query, _query); 218 | callback(null); 219 | }; 220 | 221 | User.remove(query, function (error, ret) { 222 | assert.ok(calledBefore); 223 | assert.ok(calledAfter); 224 | User.mongo('findArray', {}, function (error, docs) { 225 | assert.deepEqual(docs, []); 226 | done(); 227 | }); 228 | }); 229 | }); 230 | 231 | it('`validateAndInsert` validates and inserts', function (done) { 232 | var User = Model(db, 'users'); 233 | 234 | User.validate = function (document, update, callback) { 235 | var validator = require('./../lib/validator')(document, update); 236 | 237 | if (update.name !== 'zemba') { 238 | validator.addError('name', 'We only love Zemba here'); 239 | } 240 | 241 | callback(null, validator); 242 | }; 243 | 244 | User.validateAndInsert({name: 'zemba'}, function (error, validation) { 245 | assert.equal(validation.updated_document.name, 'zemba'); 246 | assert.deepEqual(validation.errors, {}); 247 | 248 | // Try to insert an invalid record 249 | User.validateAndInsert({name: 'barbaz'}, function (error, validation) { 250 | assert.deepEqual(validation.errors.name, ['We only love Zemba here']); 251 | done(); 252 | }); 253 | }); 254 | }); 255 | 256 | it('`validateAndUpdate` validates and updates', function (done) { 257 | var User = Model(db, 'users'); 258 | 259 | User.mongo('insert', {name: 'John Smith', age: 30}, function (errors, documents) {}); 260 | 261 | User.validate = function (document, update, callback) { 262 | var validator = require('./../lib/validator')(document, update); 263 | 264 | if (validator.updated_document.name !== 'zemba') { 265 | validator.addError('name', 'We only love Zemba here'); 266 | } 267 | 268 | callback(null, validator); 269 | }; 270 | 271 | User.validateAndUpdate({name: 'John Smith'}, {'$set': {name: 'foobar'}}, function (error, validation) { 272 | assert.deepEqual(validation.errors.name, ['We only love Zemba here']); 273 | assert.deepEqual(validation.updated_document.name, 'foobar'); 274 | 275 | User.validateAndUpdate({name: 'John Smith'}, {'$set': {name: 'zemba'}}, function (error, validation) { 276 | assert.deepEqual(validation.errors, {}); 277 | assert.deepEqual(validation.updated_document.name, 'zemba'); 278 | done(); 279 | }); 280 | }); 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /test/helpers_update_document_test.js: -------------------------------------------------------------------------------- 1 | /*globals describe, it*/ 2 | var assert = require('assert') 3 | , UpdateDocument = require('./../lib/helpers/update_document'); 4 | 5 | describe('namespacer', function () { 6 | 7 | it('should return update document if its not an special operation', function () { 8 | var update = {foo: 'bar'} 9 | , doc = {foo: 'zemba', hey: 'joe'} 10 | , ret = UpdateDocument({document: doc, update: update}); 11 | 12 | assert.deepEqual(ret, update); 13 | }); 14 | 15 | it('`$inc` increases doc value', function () { 16 | var update = {'$inc': {foo: 3}} 17 | , doc = {foo: 1, hey: 'joe'} 18 | , ret = UpdateDocument({document: doc, update: update}); 19 | 20 | assert.deepEqual(ret, {foo: 4, hey: 'joe'}); 21 | }); 22 | 23 | it('`$inc` increases doc value using `dot_notation`', function () { 24 | var update = {'$inc': {'foo.bar': 3}} 25 | , doc = {foo: {bar: 1}} 26 | , ret = UpdateDocument({document: doc, update: update}); 27 | 28 | assert.deepEqual(ret, {foo: {bar: 4}}); 29 | }); 30 | 31 | it('`$inc` sets doc value using `dot_notation` on `inexistant` elements', function () { 32 | var update = {'$inc': {'foo.bar': 3}} 33 | , doc = {} 34 | , ret = UpdateDocument({document: doc, update: update}); 35 | 36 | assert.deepEqual(ret, {foo: {bar: 3}}); 37 | }); 38 | 39 | it('`$set` sets doc value', function () { 40 | var update = {'$set': {foo: 'zemba'}} 41 | , doc = {foo: 'bar', hey: 'joe'} 42 | , ret = UpdateDocument({document: doc, update: update}); 43 | 44 | assert.deepEqual(ret, {foo: 'zemba', hey: 'joe'}); 45 | }); 46 | 47 | it('`$set` sets doc value using `dot_notation`', function () { 48 | var update = {'$set': {'foo.bar': 'zemba'}} 49 | , doc = {foo: {bar: 'joe'}} 50 | , ret = UpdateDocument({document: doc, update: update}); 51 | 52 | assert.deepEqual(ret, {foo: {bar: 'zemba'}}); 53 | }); 54 | 55 | it('`$set` sets doc value using `dot_notation` on `inexistant` elements', function () { 56 | var update = {'$set': {'foo.bar': 'zemba'}} 57 | , doc = {} 58 | , ret = UpdateDocument({document: doc, update: update}); 59 | 60 | assert.deepEqual(ret, {foo: {bar: 'zemba'}}); 61 | }); 62 | 63 | it('`$unset` unsets doc value', function () { 64 | var update = {'$unset': {foo: 1}} 65 | , doc = {foo: 'bar', hey: 'joe'} 66 | , ret = UpdateDocument({document: doc, update: update}); 67 | 68 | assert.deepEqual(ret, {hey: 'joe'}); 69 | }); 70 | 71 | it('`$unset` unsets doc value using `dot_notation`', function () { 72 | var update = {'$unset': {'foo.bar.hey': 1}} 73 | , doc = {foo: {bar: {hey: 'joe'}}} 74 | , ret = UpdateDocument({document: doc, update: update}); 75 | 76 | assert.deepEqual(ret, {foo: {bar: {}}}); 77 | }); 78 | 79 | it('`$unset` unsets doc value using `dot_notation` on `inexistant` elements', function () { 80 | var update = {'$unset': {'foo.bar.hey': 1}} 81 | , doc = {} 82 | , ret = UpdateDocument({document: doc, update: update}); 83 | 84 | assert.deepEqual(ret, {}); 85 | }); 86 | 87 | it('`$push` pushes doc value', function () { 88 | var update = {'$push': {foo: 'fleiba'}} 89 | , doc = {foo: ['bar'], hey: 'joe'} 90 | , ret = UpdateDocument({document: doc, update: update}); 91 | 92 | assert.deepEqual(ret, {foo: ['bar', 'fleiba'], hey: 'joe'}); 93 | }); 94 | 95 | it('`$push` pushes doc value using `dot_notation`', function () { 96 | var update = {'$push': {'foo.bar': 'fleiba'}} 97 | , doc = {foo: {bar: ['bar']}} 98 | , ret = UpdateDocument({document: doc, update: update}); 99 | 100 | assert.deepEqual(ret, {foo: {bar: ['bar', 'fleiba']}}); 101 | }); 102 | 103 | it('`$push` pushes doc value using `dot_notation` on `inexistant` elements', function () { 104 | var update = {'$push': {'foo.bar': 'fleiba'}} 105 | , doc = {} 106 | , ret = UpdateDocument({document: doc, update: update}); 107 | 108 | assert.deepEqual(ret, {foo: {bar: ['fleiba']}}); 109 | }); 110 | 111 | it('`$pushAll` pushes doc array', function () { 112 | var update = {'$pushAll': {foo: ['zemba', 'fleiba']}} 113 | , doc = {foo: ['bar'], hey: 'joe'} 114 | , ret = UpdateDocument({document: doc, update: update}); 115 | 116 | assert.deepEqual(ret, {foo: ['bar', 'zemba', 'fleiba'], hey: 'joe'}); 117 | }); 118 | 119 | it('`$pushAll` pushes doc array using `dot_notation`', function () { 120 | var update = {'$pushAll': {'foo.bar': ['zemba', 'fleiba']}} 121 | , doc = {foo: {bar: ['bar']}} 122 | , ret = UpdateDocument({document: doc, update: update}); 123 | 124 | assert.deepEqual(ret, {foo: {bar: ['bar', 'zemba', 'fleiba']}}); 125 | }); 126 | 127 | it('`$pushAll` pushes doc array using `dot_notation` on `inexistant` elements', function () { 128 | var update = {'$pushAll': {'foo.bar': ['zemba', 'fleiba']}} 129 | , doc = {} 130 | , ret = UpdateDocument({document: doc, update: update}); 131 | 132 | assert.deepEqual(ret, {foo: {bar: ['zemba', 'fleiba']}}); 133 | }); 134 | 135 | it('`$addToSet` pushes doc value if not present', function () { 136 | [0, 1].forEach(function () { 137 | var update = {'$addToSet': {foo: 'zemba'}} 138 | , doc = {foo: ['bar'], hey: 'joe'} 139 | , ret = UpdateDocument({document: doc, update: update}); 140 | 141 | assert.deepEqual(ret, {foo: ['bar', 'zemba'], hey: 'joe'}); 142 | }); 143 | }); 144 | 145 | it('`$addToSet` pushes doc value if not present using `dot_notation`', function () { 146 | [0, 1].forEach(function () { 147 | var update = {'$addToSet': {'foo.bar': 'zemba'}} 148 | , doc = {foo: {bar: ['bar']}} 149 | , ret = UpdateDocument({document: doc, update: update}); 150 | 151 | assert.deepEqual(ret, {foo: {bar: ['bar', 'zemba']}}); 152 | }); 153 | }); 154 | 155 | it('`$addToSet` pushes doc value if not present using `dot_notation` on `inexistant` elements', function () { 156 | [0, 1].forEach(function () { 157 | var update = {'$addToSet': {'foo.bar': 'zemba'}} 158 | , doc = {} 159 | , ret = UpdateDocument({document: doc, update: update}); 160 | 161 | assert.deepEqual(ret, {foo: {bar: ['zemba']}}); 162 | }); 163 | }); 164 | 165 | it('`$pop` pops array element', function () { 166 | var update = {'$pop': {foo: 1}} 167 | , doc = {foo: ['bar', 'zemba', 'fleiba'], hey: 'joe'} 168 | , ret = UpdateDocument({document: doc, update: update}); 169 | 170 | assert.deepEqual(ret, {foo: ['bar', 'zemba'], hey: 'joe'}); 171 | 172 | update = {'$pop': {foo: -1}}; 173 | doc = {foo: ['bar', 'zemba', 'fleiba'], hey: 'joe'}; 174 | ret = UpdateDocument({document: doc, update: update}); 175 | 176 | assert.deepEqual(ret, {foo: ['zemba', 'fleiba'], hey: 'joe'}); 177 | }); 178 | 179 | it('`$pop` pops array element using `dot_notation`', function () { 180 | var update = {'$pop': {'foo.bar': 1}} 181 | , doc = {foo: {bar: ['bar', 'zemba', 'fleiba']}} 182 | , ret = UpdateDocument({document: doc, update: update}); 183 | 184 | assert.deepEqual(ret, {foo: {bar: ['bar', 'zemba']}}); 185 | 186 | update = {'$pop': {'foo.bar': -1}}; 187 | doc = {foo: {bar: ['bar', 'zemba', 'fleiba']}}; 188 | ret = UpdateDocument({document: doc, update: update}); 189 | 190 | assert.deepEqual(ret, {foo: {bar: ['zemba', 'fleiba']}}); 191 | }); 192 | 193 | it('`$pop` pops array element using `dot_notation` on `inexistant` elements', function () { 194 | var update = {'$pop': {'foo.bar': 1}} 195 | , doc = {} 196 | , ret = UpdateDocument({document: doc, update: update}); 197 | 198 | assert.deepEqual(ret, {}); 199 | }); 200 | 201 | it('`$pull` removes array elements matchin a value', function () { 202 | var update = {'$pull': {foo: 'zemba'}} 203 | , doc = {foo: ['bar', 'zemba', 'zemba', 'fleiba'], hey: 'joe'} 204 | , ret = UpdateDocument({document: doc, update: update}); 205 | 206 | assert.deepEqual(ret, {foo: ['bar', 'fleiba'], hey: 'joe'}); 207 | }); 208 | 209 | it('`$pull` removes array elements matchin a value using `dot_notation`', function () { 210 | var update = {'$pull': {'foo.bar': 'zemba'}} 211 | , doc = {foo: {bar: ['bar', 'zemba', 'zemba', 'fleiba']}} 212 | , ret = UpdateDocument({document: doc, update: update}); 213 | 214 | assert.deepEqual(ret, {foo: {bar: ['bar', 'fleiba']}}); 215 | }); 216 | 217 | it('`$pull` removes array elements matchin a value using `dot_notation` on `inexistant` elements', function () { 218 | var update = {'$pull': {'foo.bar': 'zemba'}} 219 | , doc = {} 220 | , ret = UpdateDocument({document: doc, update: update}); 221 | 222 | assert.deepEqual(ret, {}); 223 | }); 224 | 225 | it('`$pullAll` removes array elements in a array', function () { 226 | var update = {'$pullAll': {foo: ['fleiba', 'zemba']}} 227 | , doc = {foo: ['bar', 'zemba', 'zemba', 'fleiba'], hey: 'joe'} 228 | , ret = UpdateDocument({document: doc, update: update}); 229 | 230 | assert.deepEqual(ret, {foo: ['bar'], hey: 'joe'}); 231 | }); 232 | 233 | it('`$pullAll` removes array elements in a array using `dot_notation`', function () { 234 | var update = {'$pullAll': {'foo.bar': ['fleiba', 'zemba']}} 235 | , doc = {foo: {bar: ['bar', 'zemba', 'zemba', 'fleiba']}} 236 | , ret = UpdateDocument({document: doc, update: update}); 237 | 238 | assert.deepEqual(ret, {foo: {bar: ['bar']}}); 239 | }); 240 | 241 | it('`$pullAll` removes array elements in a array using `dot_notation` on `inexistant` elements', function () { 242 | var update = {'$pullAll': {'foo.bar': ['fleiba', 'zemba']}} 243 | , doc = {} 244 | , ret = UpdateDocument({document: doc, update: update}); 245 | 246 | assert.deepEqual(ret, {}); 247 | }); 248 | 249 | it('`$rename` renames a field', function () { 250 | var update = {'$rename': {foo: 'bar'}} 251 | , doc = {foo: 'foo', hey: 'joe'} 252 | , ret = UpdateDocument({document: doc, update: update}); 253 | 254 | assert.deepEqual(ret, {bar: 'foo', hey: 'joe'}); 255 | }); 256 | 257 | it('`$rename` renames a field using `dot_notation`', function () { 258 | var update = {'$rename': {'foo.bar': 'far'}} 259 | , doc = {foo: {bar: 'foo'}} 260 | , ret = UpdateDocument({document: doc, update: update}); 261 | 262 | assert.deepEqual(ret, {foo: {far: 'foo'}}); 263 | }); 264 | 265 | it('`$rename` renames a field using `dot_notation` on `inexistant` elements', function () { 266 | var update = {'$rename': {'foo.bar': 'far'}} 267 | , doc = {} 268 | , ret = UpdateDocument({document: doc, update: update}); 269 | 270 | assert.deepEqual(ret, {}); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /test/model_test.js: -------------------------------------------------------------------------------- 1 | /*globals describe, beforeEach, it*/ 2 | var assert = require('assert') 3 | , sinon = require('sinon') 4 | , Model = require('./../lib/model') 5 | , Validator = require('./../lib/validator') 6 | 7 | , _db = {bson_serializer: {}, collection: function () {}} 8 | , _mock_validator = function (ret) { 9 | return { 10 | hasErrors: function () { 11 | return ret; 12 | } 13 | }; 14 | } 15 | , User; 16 | 17 | describe('Models', function () { 18 | 19 | beforeEach(function () { 20 | User = Model(_db, 'users'); 21 | }); 22 | 23 | it('`core` throws an error when there is no db', function () { 24 | assert.throws(function () { 25 | Model(null); 26 | }, 'You must specify a db'); 27 | }); 28 | 29 | it('`core` throws an error when collection is missing', function () { 30 | assert.throws(function () { 31 | Model(_db); 32 | }, 'You must specify a collection name'); 33 | }); 34 | 35 | it('`getCollection` returns a document collection', sinon.test(function () { 36 | var cb = function () {} 37 | , stub, self = this; 38 | 39 | stub = self.stub(_db, 'collection').withArgs('users', cb); 40 | 41 | User.getCollection(cb); 42 | sinon.assert.calledOnce(stub); 43 | })); 44 | 45 | it('`mongo` proxies collection calls', sinon.test(function () { 46 | var callback = function (error, doc) {} 47 | , stub, self = this 48 | , query = {name: 'zemba'}; 49 | 50 | stub = self.stub(User.collection_proxy, 'proxy', function (_model, _options, _args) { 51 | assert.deepEqual(_options, {hooks: true, namespacing: true, mapping: true, method: 'findArray'}); 52 | assert.deepEqual(_args[0], query); 53 | assert.deepEqual(_args[1], callback); 54 | }); 55 | 56 | User.mongo('findArray', query, callback); 57 | sinon.assert.calledOnce(stub); 58 | })); 59 | 60 | it('`mongo` proxies namespaced collection calls', sinon.test(function () { 61 | var callback = function (error, doc) {} 62 | , stub, self = this 63 | , query = {name: 'zemba'}; 64 | 65 | stub = self.stub(User.collection_proxy, 'proxy', function (_model, _options, _args) { 66 | assert.deepEqual(_options, { 67 | hooks: true 68 | , namespacing: true 69 | , mapping: true 70 | , method: 'findArray' 71 | , namespace: 'public' 72 | }); 73 | assert.deepEqual(_args[0], query); 74 | assert.deepEqual(_args[1], callback); 75 | }); 76 | 77 | User.mongo('findArray:public', query, callback); 78 | sinon.assert.calledOnce(stub); 79 | })); 80 | 81 | it('`mongo` proxies with options', sinon.test(function () { 82 | var callback = function (error, doc) {} 83 | , stub, self = this 84 | , query = {name: 'zemba'}; 85 | 86 | stub = self.stub(User.collection_proxy, 'proxy', function (_model, _options, _args) { 87 | assert.deepEqual(_options, { 88 | hooks: false 89 | , namespacing: true 90 | , mapping: true 91 | , method: 'findArray' 92 | , namespace: 'public' 93 | }); 94 | assert.deepEqual(_args[0], query); 95 | assert.deepEqual(_args[1], callback); 96 | }); 97 | 98 | User.mongo({method: 'findArray', namespace: 'public', hooks: false}, query, callback); 99 | sinon.assert.calledOnce(stub); 100 | })); 101 | 102 | it('`validate` validates a mongo document', sinon.test(function () { 103 | var document = {} 104 | , self = this, stub 105 | , update = {name: 'Pau'} 106 | , validator = {data: 'foo'} 107 | , callback; 108 | 109 | stub = self.stub(User, 'validator').withArgs(document, update).returns(validator); 110 | callback = self.spy().withArgs(null, validator); 111 | 112 | User.validate(document, update, callback); 113 | sinon.assert.calledOnce(stub); 114 | sinon.assert.calledOnce(callback); 115 | })); 116 | 117 | it('`validateAndInsert` when the model is invalid does not insert it', sinon.test(function () { 118 | var document = {} 119 | , self = this, stub 120 | , validator = _mock_validator(true) 121 | , callback; 122 | 123 | stub = self.stub(User, 'validate', function (_document, _data, _callback) { 124 | _callback(null, validator); 125 | }); 126 | callback = self.spy().withArgs(null, validator); 127 | 128 | User.validateAndInsert(document, callback); 129 | sinon.assert.calledOnce(stub); 130 | sinon.assert.calledOnce(callback); 131 | })); 132 | 133 | it('`validateAndInsert` when the model is valid inserts it afterwards', sinon.test(function () { 134 | var document = {foo: 'bar'} 135 | , self = this, stub1, stub2 136 | , validator = _mock_validator(false) 137 | , callback; 138 | 139 | User.maps = { 140 | foo: function (el) { 141 | return el.toUpperCase(); 142 | } 143 | }; 144 | 145 | stub1 = self.stub(User, 'validate', function (_document, _data, _callback) { 146 | _callback(null, validator); 147 | }); 148 | 149 | stub2 = self.stub(User, 'mongo', function (_action, _document, _callback) { 150 | assert.deepEqual(_action, {method: 'insert', namespacing: false, mapping: false}); 151 | assert.deepEqual(_document.foo, 'BAR'); 152 | _callback(null, _document); 153 | }); 154 | 155 | callback = self.spy().withArgs(null, validator); 156 | 157 | User.validateAndInsert(document, callback); 158 | sinon.assert.calledOnce(stub1); 159 | sinon.assert.calledOnce(stub2); 160 | sinon.assert.calledOnce(callback); 161 | })); 162 | 163 | it('`beforeInsert` default hook sets the created_at date', sinon.test(function () { 164 | var documents = [{name: 'zemba'}, {foo: 'bar'}]; 165 | 166 | User.beforeInsert(documents, function (_error, _documents) { 167 | _documents.forEach(function (document) { 168 | assert.ok(document.created_at); 169 | assert.equal(document.created_at.constructor, (new Date()).constructor); 170 | }); 171 | }); 172 | })); 173 | 174 | it('`beforeUpdate` default hook updated the updated_at date', sinon.test(function () { 175 | var query = {foo: 'bar'} 176 | , update = {'$set': {fleiba: 'zemba'}}; 177 | 178 | User.beforeUpdate(query, update, function (error, _query, _update) { 179 | assert.ok(_update.$set); 180 | assert.ok(_update.$set.updated_at); 181 | assert.equal(_update.$set.updated_at.constructor, (new Date()).constructor); 182 | }); 183 | })); 184 | 185 | it('`validateAndUpdate` when the model is invalid does not update it', sinon.test(function () { 186 | var query = {foo: 'bar'} 187 | , self = this, stub1, stub2 188 | , document = {foo: 'bar', fleiba: 'foo'} 189 | , update = {fleiba: 'zemba'} 190 | , validator = _mock_validator(true) 191 | , options = {} 192 | , callback; 193 | 194 | stub1 = self.stub(User, 'mongo', function (_method, _query, _callback) { 195 | assert.equal(_method, 'findOne'); 196 | assert.deepEqual(_query, query); 197 | _callback(null, document); 198 | }); 199 | stub2 = self.stub(User, 'validate', function (_document, _data, _callback) { 200 | _callback(null, validator); 201 | }); 202 | callback = self.spy().withArgs(null, validator); 203 | 204 | User.validateAndUpdate(query, update, options, callback); 205 | sinon.assert.calledOnce(stub1); 206 | sinon.assert.calledOnce(stub2); 207 | sinon.assert.calledOnce(callback); 208 | })); 209 | 210 | it('`validateAndUpdate` when the model is valid updates it afterwards', sinon.test(function () { 211 | var query = {foo: 'bar'} 212 | , self = this, stub1, stub2, stub3 213 | , document = {_id: '123', foo: 'bar'} 214 | , update = {'$set': {fleiba: 'John'}} 215 | , validator = _mock_validator(false) 216 | , options = {} 217 | , callback; 218 | 219 | User.maps = { 220 | fleiba: function (el) { 221 | return el.toLowerCase(); 222 | } 223 | }; 224 | 225 | stub1 = self.stub(User, 'mongo', function (_method, _query, _callback) { 226 | assert.equal(_method, 'findOne'); 227 | assert.deepEqual(_query, query); 228 | 229 | sinon.assert.calledOnce(stub1); 230 | stub1.restore(); 231 | stub3 = self.stub(User, 'mongo', function (_action, _document, _update, _options, _callback) { 232 | assert.deepEqual(_action, {method: 'update', namespacing: false, mapping: false}); 233 | assert.deepEqual(_document._id, document._id); 234 | assert.deepEqual(_update.$set.fleiba, 'john'); 235 | assert.deepEqual(_options, options); 236 | _callback(null, _document); 237 | }); 238 | 239 | _callback(null, document); 240 | }); 241 | 242 | User.beforeUpdate = function (_query, _update, _callback) { 243 | _callback(null, document); 244 | }; 245 | 246 | stub2 = self.stub(User, 'validate', function (_document, _data, _callback) { 247 | _callback(null, validator); 248 | }); 249 | 250 | callback = self.spy().withArgs(null, validator); 251 | 252 | User.validateAndUpdate(query, update, options, callback); 253 | sinon.assert.calledOnce(stub2); 254 | sinon.assert.calledOnce(stub3); 255 | sinon.assert.calledOnce(callback); 256 | })); 257 | 258 | it('`getEmbeddedDocument` filters the document following the skeletons directive', sinon.test(function () { 259 | var comment = {_id: 1, title: 'foo', body: 'Lorem ipsum'}; 260 | 261 | User.skeletons = { 262 | comment: ['_id', 'title'] 263 | }; 264 | 265 | assert.deepEqual(User.getEmbeddedDocument('comment', comment), { _id: 1, title: 'foo' }); 266 | assert.deepEqual( 267 | User.getEmbeddedDocument('comment', comment, 'post.comment') 268 | , {post: {comment: {_id: 1, title: 'foo'}}} 269 | ); 270 | })); 271 | 272 | it('`getEmbeddedDocument` filters the document following recursive skeletons directives', sinon.test(function () { 273 | var post = {_id: 1, title: 'foo', body: 'Lorem ipsum', comment: {body: 'comment body!', created_at: Date.now()}}; 274 | 275 | User.skeletons = { 276 | post: ['_id', 'title', 'comment.body'] 277 | }; 278 | 279 | assert.deepEqual(User.getEmbeddedDocument('post', post), {_id: 1, title: 'foo', comment: {body: 'comment body!'}}); 280 | assert.deepEqual( 281 | User.getEmbeddedDocument('post', post, 'post') 282 | , {post: {_id: 1, title: 'foo', comment: {body: 'comment body!'}}} 283 | ); 284 | })); 285 | 286 | it('`getEmbeddedDocument` returns appropiate `dot_notation` strings', sinon.test(function () { 287 | var comment = {_id: 1, title: 'foo', body: 'Lorem ipsum'}; 288 | 289 | User.skeletons = { 290 | comment: ['_id', 'title'] 291 | }; 292 | 293 | assert.deepEqual(User.getEmbeddedDocument('comment', comment), {_id: 1, title: 'foo'}); 294 | assert.deepEqual( 295 | User.getEmbeddedDocument('comment', comment, 'post', true) 296 | , {'post._id': 1, 'post.title': 'foo'} 297 | ); 298 | assert.deepEqual( 299 | User.getEmbeddedDocument('comment', comment, 'post.comment', true) 300 | , {'post.comment._id': 1, 'post.comment.title': 'foo'} 301 | ); 302 | })); 303 | 304 | it('`getEmbeddedDocument` returns appropiate `dot_notation` strings using rescursive stuff', sinon.test(function () { 305 | var post = {_id: 1, title: 'foo', body: 'Lorem ipsum', comment: {body: 'comment body!', created_at: Date.now()}}; 306 | 307 | User.skeletons = { 308 | post: ['_id', 'title', 'comment.body'] 309 | }; 310 | 311 | assert.deepEqual( 312 | User.getEmbeddedDocument('post', post, 'user.post', true) 313 | , {'user.post._id': 1, 'user.post.title': 'foo', 'user.post.comment.body': 'comment body!'} 314 | ); 315 | })); 316 | 317 | it('`getEmbeddedDocument` works without specifying the skeletons', sinon.test(function () { 318 | var comment = {_id: 1, title: 'foo', body: 'Lorem ipsum'}; 319 | 320 | User.skeletons = null; 321 | 322 | assert.deepEqual(User.getEmbeddedDocument('comment', comment), { _id: 1, title: 'foo', body: 'Lorem ipsum'}); 323 | assert.deepEqual( 324 | User.getEmbeddedDocument('comment', comment, 'post.comment') 325 | , {post: {comment: comment}} 326 | ); 327 | })); 328 | 329 | it('`updateEmbeddedDocument` updates embedded objects', sinon.test(function () { 330 | var embeddedDocument = {name: 'john', surname: 'snow', bo: 'vale'} 331 | , self = this, stub 332 | , options = {upsert: true} 333 | , callback = function () {}; 334 | 335 | User.skeletons = { 336 | author: ['_id', 'name', 'surname'] 337 | }; 338 | 339 | stub = self.stub(User, 'mongo', function (_opts, _query, _update, _options, _callback) { 340 | assert.deepEqual(_opts, {method: 'update', hooks: false}); 341 | assert.deepEqual(_query, {'author._id': 1}); 342 | assert.deepEqual(_update, {'$set': {'author.name': 'john', 'author.surname': 'snow'}}); 343 | assert.deepEqual(_options, {upsert: true, multi: true}); 344 | assert.equal(_callback, callback); 345 | }); 346 | 347 | User.updateEmbeddedDocument({_id: 1}, 'author', embeddedDocument, options, callback); 348 | sinon.assert.calledOnce(stub); 349 | })); 350 | 351 | it('`pushEmbeddedDocument` pushes embedded objects', sinon.test(function () { 352 | var embeddedDocument = {name: 'john'} 353 | , self = this, stub1, stub2 354 | , collection = {foo: 'bar'}; 355 | 356 | stub1 = self.stub(User, 'getEmbeddedDocument', function (_name, _doc, _scope, _dot_notation) { 357 | assert.equal(_name, 'author'); 358 | assert.deepEqual(_doc, embeddedDocument); 359 | assert.ifError(_scope); 360 | assert.ifError(_dot_notation); 361 | return embeddedDocument; 362 | }); 363 | 364 | stub2 = self.stub(User, 'mongo', function (_opts, _query, _update, _options, _callback) { 365 | assert.deepEqual(_opts, {method: 'update', hooks: false}); 366 | assert.deepEqual(_query, {_id: 1}); 367 | assert.deepEqual(_update, {'$push': {author: embeddedDocument}}); 368 | assert.deepEqual(_options, {upsert: false, multi: true}); 369 | }); 370 | 371 | User.pushEmbeddedDocument({_id: 1}, 'author', embeddedDocument); 372 | sinon.assert.calledOnce(stub1); 373 | sinon.assert.calledOnce(stub2); 374 | })); 375 | }); 376 | -------------------------------------------------------------------------------- /lib/model.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'); 2 | 3 | module.exports = function (db, collection_name) { 4 | 5 | if (!db) { 6 | throw (Error('You must specify a db')); 7 | } 8 | 9 | if (!collection_name) { 10 | throw (Error('You must specify a collection name')); 11 | } 12 | 13 | var MODEL = { 14 | db: db 15 | , collection_proxy: require('./helpers/collection_proxy') 16 | }; 17 | 18 | /** 19 | * Gets the mongo collection 20 | * 21 | * @param {Function} callback 22 | * @returns itself 23 | */ 24 | MODEL.getCollection = function (callback) { 25 | db.collection(collection_name, callback); 26 | 27 | return MODEL; 28 | }; 29 | 30 | MODEL.validator = require('./validator'); 31 | 32 | /** 33 | * Proxies `collection` calls and then: 34 | * 35 | * + Namespacing: Filters sent and received documents according to namespaces. 36 | * + Mapping: Applies functions to sent and received document attributes. 37 | * + Hooks: Calls some before* and after* hooks 38 | * 39 | * This functionalities are enabled by default, but you can disable them by providing 40 | * options as the first param. 41 | * 42 | * Adds custom `findArray`, `mapReduceCursor` and `mapReduceArray`. 43 | * 44 | * @param {String|Object} options 45 | * @param {Mixed} args 46 | * @param {Function} callback 47 | * @return itself 48 | */ 49 | MODEL.mongo = function (args) { 50 | var parsed_method, options, callback; 51 | 52 | args = _.toArray(arguments); 53 | options = args.shift(); 54 | 55 | if (typeof options === 'string') { 56 | parsed_method = options.match(/([a-zA-Z]*)(?::([a-zA-Z]*))?/); // extract the namespace 57 | options = { 58 | method: parsed_method[1] 59 | , namespace: parsed_method[2] 60 | }; 61 | } 62 | 63 | if (!options.method) { 64 | throw (Error('`method` is mandatory!')); 65 | } 66 | 67 | options = _.extend({ 68 | hooks: true 69 | , namespacing: true 70 | , mapping: true 71 | }, options); 72 | 73 | MODEL.collection_proxy.proxy(MODEL, options, args); 74 | 75 | return MODEL; 76 | }; 77 | 78 | /** 79 | * Validates a mongo document 80 | * 81 | * @param {Object} document 82 | * @param {Object} update 83 | * @param {Function} callback 84 | */ 85 | MODEL.validate = function (document, update, callback) { 86 | callback(null, MODEL.validator(document, update)); 87 | }; 88 | 89 | /** 90 | * Validates a mongo document and inserts it 91 | * 92 | * TODO: Refactor 93 | * 94 | * @param {Object} document 95 | * @param {Object} options 96 | * @param {Function} callback 97 | * @returns itself 98 | */ 99 | MODEL.validateAndInsert = function (document, options, callback) { 100 | // options are optional 101 | if (typeof options === 'function') { 102 | callback = options; 103 | options = {}; 104 | } else { 105 | options = options || {}; 106 | } 107 | 108 | var fn = {method: 'insert', namespacing: false, mapping: false} 109 | , args = [document]; 110 | 111 | if (options.namespace) { 112 | fn.namespace = options.namespace; 113 | } 114 | 115 | if (MODEL.namespaces && MODEL.namespaces[options.namespace]) { 116 | // has side effects, alters args 117 | MODEL.collection_proxy.namespacer.filter(MODEL.namespaces, options.namespace, 'insert', args); 118 | } 119 | 120 | if (MODEL.maps) { 121 | // has side effects, alters args 122 | MODEL.collection_proxy.mapper.map(MODEL.maps, 'insert', args); 123 | } 124 | 125 | MODEL.validate({}, document, function (error, validator) { 126 | if (error) { 127 | return callback(error); 128 | } 129 | 130 | if (validator.hasErrors()) { 131 | return callback(null, validator); 132 | } 133 | 134 | args.push(function cb(error, documents) { 135 | if (error) { 136 | return callback(error); 137 | } else { 138 | if (documents) { 139 | validator.updated_document = documents[0]; 140 | } 141 | callback(error, validator); 142 | } 143 | }); 144 | args.unshift(fn); 145 | MODEL.mongo.apply(MODEL, args); 146 | }); 147 | 148 | return MODEL; 149 | }; 150 | 151 | /** 152 | * Hook triggered before inserting a document 153 | * 154 | * @param {Array} documents 155 | * @param {Function} callback 156 | */ 157 | MODEL.beforeInsert = function (documents, callback) { 158 | documents.forEach(function (document) { 159 | if (!document.created_at) { 160 | document.created_at = new Date(); 161 | } 162 | }); 163 | callback(null, documents); 164 | }; 165 | 166 | /** 167 | * Hook triggered after inserting a document 168 | * 169 | * @param {Array} documents 170 | * @param {Function} callback 171 | */ 172 | MODEL.afterInsert = function (documents, callback) { 173 | callback(null, documents); 174 | }; 175 | 176 | /** 177 | * Hook triggered before updating a document 178 | * 179 | * @param {Object} query 180 | * @param {Object} update 181 | * @param {Function} callback 182 | */ 183 | MODEL.beforeUpdate = function (query, update, callback) { 184 | update.$set = update.$set || {}; 185 | update.$set.updated_at = new Date(); 186 | callback(null, query, update); 187 | }; 188 | 189 | /** 190 | * Hook triggered after updating a document 191 | * 192 | * @param {Object} query 193 | * @param {Object} update 194 | * @param {Function} callback 195 | */ 196 | MODEL.afterUpdate = function (query, update, callback) { 197 | callback(null, query, update); 198 | }; 199 | 200 | /** 201 | * Hook triggered before removing a document 202 | * 203 | * @param {Object} query 204 | * @param {Function} callback 205 | */ 206 | MODEL.beforeRemove = function (query, callback) { 207 | callback(null, query); 208 | }; 209 | 210 | /** 211 | * Hook triggered after removing a document 212 | * 213 | * @param {Object} query 214 | * @param {Function} callback 215 | */ 216 | MODEL.afterRemove = function (query, callback) { 217 | callback(null, query); 218 | }; 219 | 220 | /** 221 | * Validates a mongo document and updates it 222 | * 223 | * @param {Object} query - Document query 224 | * @param {Object} update - Update object 225 | * @param {Object} options - Update options. Defaults to {upsert: false, multi: false} 226 | * @param {Function} callback - The callback returns a validator 227 | * 228 | * @returns itself 229 | */ 230 | MODEL.validateAndUpdate = function (query, update, options, callback) { 231 | // options are optional 232 | if (typeof options === 'function') { 233 | callback = options; 234 | options = {}; 235 | } else { 236 | options = options || {}; 237 | } 238 | 239 | options = _.defaults(options, {upsert: false, multi: false}); 240 | 241 | var fn = {method: 'update', namespacing: false, mapping: false} 242 | , args = [query, update, options]; 243 | 244 | if (options.namespace) { 245 | fn.namespace = options.namespace; 246 | } 247 | 248 | delete options.namespace; 249 | 250 | if (MODEL.namespaces && MODEL.namespaces[options.namespace]) { 251 | // has side effects, alters args 252 | MODEL.collection_proxy.namespacer.filter(MODEL.namespaces, options.namespace, 'update', args); 253 | } 254 | 255 | if (MODEL.maps) { 256 | // has side effects, alters args 257 | MODEL.collection_proxy.mapper.map(MODEL.maps, 'update', args); 258 | } 259 | 260 | MODEL.mongo('findOne', query, function (error, document) { 261 | if (error) { 262 | return callback(error); 263 | } 264 | 265 | MODEL.validate(document, update, function (error, validator) { 266 | if (error) { 267 | return callback(error); 268 | } 269 | 270 | if (validator.hasErrors()) { 271 | callback(null, validator); 272 | } else { 273 | args.push(function cb(error, _) { 274 | callback(error, validator); 275 | }); 276 | 277 | args.unshift(fn); 278 | args[1] = {_id: document._id}; 279 | MODEL.mongo.apply(MODEL, args); 280 | } 281 | }); 282 | }); 283 | 284 | return MODEL; 285 | }; 286 | 287 | /** 288 | * Get the document ready to embed to this model according to the skeleton 289 | * 290 | * TODO: Refactor this method! Or take it away as a helper 291 | * 292 | * @param {String} name 293 | * @param {Object} document 294 | * @param {String} scope - Optional field to scope the resulting document 295 | * @param {Boolean} dot_notation - Return the nested object or using dot_notation ready for mongo 296 | * 297 | * @returns document with applied skeleton 298 | */ 299 | MODEL.getEmbeddedDocument = function (name, document, scope, dot_notation) { 300 | var filtered = _.clone(document) 301 | , result = {} 302 | , pointer = result; 303 | 304 | 305 | function parseAttr(scope, attr) { 306 | return dot_notation ? ((scope ? scope + '.' : '') + attr) : attr; 307 | } 308 | 309 | /** 310 | * 0: not visible 311 | * 1: partial visible, needs more inspection 312 | * 2: fully visible 313 | */ 314 | function isVisible(attr, level) { 315 | var ret = 0; 316 | 317 | if (MODEL.skeletons && MODEL.skeletons[name]) { 318 | _.some(MODEL.skeletons[name], function (el) { 319 | var match; 320 | if (attr.indexOf('.') !== -1) { 321 | match = attr.match(new RegExp(el + '(\..*)?')); 322 | if (match) { 323 | ret = 2; 324 | } 325 | } else { 326 | el = el.split('.'); 327 | match = el[level] === attr; 328 | if (match) { 329 | ret = el.length === level + 1 ? 2 : 1; 330 | } 331 | } 332 | return match; 333 | }); 334 | } else { 335 | return 2; // if no skeleton available, everything is visible! 336 | } 337 | 338 | return ret; 339 | } 340 | 341 | function filter(el, level, accumulated_scope) { 342 | var attr, is_visible; 343 | 344 | for (attr in el) { 345 | if (el.hasOwnProperty(attr)) { 346 | is_visible = isVisible(attr, level); 347 | if (is_visible === 0) { 348 | delete el[attr]; 349 | } else { 350 | 351 | if (is_visible === 1 && typeof el[attr] === 'object') { 352 | filter(el[attr], level + 1, accumulated_scope ? accumulated_scope + '.' + attr : attr); 353 | if (dot_notation) { 354 | delete el[attr]; 355 | } 356 | } else { 357 | if (dot_notation && parseAttr(attr) !== attr) { 358 | if (accumulated_scope) { 359 | filtered[parseAttr(parseAttr(scope, accumulated_scope), attr)] = el[attr]; 360 | } else { 361 | el[parseAttr(scope, attr)] = el[attr]; 362 | } 363 | delete el[attr]; 364 | } 365 | } 366 | } 367 | } 368 | } 369 | } 370 | 371 | filter(filtered, 0); 372 | 373 | if (scope && !dot_notation) { 374 | scope.split('.').forEach(function (level, i, array) { 375 | pointer = pointer[level] = (i === array.length - 1) ? filtered : {}; 376 | }); 377 | return result; 378 | } else { 379 | return filtered; 380 | } 381 | }; 382 | 383 | /** 384 | * Update all the embedded objects 385 | * 386 | * @param {ObjectID} id - Id of the embedded document to be updated 387 | * @param {String} document_name - Name for the embedded document 388 | * @param {Object} document - Document to be embedded 389 | * @param {Object} options - Update options. Defaults to {upsert: false, multi: true} 390 | * @param {Function} callback 391 | * 392 | * @returns itself 393 | */ 394 | MODEL.updateEmbeddedDocument = function (query, document_name, document, options, callback) { 395 | var update = {}, 396 | scoped_query = {}; 397 | 398 | document = document.$set || document; 399 | 400 | update = MODEL.getEmbeddedDocument(document_name, document, document_name, true); 401 | 402 | // options are optional 403 | if (typeof options === 'function') { 404 | callback = options; 405 | options = {}; 406 | } else { 407 | options = options || {}; 408 | } 409 | 410 | options = _.extend({upsert: false, multi: true}, options); 411 | Object.keys(query).forEach(function (attr) { 412 | scoped_query[document_name + '.' + attr] = query[attr]; 413 | }); 414 | 415 | MODEL.mongo({method: 'update', hooks: false}, scoped_query, {'$set': update}, options, callback); 416 | 417 | return MODEL; 418 | }; 419 | 420 | /** 421 | * Push an embedded document. 422 | * 423 | * @param {Object} query - Query to match the objects to push the embedded doc 424 | * @param {String} document_name - Name for the embedded document array 425 | * @param {Object} document - Document to be push 426 | * @param {Object} options - Update options. Defaults to {upsert: false, multi: true} 427 | * @param {Function} callback 428 | * 429 | * @returns itself 430 | */ 431 | MODEL.pushEmbeddedDocument = function (query, document_name, document, options, callback) { 432 | var update = {}; 433 | 434 | update[document_name] = MODEL.getEmbeddedDocument(document_name, document); 435 | 436 | // options are optional 437 | if (typeof options === 'function') { 438 | callback = options; 439 | options = {}; 440 | } else { 441 | options = options || {}; 442 | } 443 | 444 | options = _.extend({upsert: false, multi: true}, options); 445 | MODEL.mongo({method: 'update', hooks: false}, query, {'$push': update}, options, callback); 446 | 447 | return MODEL; 448 | }; 449 | 450 | /** 451 | * Add some shortcuts for collection methods 452 | */ 453 | _.each([ 'find', 'findArray', 'insert', 'update', 'remove', 'rename', 'insertAll' 454 | , 'save', 'distinct', 'count', 'drop', 'findAndModify', 'findAndRemove' 455 | , 'findOne', 'mapReduce', 'mapReduceArray', 'mapReduceCursor', 'group' 456 | , 'aggregate', 'stats'], function (method) { 457 | MODEL[method] = function () { 458 | MODEL.mongo.apply(MODEL, [method].concat(_.toArray(arguments))); 459 | }; 460 | }); 461 | 462 | return MODEL; 463 | }; 464 | -------------------------------------------------------------------------------- /test/helpers_collection_proxy_test.js: -------------------------------------------------------------------------------- 1 | /*globals describe, it, beforeEach, afterEach*/ 2 | var assert = require('assert') 3 | , sinon = require('sinon') 4 | , CollectionProxy = require('./../lib/helpers/collection_proxy') 5 | , noop = function () { 6 | return function () {}; 7 | } 8 | , Collection = { update: noop(), insert: noop(), findArray: noop() 9 | , find: noop(), findAndModify: noop(), mapReduce: noop(), remove: noop()} 10 | , Model; 11 | 12 | function stubGetCollection(sinon) { 13 | var stub = sinon.stub(Model, 'getCollection', function (_callback) { 14 | _callback(null, Collection); 15 | stub.restore(); 16 | }); 17 | } 18 | 19 | describe('collection proxy test', function () { 20 | 21 | beforeEach(function () { 22 | Model = require('../').model({}, 'models'); 23 | }); 24 | 25 | describe('proxy', function () { 26 | it('delegates every call to collection_proxy or native driver collection functions', sinon.test(function () { 27 | var cb = function cb() {} 28 | , self = this 29 | , stub 30 | , args = ['zemba', cb]; 31 | 32 | ['update', 'insert', 'findArray'].forEach(function (method) { 33 | stubGetCollection(self); 34 | stub = self.stub(CollectionProxy, method).withArgs(Model, Collection, args, cb); 35 | CollectionProxy.proxy(Model, {method: method, hooks: true}, args, cb); 36 | sinon.assert.calledOnce(stub); 37 | }); 38 | 39 | ['find', 'update', 'insert'].forEach(function (method) { 40 | stubGetCollection(self); 41 | stub = self.stub(Collection, method).withArgs(args[0], args[1]); 42 | CollectionProxy.proxy(Model, {method: method, hooks: false}, args, cb); 43 | sinon.assert.calledOnce(stub); 44 | }); 45 | })); 46 | 47 | describe('namespace', function () { 48 | var cb, args; 49 | 50 | beforeEach(function () { 51 | cb = function () {}; 52 | args = [{foo: 'bar'}, cb]; 53 | 54 | Model.namespaces = {foo: ['zemba', 'fleiba']}; 55 | }); 56 | 57 | it('modifies the arguments if `namespace` is set to `true`', sinon.test(function () { 58 | var self = this, stub1, stub2; 59 | 60 | stubGetCollection(self); 61 | stub1 = self.stub(CollectionProxy.namespacer, 'filter').withArgs(Model.namespaces, 'foo', 'find', args); 62 | stub2 = self.stub(Collection, 'find').withArgs(args[0], args[1]); 63 | 64 | CollectionProxy.proxy(Model, {method: 'find', namespace: 'foo', namespacing: true}, args, cb); 65 | sinon.assert.calledOnce(stub1); 66 | sinon.assert.calledOnce(stub2); 67 | })); 68 | 69 | it('doesnt modify the arguments if `namespace` is set to `false`', sinon.test(function () { 70 | var self = this, stub; 71 | 72 | stubGetCollection(self); 73 | stub = self.stub(Collection, 'find').withArgs(args[0], args[1]); 74 | 75 | CollectionProxy.proxy(Model, {method: 'find', namespace: 'foo', namespacing: false}, args, cb); 76 | sinon.assert.calledOnce(stub); 77 | })); 78 | }); 79 | 80 | describe('maps', function () { 81 | var cb, args; 82 | 83 | beforeEach(function () { 84 | cb = function () {}; 85 | args = [{foo: 'bar', _id: '3'}, cb]; 86 | 87 | Model.maps = {foo: Boolean}; 88 | }); 89 | 90 | it('maps the arguments according if the `mapping` option is set to `true`', sinon.test(function () { 91 | var self = this, stub1, stub2; 92 | 93 | stubGetCollection(self); 94 | stub1 = self.stub(CollectionProxy.mapper, 'map').withArgs({foo: Boolean}, 'find', args); 95 | stub2 = self.stub(Collection, 'find').withArgs(args[0], args[1]); 96 | 97 | CollectionProxy.proxy(Model, {method: 'find', mapping: true}, args, cb); 98 | sinon.assert.calledOnce(stub1); 99 | sinon.assert.calledOnce(stub2); 100 | })); 101 | 102 | it('does not map the arguments if the `mapping` option is set to `false`', sinon.test(function () { 103 | var self = this, stub; 104 | 105 | stubGetCollection(self); 106 | stub = self.stub(Collection, 'find').withArgs(args[0], args[1]); 107 | 108 | CollectionProxy.proxy(Model, {method: 'find', mapping: false}, args, cb); 109 | sinon.assert.calledOnce(stub); 110 | })); 111 | }); 112 | 113 | it('can be called with no callback', sinon.test(function () { 114 | var args = ['zemba'] 115 | , stub 116 | , self = this; 117 | 118 | ['update', 'insert', 'findArray'].forEach(function (method) { 119 | stubGetCollection(self); 120 | stub = self.stub(CollectionProxy, method, function (m, c, a, callback) { 121 | assert.equal(typeof callback, 'function'); 122 | }); 123 | 124 | CollectionProxy.proxy(Model, {method: method, hooks: true}, args); 125 | sinon.assert.calledOnce(stub); 126 | }); 127 | 128 | ['find', 'update', 'insert'].forEach(function (method) { 129 | stubGetCollection(self); 130 | stub = self.stub(Collection, method, function (args, callback) { 131 | assert.equal(typeof callback, 'undefined'); 132 | }); 133 | 134 | CollectionProxy.proxy(Model, {method: method, hooks: false}, args); 135 | sinon.assert.calledOnce(stub); 136 | }); 137 | })); 138 | }); 139 | 140 | describe('findArray', function () { 141 | it('calls find on a collection with some arguments', sinon.test(function () { 142 | var cb = function (error, cursor) {} 143 | , self = this 144 | , stub1, stub2 145 | , cursor = {toArray: function () {}} 146 | , error_result = null 147 | , args = ['fleiba', cb]; 148 | 149 | stub1 = self.stub(Collection, 'find', function (_collection, _args) { 150 | assert.deepEqual(_collection, args[0]); 151 | stub2 = self.stub(cursor, 'toArray'); 152 | args[args.length - 1](null, cursor); 153 | }); 154 | 155 | CollectionProxy.findArray(Model, Collection, args, cb); 156 | sinon.assert.calledOnce(stub1); 157 | sinon.assert.calledOnce(stub2); 158 | })); 159 | }); 160 | 161 | describe('insert', function () { 162 | it('inserts a record', sinon.test(function () { 163 | var callback_called = false 164 | , self = this 165 | , stub1, stub2, stub3 166 | , cb = function (error, ret) { 167 | assert.deepEqual(error, null); 168 | assert.deepEqual(ret, [1, 2, 3]); 169 | callback_called = true; 170 | } 171 | , args = ['fleiba', cb]; 172 | 173 | stub1 = self.stub(Model, 'beforeInsert', function (ar, callback) { 174 | assert.deepEqual(ar, args[0]); 175 | 176 | stub2 = self.stub(Collection.insert, 'apply', function (_collection, _args) { 177 | assert.deepEqual(_collection, Collection); 178 | assert.deepEqual(_args[0], ['document1', 'document2']); 179 | 180 | stub3 = self.stub(Model, 'afterInsert', function (_docs, _callback) { 181 | assert.deepEqual(_docs, ['document1', 'document2']); 182 | _callback(null); 183 | assert.ok(callback_called); 184 | }); 185 | 186 | _args[1](null, [1, 2, 3]); 187 | }); 188 | 189 | callback(null, ['document1', 'document2']); 190 | }); 191 | 192 | CollectionProxy.insert(Model, Collection, args, cb); 193 | sinon.assert.calledOnce(stub1); 194 | sinon.assert.calledOnce(stub2); 195 | sinon.assert.calledOnce(stub3); 196 | })); 197 | }); 198 | 199 | describe('update', function () { 200 | it('finds and modifies a record', sinon.test(function () { 201 | var callback_called = false 202 | , self = this 203 | , stub1, stub2, stub3 204 | , cb = function (error, ret) { 205 | assert.deepEqual(error, null); 206 | assert.deepEqual(ret, [1, 2, 3]); 207 | callback_called = true; 208 | } 209 | , args = [{name: 'zemba'}, {'$set': {name: 'foo'}}, {}, cb]; 210 | 211 | stub1 = sinon.stub(Model, 'beforeUpdate', function (_query, _update, _callback) { 212 | assert.deepEqual(_query, args[0]); 213 | assert.deepEqual(_update, args[1]); 214 | _update.$set.updated_at = 123; 215 | 216 | stub2 = sinon.stub(Collection.update, 'apply', function (_collection, _args) { 217 | assert.deepEqual(_collection, Collection); 218 | assert.deepEqual(_args[0], args[0]); 219 | assert.deepEqual(_args[1].$set.updated_at, 123); 220 | 221 | stub3 = sinon.stub(Model, 'afterUpdate', function (_doc, _update, _callback) { 222 | assert.deepEqual(_doc, args[0]); 223 | assert.deepEqual(_update, args[1]); 224 | _callback(null); 225 | assert.ok(callback_called); 226 | }); 227 | 228 | _args[3](null, [1, 2, 3]); 229 | }); 230 | 231 | _callback(null, _query, _update); 232 | }); 233 | 234 | CollectionProxy.update(Model, Collection, args, cb); 235 | sinon.assert.calledOnce(stub1); 236 | sinon.assert.calledOnce(stub2); 237 | sinon.assert.calledOnce(stub3); 238 | })); 239 | }); 240 | 241 | describe('findAndModify', function () { 242 | it('finds and modifies a record', sinon.test(function () { 243 | var callback_called = false 244 | , self = this 245 | , stub1, stub2, stub3 246 | , cb = function (error, ret) { 247 | assert.deepEqual(error, null); 248 | assert.deepEqual(ret, [1, 2, 3]); 249 | callback_called = true; 250 | } 251 | , args = [{name: 'zemba'}, [], {'$set': {name: 'foo'}}, {}, cb]; 252 | 253 | stub1 = self.stub(Model, 'beforeUpdate', function (_query, _update, _callback) { 254 | assert.deepEqual(_query, args[0]); 255 | assert.deepEqual(_update, args[2]); 256 | _update.$set.updated_at = 123; 257 | 258 | stub2 = self.stub(Collection.findAndModify, 'apply', function (_collection, _args) { 259 | assert.deepEqual(_collection, Collection); 260 | assert.deepEqual(_args[0], args[0]); 261 | assert.deepEqual(_args[2].$set.updated_at, 123); 262 | 263 | stub3 = self.stub(Model, 'afterUpdate', function (_doc, _update, _callback) { 264 | assert.deepEqual(_doc, args[0]); 265 | assert.deepEqual(_update, args[2]); 266 | _callback(null); 267 | assert.ok(callback_called); 268 | }); 269 | 270 | _args[4](null, [1, 2, 3]); 271 | }); 272 | 273 | _callback(null, _query, _update); 274 | }); 275 | 276 | CollectionProxy.findAndModify(Model, Collection, args, cb); 277 | sinon.assert.calledOnce(stub1); 278 | sinon.assert.calledOnce(stub2); 279 | sinon.assert.calledOnce(stub3); 280 | })); 281 | }); 282 | 283 | describe('mapReduceCursor', function () { 284 | var args, stub1, stub2, coll, cb; 285 | 286 | beforeEach(function () { 287 | args = ['a', 'b']; 288 | coll = {find: function () {}}; 289 | }); 290 | 291 | it('calls `mapReduce` returning a cursor if no intermediate errors occur', sinon.test(function () { 292 | var self = this; 293 | 294 | stub1 = self.stub(Collection.mapReduce, 'apply', function (_collection, _args) { 295 | assert.equal(_collection, Collection); 296 | assert.equal(_args, args); 297 | 298 | stub2 = self.stub(coll, 'find', function (callback) { 299 | assert.ok(callback); 300 | }); 301 | 302 | _args[1](null, coll); 303 | }); 304 | 305 | cb = function () {}; 306 | 307 | CollectionProxy.mapReduceCursor(Model, Collection, args, cb); 308 | sinon.assert.calledOnce(stub1); 309 | sinon.assert.calledOnce(stub2); 310 | })); 311 | 312 | it('calls `mapReduce` returning an error if an intermediate error occurs', sinon.test(function () { 313 | var self = this 314 | , error = 'could not access the DB'; 315 | 316 | stub1 = self.stub(Collection.mapReduce, 'apply', function (_collection, _args) { 317 | assert.equal(_collection, Collection); 318 | assert.equal(_args, args); 319 | _args[1](error, coll); 320 | }); 321 | 322 | stub2 = self.spy().withArgs(error, null); 323 | 324 | CollectionProxy.mapReduceCursor(Model, Collection, args, stub2); 325 | sinon.assert.calledOnce(stub1); 326 | sinon.assert.calledOnce(stub2); 327 | })); 328 | }); 329 | 330 | describe('mapReduceArray', function () { 331 | it('returns a `mapReduceCursor` to Array', sinon.test(function () { 332 | var cb = function () {} 333 | , self = this 334 | , stub1, stub2 335 | , args = ['fleiba', cb] 336 | , cursor = {toArray: function () {}}; 337 | 338 | stub1 = self.stub(CollectionProxy, 'mapReduceCursor', function (_model, _collection, _args, _callback) { 339 | assert.equal(_model, Model); 340 | assert.equal(_collection, Collection); 341 | assert.equal(_args, args); 342 | 343 | stub2 = self.stub(cursor, 'toArray').withArgs(cb); 344 | 345 | _callback(null, cursor); 346 | }); 347 | 348 | CollectionProxy.mapReduceArray(Model, Collection, args, cb); 349 | sinon.assert.calledOnce(stub1); 350 | sinon.assert.calledOnce(stub2); 351 | })); 352 | }); 353 | 354 | describe('remove', function () { 355 | it('removes a document', sinon.test(function () { 356 | var callback_called = false 357 | , self = this 358 | , stub1, stub2, stub3 359 | , cb = function (error, ret) { 360 | assert.deepEqual(error, null); 361 | assert.deepEqual(ret, [1, 2, 3]); 362 | callback_called = true; 363 | } 364 | , args = ['fleiba', cb]; 365 | 366 | stub1 = self.stub(Model, 'beforeRemove', function (_query, _callback) { 367 | assert.deepEqual(_query, args[0]); 368 | 369 | stub2 = self.stub(Collection.remove, 'apply', function (_collection, _args) { 370 | assert.deepEqual(Collection, _collection); 371 | assert.deepEqual(_args[0], args[0]); 372 | 373 | stub3 = self.stub(Model, 'afterRemove', function (_query, _callback) { 374 | assert.deepEqual(_query, args[0]); 375 | _callback(null); 376 | assert.ok(callback_called); 377 | }); 378 | 379 | _args[1](null, [1, 2, 3]); 380 | }); 381 | 382 | _callback(null, _query); 383 | }); 384 | 385 | CollectionProxy.remove(Model, Collection, args, cb); 386 | sinon.assert.calledOnce(stub1); 387 | sinon.assert.calledOnce(stub2); 388 | sinon.assert.calledOnce(stub3); 389 | })); 390 | }); 391 | }); 392 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ooo ooooo oooo o8o 2 | `88. .888' `888 `"' 3 | 888b d'888 .ooooo. ooo. .oo. .oooooooo .ooooo. 888 oooo .oooo. 4 | 8 Y88. .P 888 d88' `88b `888P"Y88b 888' `88b d88' `88b 888 `888 `P )88b 5 | 8 `888' 888 888 888 888 888 888 888 888 888 888 888 .oP"888 6 | 8 Y 888 888 888 888 888 `88bod8P' 888 888 888 888 d8( 888 7 | o8o o888o `Y8bod8P' o888o o888o `8oooooo. `Y8bod8P' o888o o888o `Y888""8o 8 | d" YD 9 | "Y88888P' 10 | 11 | 12 | Mongolia is a thin layer that sits on top of the mongo native driver and helps you dealing with your data logic. 13 | Mongolia is not an ORM. Models contains no state, just logic. 14 | Mongolia contains no magic. 15 | 16 | ## Install 17 | 18 | ``` bash 19 | npm install mongolia 20 | ``` 21 | 22 | Mongolia contains two independent modules: 23 | 24 | * `model`: An object representing a collection with some hooks of mongo calls. 25 | * `validator`: An object that validates mongoDB documents and returns errors if found. 26 | 27 | # Model 28 | 29 | Models are attached to collections. 30 | Models don't map data from the db, they just define the logic. 31 | 32 | ``` javascript 33 | var USER = require('mongolia').model(db, 'users'); 34 | ``` 35 | 36 | ## mongo proxied collection commands 37 | 38 | Calls to the db are done using the method `mongo`. 39 | `mongo` proxies all the `collection` methods defined on the driver plus some custom methods. 40 | 41 | This allows mongolia to extend the driver with extra functionalties: 42 | 43 | * Namespacing: Allows you to filter the documents going and coming from the db. 44 | * Mapping: Allows you to apply functions to the documents attributes going and coming from the db. 45 | * Hooks: They are triggered before and after a call is done. 46 | 47 | There are two APIs: 48 | 49 | `mongo('method[:namespace]', args)` 50 | 51 | and 52 | 53 | `mongo({method: method[, namespace: namespace, namespacing: false, mapping: false, hooks: false])` 54 | 55 | Example: 56 | ``` javascript 57 | var Db = require('mongodb/lib/mongodb/db').Db, 58 | Server = require('mongodb/lib/mongodb/connection').Server, 59 | db = new Db('blog', new Server('localhost', 27017, {auto_reconnect: true, native_parser: true})); 60 | 61 | db.open(function () { 62 | var User = require('./user.js')(db); 63 | 64 | User.mongo('findOne', {name: 'foo'}, console.log); 65 | User.mongo({method: 'insert', hooks: false}, {name: 'foo'}, console.log); 66 | }); 67 | ``` 68 | 69 | All the `collection` methods from the driver are supported and have a shortcut so you can use Mongolia 70 | like the native driver (with the advantage of not having to ask for the collection): 71 | 72 | Example: 73 | ``` javascript 74 | User.findOne({name: 'foo'}, console.log); 75 | User.insert({name: 'foo'}); // fire and forget 76 | }); 77 | ``` 78 | 79 | If you need more information on collection methods 80 | visit the [driver](https://github.com/mongodb/node-mongodb-native) documentation 81 | 82 | ### Custom mongo collection commands 83 | 84 | Mongolia provides some useful commands that are not available using the driver. 85 | 86 | * `findArray`: find that returns an array instead of a cursor. 87 | * `mapReduceArray`: mapReduce that returns an array with the results. 88 | * `mapReduceCursor`: mapReduce that returns a cursor. 89 | 90 | ### Namespacing 91 | 92 | Secure your data access defining visibility namespaces. 93 | 94 | You can namespace a call to the database by appending `:namespace` on 95 | your proxied method. 96 | 97 | If called without a namespace, the method will work ignoring the `namespace` directives. 98 | 99 | You can `extend` other namespaces and `add` or `remove` some data visibility. 100 | 101 | ``` javascript 102 | var USER = require('mongolia').model(db, 'users'); 103 | 104 | USER.namespaces = { 105 | public: ['account.email', 'account.name', '_id'], 106 | private: { 107 | extend: 'public', 108 | add: ['password'], 109 | }, 110 | accounting: { 111 | extend: 'private', 112 | add: ['credit_card_number'] // don't do this at home 113 | } 114 | }; 115 | 116 | USER.mongo('insert:public', {account: {email: 'foo@bar.com'}, password: 'fleiba', credit_card_number: 123, is_active: true}); 117 | // insert => {account: {email: 'foo@bar.com'}} 118 | 119 | USER.validateAndUpdate({account: {email: 'foo@bar.com'}}, {'$set': {'account.email': 'super@mail.com', password: '123'}, {namespace: 'public'}); 120 | // updates => {'$set': {'account.email': 'super@mail.com'}} 121 | 122 | USER.mongo('findArray:public', {account: {email: 'foo@bar.com'}}); 123 | // find => {account: {email: 'foo@bar.com', name: 'paco'}} 124 | 125 | USER.mongo('findArray:accounting', {account: {email: 'foo@bar.com'}}); 126 | // find => {account: {email: 'foo@bar.com', name: 'paco'}, password: 'fleiba', credit_card_number: 123} 127 | ``` 128 | 129 | Use this feature wisely to filter data coming from forms. 130 | 131 | ### Mappings and type casting 132 | 133 | Mongolia `maps` allows you to cast the data before is stored to the database. 134 | Mongolia will apply the specified function for each attribute on the `maps` object. 135 | 136 | By default we provide the map `_id -> ObjectId`, so you don't need to cast it. 137 | 138 | ``` javascript 139 | var USER = require('mongolia').model(db, 'users'); 140 | 141 | USER.maps = { 142 | _id: ObjectID, 143 | account: { 144 | email: String, 145 | name: function (val) {val.toUpperCase()} 146 | }, 147 | password: String, 148 | salt: String, 149 | is_deleted: Boolean 150 | }; 151 | 152 | USER.mongo('insert', {email: 'foo@bar.com', password: 123, name: 'john', is_deleted: 'true'}); 153 | // stored => {password: '123', name: 'JOHN', is_deleted: true} 154 | ``` 155 | 156 | ### Hooks 157 | 158 | Mongolia let you define some hooks on your models that will be triggered after a mongoDB command. 159 | 160 | * `beforeInsert(documents, callback)`: triggered *before* an `insert`. 161 | * `afterInsert(documents, callback)`: triggered *after* an `insert. 162 | 163 | * `beforeUpdate(query, update, callback)`: triggered *before* an `update` or `findAndModify` command. 164 | * `afterUpdate(query, update, callback)`: triggered *after* an `update` or `findAndModify` command. 165 | 166 | * `beforeRemove(query, callback)`: triggered *before* a `remove` command. 167 | * `afterRemove(query, callback)`: triggered *after* a `remove` command. 168 | 169 | Example: 170 | 171 | ``` javascript 172 | var COMMENT = require('mongolia').model(db, 'comments'), 173 | Post = require('./post'); 174 | 175 | COMMENT.beforeInsert = function (documents, callback) { 176 | documents.forEach(function (doc) { 177 | doc.created_at = new Date(); 178 | }); 179 | callback(null, documents); 180 | }; 181 | 182 | COMMENT.afterInsert = function (documents, callback) { 183 | documents.forEach(function (doc) { 184 | Post(db).mongo('update', {_id: doc.post._id}, {'$inc': {num_posts: 1}}); // fire and forget 185 | }); 186 | callback(null, documents); 187 | }; 188 | 189 | USER.mongo('insert', {email: 'foo@bar.com'}); 190 | // stored => {email: 'foo@bar.com', created_at: Thu, 14 Jul 2011 12:13:39 GMT} 191 | // Post#num_posts is increased 192 | ``` 193 | 194 | ## Embedded documents 195 | 196 | Mongolia helps you to _denormalize_ your mongo collections. 197 | 198 | ### getEmbeddedDocument 199 | 200 | Filters document following the `skeletons` attribute. 201 | 202 | getEmbeddedDocument(name, object, scope [, dot_notation]); 203 | 204 | Example: 205 | 206 | ``` javascript 207 | var POST = require('mongolia').model(db, 'posts'); 208 | 209 | // only embed the comment's _id, and title 210 | POST.skeletons = { 211 | comment: ['_id', 'title', 'post.name'] 212 | }; 213 | 214 | var comment = {'_id': 1, title: 'foo', body: 'Lorem ipsum', post: {_id: 1, name: 'bar'}} 215 | console.log(Post(db).getEmbeddedDocument('comment', comment)); 216 | // outputs => {'_id': 1, title: 'foo', post: {name: 'bar'}}; 217 | 218 | console.log(Post(db).getEmbeddedDocument('comment', comment, 'post')); 219 | // outputs => {post: {'_id': 1, title: 'foo', post: {name: 'bar'}}}; 220 | 221 | console.log(Post(db).getEmbeddedDocument('comment', comment, 'posts', true)); 222 | // outputs => {'posts._id': 1, 'posts.title': 'foo', 'posts.post.name': 'bar'}; 223 | ``` 224 | 225 | ### updateEmbeddedDocument 226 | 227 | Updates an embed object following the `skeletons` directive. 228 | 229 | ``` javascript 230 | Model.updateEmbeddedDocument(query, document_name, document[, options, callback]); 231 | ``` 232 | 233 | Example: 234 | 235 | ``` javascript 236 | module.exports = function (db) { 237 | var USER = require('mongolia').model(db, 'users'); 238 | 239 | // After updating a user, we want to update denormalized Post.author foreach post 240 | USER.afterUpdate = function (query, update, callback) { 241 | Post(db).updateEmbeddedDocument({_id: query._id}, 'author', update, {upsert: false}, callback); 242 | }; 243 | 244 | return USER; 245 | }; 246 | ``` 247 | 248 | ### pushEmbeddedDocument 249 | 250 | Pushes an embedded document following the `skeletons` directive. 251 | 252 | ``` javascript 253 | Model.pushEmbeddedDocument(query, data, name[, options, callback]); 254 | ``` 255 | 256 | Example: 257 | 258 | ``` javascript 259 | module.exports = function (db) { 260 | var POST = require('mongolia')(db, 'posts'); 261 | 262 | // After inserting a post, we want to push it to `users.posts[]` 263 | POST.afterInsert = function (documents, callback) { 264 | User(db).pushEmbeddedDocument({_id: documents[0].author._id}, 'posts', document, callback); 265 | }; 266 | 267 | return POST; 268 | } 269 | ``` 270 | 271 | ## Create and update using validations 272 | 273 | Mongolia provides two methods that allow you to create and update using the `validator`. 274 | 275 | ``` javascript 276 | Model.validateAndInsert(document[, options, callback(error, validator)]); 277 | Model.validateAndUpdate(document, update[, options, callback(error, validator)]); 278 | ``` 279 | 280 | To scope the insert/update within a namespace, use `options.namespace`. 281 | 282 | In order to validate an insertion/update, the model have to implement a `validate` function on your model. 283 | 284 | ``` javascript 285 | validate(query, update, callback); 286 | ``` 287 | 288 | Example: 289 | 290 | ``` javascript 291 | // post.js 292 | module.exports = function (db) { 293 | var POST = require('mongolia').model(db, 'posts'); 294 | 295 | POST.validate = function (query, update, callback) { 296 | var validator = require('mongolia').validator(query, update); 297 | 298 | validator.validateRegex({ 299 | title: [validator.regex.title, 'Incorrect title'], 300 | body: [/.{4,200}/, 'Incorrect body'], 301 | }); 302 | 303 | if (!update.body === 'Lorem ipsum') { 304 | validator.addError('body', 'You can be a little bit more creative'); 305 | } 306 | 307 | callback(null, validator); 308 | } 309 | 310 | return POST; 311 | }; 312 | 313 | // app.js 314 | var Post = require('./post.js'); 315 | 316 | Post(db).validateAndInsert( 317 | {title: 'This is a post', body: 'Lorem ipsum'}, 318 | function (error, validator) { 319 | if (validator.hasErrors()) { 320 | console.log(validator.errors); 321 | } else { 322 | console.log(validator.updated_document); 323 | } 324 | } 325 | ); 326 | ``` 327 | 328 | # Validator 329 | 330 | Mongolia validator accepts a document and an update. 331 | 332 | If you are validating an insert, the document will be an empty object `{}` and the update 333 | the document you are inserting. 334 | 335 | Mongolia will resolve the update client side exposing a `updated_document`. 336 | 337 | ``` javascript 338 | var validator = require('mongolia').validator({foo: 1}, {'$inc': {foo: 1}}); 339 | 340 | if (validator.updated_document.foo > 1) { 341 | validator.addError('foo', 'foo must be ONE'); 342 | } 343 | console.log(validator.hasError('foo')); // => true 344 | ``` 345 | 346 | All the methods listed below accept `dot_notation`. 347 | 348 | ## API 349 | 350 | Returns true if the validator is handling an updateInstance operation. 351 | 352 | ``` javascript 353 | isUpdating() 354 | ``` 355 | 356 | Returns true if the validator is handling an createInstance operation. 357 | 358 | ``` javascript 359 | isInserting() 360 | ``` 361 | 362 | Returns true if the attributed changed 363 | 364 | ``` javascript 365 | attrChanged(attr) 366 | ``` 367 | 368 | Adds an error to your validator. Accept dot notation to add nested errors. 369 | 370 | ``` javascript 371 | addError(field, value) 372 | ``` 373 | 374 | Returns true if the attributed failed a validation. Accept dot notation to check nested errors. 375 | 376 | ``` javascript 377 | hasError(field) 378 | ``` 379 | 380 | Returns true if any attributed failed a validation 381 | 382 | ``` javascript 383 | hasErrors() 384 | ``` 385 | 386 | It fills your validator with errors if any of the elements are empty 387 | 388 | ``` javascript 389 | validateExistence({ 390 | attr: 'Error message' 391 | , attr: ... 392 | }) 393 | ``` 394 | 395 | It fills your validator with errors if any of the elements fail the regex 396 | 397 | ``` javascript 398 | validateRegex({ 399 | attr: [/regex/, 'Error message'] 400 | , attr: ... 401 | }) 402 | ``` 403 | 404 | It fills your validator with errors if any of the elements fail the confirmation (good for passwords) 405 | 406 | ``` javascript 407 | validateConfirmation({ 408 | attr: ['confirmation_attr', 'Error message'] 409 | , attr: ... 410 | }) 411 | ``` 412 | 413 | It fills your validator with errors if any of the queries fail (good to avoid duplicated data) 414 | 415 | ``` javascript 416 | validateQuery({ 417 | attr: [Model, query, false, 'Error message'] 418 | , attr: ... 419 | }, callback) 420 | ``` 421 | 422 | Example using some of the validator features: 423 | 424 | ``` javascript 425 | var User = function (db) { 426 | var USER = require('mongolia').model(db, 'users'); 427 | 428 | USER.validate = function (document, update, callback) { 429 | var validator = require('mongolia').validator(document, update) 430 | , updated_document = validator.updated_document; 431 | 432 | validator.validateRegex({ 433 | name: [validator.regex.username, 'Incorrect name'], 434 | email: [validator.regex.email, 'Incorrect email'], 435 | password: [validator.regex.password, 'Incorrect password'], 436 | description: [validator.regex.description, 'Incorrect description'] 437 | }); 438 | 439 | if (validator.attrChanged('password')) { 440 | validator.validateConfirmation({ 441 | 'password': ['password_confirmation', 'Passwords must match'] 442 | }); 443 | } 444 | 445 | if (!updated_document.tags || updated_document.tags.length <= 0) { 446 | validator.addError('tags', 'Select at least one tag'); 447 | } 448 | 449 | validator.validateQuery({ 450 | email: [ 451 | this 452 | , {_id: {'$not': document._id}, email: updated_document.email} 453 | , false 454 | , 'There is already a user with this email' 455 | ] 456 | }, function () { 457 | callback(null, validator); 458 | }); 459 | } 460 | 461 | return USER; 462 | }; 463 | ``` 464 | 465 | ## Tests 466 | 467 | Mongolia is fully tested using [mocha](http://github.com/visionmedia/mocha) 468 | To run the tests use: 469 | 470 | ```bash 471 | make 472 | ``` 473 | 474 | ## Example 475 | 476 | Mongolia has a fully working blog example on the `example` folder. 477 | 478 | ## Contributors 479 | 480 | In no specific order. 481 | 482 | * Josep M. Bach ([txus](http://github.com/txus)) 483 | * Pau Ramon ([masylum](http://github.com/masylum)) 484 | 485 | ## License 486 | 487 | (The MIT License) 488 | 489 | Copyright (c) 2010-2011 Pau Ramon Revilla <masylum@gmail.com> 490 | 491 | Permission is hereby granted, free of charge, to any person obtaining 492 | a copy of this software and associated documentation files (the 493 | 'Software'), to deal in the Software without restriction, including 494 | without limitation the rights to use, copy, modify, merge, publish, 495 | distribute, sublicense, and/or sell copies of the Software, and to 496 | permit persons to whom the Software is furnished to do so, subject to 497 | the following conditions: 498 | 499 | The above copyright notice and this permission notice shall be 500 | included in all copies or substantial portions of the Software. 501 | 502 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 503 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 504 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 505 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 506 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 507 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 508 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 509 | --------------------------------------------------------------------------------