├── .gitignore ├── .istanbul.yml ├── .travis.yml ├── LICENSE ├── README.md ├── examples └── rest-api │ ├── Comment.js │ ├── Group.js │ ├── User.js │ ├── db.js │ └── server.js ├── jsdoc.config.json ├── lib ├── errors.js ├── index.js └── utils.js ├── npm-shrinkwrap.json ├── package.json └── test ├── _stubs.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | docs 3 | npm-debug.log 4 | coverage 5 | -------------------------------------------------------------------------------- /.istanbul.yml: -------------------------------------------------------------------------------- 1 | 2 | instrumentation: 3 | excludes: ['**/examples/**'] 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ian Hinsdale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bookshelf-advanced-serialization 2 | [![Build Status](https://travis-ci.org/sequiturs/bookshelf-advanced-serialization.svg?branch=master)](https://travis-ci.org/sequiturs/bookshelf-advanced-serialization) [![Coverage Status](https://coveralls.io/repos/github/sequiturs/bookshelf-advanced-serialization/badge.svg?branch=master)](https://coveralls.io/github/sequiturs/bookshelf-advanced-serialization?branch=master) [![npm version](https://img.shields.io/npm/v/bookshelf-advanced-serialization.svg?style=flat)](https://www.npmjs.com/package/bookshelf-advanced-serialization) 3 | 4 | ## Description 5 | 6 | This module is a plugin for [Bookshelf.js](https://github.com/tgriesser/bookshelf), supporting three types of custom serialization behavior: 7 | 8 | 1. serializing according to **access permissions**, 9 | 2. serializing according to the **application context** in which serialization is performed, and 10 | 3. serializing **after loading relations** that should be on the model / collection. 11 | 12 | Together, these features ensure that one call to `toJSON` can yield an arbitrarily complex serialization result: any combination of a model's properties (i.e. in Bookshelf terminology, attributes and relations), the properties of its relations, and so on indefinitely. In other words, this module supports **recursive serialization** (and it does so in a way that allows infinite looping / cycling to be easily prevented if that would otherwise be a danger). This means the module excels at supporting **hierarchical data models**. 13 | 14 | You can explore the source code on [GitHub](https://github.com/sequiturs/bookshelf-advanced-serialization). This module has a **comprehensive test suite**. 15 | 16 | ## Philosophy 17 | 18 | This module was designed to support serializing models which represent the resources of the [Sequiturs](https://sequiturs.com) REST API. It is thus well-suited to the use case of using Bookshelf to power a REST API. 19 | 20 | One important aspect of the REST API use case is customizing the serialization result according to the access permissions of the client. It is crucial that no data be leaked to a client who should not see it. For this reason, this module exclusively implements a **whitelisting** approach to serialization, and does not support blacklisting. Only properties that have been explicitly allowed to be serialized--whitelisted--will be returned by `toJSON`. This makes leaking data more difficult. This is good. 21 | 22 | This **strict approach to data security** is reflected in another aspect of implementation: the module assumes that when a model has no visible properties, the serialized result should not even indicate to the client that the model exists! In practice, this means that: 23 | 24 | 1. models with no visible properties are serialized to `undefined` rather than `{}`, and 25 | 2. these `undefined` values are removed from arrays. 26 | 27 | This is useful in the following situation, for instance: suppose there is a collection that contains public items, which the client should be able to see, and private items, which the client should not be able to see. The current implementation ensures that the client will not receive any indication how many private items exist or in what order they appear in the collection. In future, this behavior could be made optional, if that is desired. 28 | 29 | ## How to use 30 | 31 | ### Overview 32 | 33 | The serialization result returned by `toJSON` is determined by: 34 | 35 | 1. evaluating the access permissions of the recipient to determine the maximum extent of the recipient's visibility into a model, 36 | 37 | - This is accomplished using three parts: 38 | - an `accessor` value that is passed as an option to `toJSON()` or that has been set on a model instance, 39 | - a `roleDeterminer` method set on the model class, and 40 | - a `rolesToVisibleProperties` object set on the model class. 41 | 42 | - `accessor` represents who is accessing the model; this is the recipient of the serialization result. `roleDeterminer` is a function for determining the role of the `accessor` in relation to the model instance. When you call `toJSON`, `roleDeterminer` is invoked, with the `accessor` passed as an argument. `rolesToVisibleProperties` is an object that maps a role to a list of properties that should be visible to someone with that role. 43 | 44 | 2. optionally specifying the subset of these role-determined visible properties that should be in the serialization result given the application context in which serialization is being performed, 45 | 46 | - This is accomplished using two parts: 47 | - a `contextSpecificVisibleProperties` object provided on the `options` object passed to `toJSON` 48 | - an optional `contextDesignator` function also provided on the `options` object 49 | 50 | - `contextSpecificVisibleProperties` indexes lists of the properties of a model that should be visible in light of the application context. These lists are indexed first by models' `tableName`, which allows for easily specifying context-specific visible properties for all models of a certain type. (We use `tableName` because this is the only identifier Bookshelf provides for identifying a model's type.) If you want fine-grained control over designating context beyond simply by model type, you can provide an `contextDesignator` function, which is invoked when you call `toJSON`, and which by default is passed the model's `tableName`, `_accessedAsRelationChain`, and `id` properties as arguments. (You can override this default behavior and pass custom arguments to `contextDesignator`, by passing your own `getEvaluatorArguments` function when registering this plugin.) The designation returned by `contextDesignator` will be used to lookup the list of context-specific visible properties, inside `contextSpecificVisibleProperties[tableName]`. 51 | 52 | 3. optionally loading specified relations on the model (or on the model's relations, recursively to any depth) before serializing, if those relations are not already loaded. 53 | 54 | - This is accomplished using two parts: 55 | - an `ensureRelationsLoaded` object provided on the `options` object passed to `toJSON` 56 | - an optional `contextDesignator` function also provided on the `options` object. This is the same `contextDesignator` as in 2\. 57 | 58 | - `ensureRelationsLoaded` works analogously to `contextSpecificVisibleProperties`, except the lists contain the names of relations that it will be ensured are loaded on the model prior to serialization, rather than context-specific visible properties. 59 | 60 | ### Installation 61 | 62 | ```JavaScript 63 | npm install bookshelf-advanced-serialization 64 | ``` 65 | 66 | then 67 | 68 | ```JavaScript 69 | 'use strict'; 70 | 71 | var advancedSerialization = require('bookshelf-advanced-serialization'); 72 | 73 | var knex = require('knex')({ ... }); 74 | var bookshelf = require('bookshelf')(knex); 75 | 76 | bookshelf.plugin(advancedSerialization()); 77 | 78 | module.exports = bookshelf; 79 | ``` 80 | 81 | ### API 82 | 83 | See the [docs](https://sequiturs.com/developers/open-source/bookshelf-advanced-serialization/module-bookshelf-advanced-serialization.html). 84 | 85 | You can also view a local copy of the docs: 86 | 87 | 1. Clone the repo. 88 | 2. Generate the docs by running `npm run jsdoc`. 89 | 3. Open `docs/index.html` in your browser. 90 | 91 | ### Examples 92 | 93 | See [`examples/rest-api`](https://github.com/sequiturs/bookshelf-advanced-serialization/tree/master/examples/rest-api). 94 | 95 | Within [`examples/rest-api/server.js`](https://github.com/sequiturs/bookshelf-advanced-serialization/blob/master/examples/rest-api/server.js), there are examples of the different ways you can use this module to control serialization behavior: 96 | 97 | - Using access permissions but not application context 98 | - See route handling for `/users/:username`. 99 | - Using access permissions and application context 100 | - using `contextDesignator`'s context designations with `contextSpecificVisibleProperties` 101 | - See route handling for `/comments/:id`. 102 | - using only table names, not `contextDesignator`'s context designations, with `contextSpecificVisibleProperties` 103 | - See route handling for `/comments/:id`, specifically the `users` table name. 104 | - using custom arguments in the `contextDesignator` function 105 | - See route handling for `/comments/:id`. 106 | - using the default `relationChain` argument to `contextDesignator` 107 | - (No example of this.) 108 | - After ensuring certain relations have been loaded 109 | - using `contextDesignator`'s context designations with `ensureRelationsLoaded` 110 | - See route handling for `/comments/:id`. 111 | - using only table names, not `contextDesignator`'s context designations, with `ensureRelationsLoaded` 112 | - See route handling for `/users/:username`. 113 | - using custom arguments in the `contextDesignator` function 114 | - See route handling for `/comments/:id`. 115 | - using the default `relationChain` argument to `contextDesignator` 116 | - (No example of this.) 117 | 118 | ## License 119 | 120 | See [`LICENSE`](https://github.com/sequiturs/bookshelf-advanced-serialization/blob/master/LICENSE). 121 | -------------------------------------------------------------------------------- /examples/rest-api/Comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(bookshelf) { 4 | var Comment = bookshelf.Model.extend({ 5 | tableName: 'comments', 6 | 7 | roleDeterminer: function(accessor) { 8 | return 'anyone'; 9 | }, 10 | rolesToVisibleProperties: { 11 | anyone: [ 'id', 'author', 'content', 'parent', 'children' ] 12 | }, 13 | 14 | parent: function() { 15 | return this.belongsTo('Comment', 'parent_id'); 16 | }, 17 | children: function() { 18 | return this.hasMany('Comment', 'parent_id'); 19 | }, 20 | author: function() { 21 | return this.belongsTo('User', 'author_id'); 22 | } 23 | }, {}); 24 | 25 | // Register model 26 | if (!bookshelf.model('Comment')) { 27 | bookshelf.model('Comment', Comment); 28 | } 29 | 30 | // Register model(s) depended on 31 | if (!bookshelf.model('User')) { 32 | require('./User.js')(bookshelf); 33 | } 34 | 35 | return bookshelf.model('Comment'); 36 | }; 37 | -------------------------------------------------------------------------------- /examples/rest-api/Group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var BluebirdPromise = require('bluebird'); 4 | 5 | var relationPromise = function(model, relationName) { 6 | return model.relations[relationName] ? 7 | BluebirdPromise.resolve(model.related(relationName)) : 8 | model.load([ relationName ]).then(function(modelLoadedWithRelation) { 9 | return modelLoadedWithRelation.related(relationName); 10 | }); 11 | }; 12 | 13 | module.exports = function(bookshelf) { 14 | var Group = bookshelf.Model.extend({ 15 | tableName: 'groups', 16 | hasTimestamps: true, 17 | 18 | roleDeterminer: function(accessor) { 19 | if (accessor.user) { 20 | var accessorId = accessor.user.id; 21 | 22 | var adminsCollectionPromise = relationPromise(this, 'admins'); 23 | return adminsCollectionPromise.bind(this).then(function(adminsCollection) { 24 | var isAdmin = !!adminsCollection.find(function(admin) { 25 | return accessorId === admin.id; 26 | }); 27 | if (isAdmin) { 28 | return 'admin'; 29 | } else { 30 | 31 | var membersCollectionPromise = relationPromise(this, 'members'); 32 | return membersCollectionPromise.then(function(membersCollection) { 33 | var isMember = !!membersCollection.find(function(member) { 34 | return accessorId === member.id; 35 | }); 36 | if (isMember) { 37 | return 'member'; 38 | } else { 39 | return 'outsider'; 40 | } 41 | }); 42 | } 43 | }); 44 | } else { 45 | return 'outsider'; 46 | } 47 | }, 48 | rolesToVisibleProperties: { 49 | admin: [ 'id', 'name', 'admins', 'members', 'created_at' ], 50 | member: [ 'id', 'name', 'admins', 'members' ], 51 | outsider: [] 52 | }, 53 | 54 | admins: function() { 55 | return this.belongsToMany('User', 'group_admins', 'group_id', 'user_id'); 56 | }, 57 | members: function() { 58 | return this.belongsToMany('User', 'group_members', 'group_id', 'user_id'); 59 | } 60 | }, {}); 61 | 62 | // Register model 63 | if (!bookshelf.model('Group')) { 64 | bookshelf.model('Group', Group); 65 | } 66 | 67 | // Register model(s) depended on 68 | if (!bookshelf.model('User')) { 69 | require('./User.js')(bookshelf); 70 | } 71 | 72 | return bookshelf.model('Group'); 73 | }; 74 | -------------------------------------------------------------------------------- /examples/rest-api/User.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(bookshelf) { 4 | var User = bookshelf.Model.extend({ 5 | tableName: 'users', 6 | hasTimestamps: true, 7 | 8 | roleDeterminer: function(accessor) { 9 | return (accessor.user && accessor.user.id === this.id) ? 10 | 'theUserHerself' : 11 | 'someoneElse'; 12 | }, 13 | rolesToVisibleProperties: { 14 | theUserHerself: [ 'id', 'username', 'created_at', 'email', 'groupsMemberOf', 'groupsAdminOf' ], 15 | someoneElse: [ 'id', 'username', 'created_at' ] 16 | // User models might contain other attributes like hashed_password, 17 | // but these won't be serialized because they are not present in these lists 18 | // of visible properties. 19 | }, 20 | 21 | groupsMemberOf: function() { 22 | return this.belongsToMany('Group', 'group_members', 'user_id', 'group_id'); 23 | }, 24 | groupsAdminOf: function() { 25 | return this.belongsToMany('Group', 'group_admins', 'user_id', 'group_id'); 26 | } 27 | }, {}); 28 | 29 | // Register model 30 | if (!bookshelf.model('User')) { 31 | bookshelf.model('User', User); 32 | } 33 | 34 | // Register model(s) depended on 35 | if (!bookshelf.model('Group')) { 36 | require('./Group.js')(bookshelf); 37 | } 38 | 39 | return bookshelf.model('User'); 40 | }; 41 | -------------------------------------------------------------------------------- /examples/rest-api/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var advancedSerialization = require('bookshelf-advanced-serialization'); 4 | 5 | var knex = require('knex')({ ... }); 6 | var bookshelf = require('bookshelf')(knex); 7 | 8 | bookshelf.plugin('registry'); 9 | 10 | var genCustomEvaluatorArguments = function() { 11 | if (this.tableName === 'comments') { 12 | return [this.tableName, this._accessedAsRelationChain, this.id, this.get('parent_id')]; 13 | } else { 14 | return [this.tableName, this._accessedAsRelationChain, this.id]; 15 | } 16 | }; 17 | 18 | bookshelf.plugin(advancedSerialization({ 19 | getEvaluatorArguments: genCustomEvaluatorArguments 20 | })); 21 | 22 | module.exports = bookshelf; 23 | -------------------------------------------------------------------------------- /examples/rest-api/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var express = require('express'); 4 | 5 | var bookshelf = require('./db.js'); 6 | 7 | require('./User.js')(bookshelf); 8 | require('./Group.js')(bookshelf); 9 | require('./Comment.js')(bookshelf); 10 | 11 | var app = express(); 12 | 13 | /** 14 | * Payload that `toJSON()` will resolve with looks like: 15 | * { 16 | * id: '8dc1464d-8c32-448d-a81d-ff161077d781', 17 | * author: { 18 | * username: 'elephant1' 19 | * }, 20 | * content: 'Hello, World!', 21 | * parent: { 22 | * id: '147fd60a-ed3a-4209-85b3-7a330caf3896', 23 | * author: { 24 | * username: 'antelope99' 25 | * } 26 | * }, 27 | * children: [ 28 | * { 29 | * id: '5792d5d9-bef5-4b32-8d34-682b8caad67a', 30 | * author: { 31 | * username: 'gazelle22' 32 | * } 33 | * }, 34 | * ... 35 | * ] 36 | * } 37 | */ 38 | app.get('/comments/:id', function(req, res) { 39 | bookshelf.model('Comment') 40 | .forge({ 41 | id: req.params.id 42 | }, { 43 | accessor: { user: req.user } 44 | }) 45 | .fetch() 46 | .then(function(comment) { 47 | return comment.toJSON({ 48 | contextSpecificVisibleProperties: { 49 | comments: { 50 | requestedComment: [ 'id', 'author', 'content', 'parent', 'children' ], 51 | requestedCommentParent: [ 'id', 'author' ], 52 | requestedCommentChild: [ 'id', 'author' ] 53 | }, 54 | users: [ 'username' ] 55 | }, 56 | ensureRelationsLoaded: { 57 | comments: { 58 | requestedComment: [ 'author', 'parent', 'children' ], 59 | requestedCommentParent: [ 'author' ], 60 | requestedCommentChild: [ 'author' ] 61 | } 62 | }, 63 | contextDesignator: function(tableName, relationChain, idOfModelBeingSerialized, parentIdOfModelBeingSerialized) { 64 | if (tableName === 'comments') { 65 | if (comment.id === idOfModelBeingSerialized) { 66 | return 'requestedComment'; 67 | } else if (comment.get('parent_id') === idOfModelBeingSerialized) { 68 | return 'requestedCommentParent'; 69 | } else if (comment.id === parentIdOfModelBeingSerialized) { 70 | return 'requestedCommentChild'; 71 | } 72 | } 73 | } 74 | }); 75 | }) 76 | .then(function(data) { 77 | res.status(200).send(data); 78 | }); 79 | }); 80 | 81 | /** 82 | * Payload that `toJSON()` will resolve with depends on who the requesting user is. 83 | * 84 | * If the requesting user is authenticated as the user being requested: 85 | * 86 | * { 87 | * id: '3d33e941-e23e-41fa-8807-03e87ce7baa8' 88 | * username: 'antelope99' 89 | * created_at: '2016-02-03T04:07:51.690Z', 90 | * email: 'antelope99@example.com', 91 | * groupsMemberOf: [ 92 | * { 93 | * id: '0f91fab2-48b5-4396-9b75-632f99da02c2', 94 | * name: 'Slouchy gauchos' 95 | * // This group object will not contain the `members` and `admins` relations, 96 | * // because those were not specified in `ensureRelationsLoaded`. 97 | * }, 98 | * ... 99 | * ], 100 | * groupsAdminOf: [ 101 | * { 102 | * id: 'b0a94a70-2db7-4063-ad0d-0ef39412bfd2', 103 | * name: 'Neo-Post-Tangential Economics Society', 104 | * created_at: '2016-04-12T08:12:11.380Z' 105 | * }, 106 | * ... 107 | * ] 108 | * } 109 | * 110 | * If the requesting user is not authenticated as the user being requested: 111 | * 112 | * { 113 | * id: '3d33e941-e23e-41fa-8807-03e87ce7baa8' 114 | * username: 'antelope99' 115 | * created_at: '2016-02-03T04:07:51.690Z' 116 | * } 117 | */ 118 | app.get('/users/:username', function(req, res) { 119 | bookshelf.model('User') 120 | .forge({ 121 | username: req.params.username 122 | }, { 123 | accessor: { user: req.user } 124 | }) 125 | .fetch() 126 | .then(function(user) { 127 | return user.toJSON({ 128 | ensureRelationsLoaded: { 129 | users: [ 'groupsMemberOf', 'groupsAdminOf' ] 130 | } 131 | }); 132 | }) 133 | .then(function(data) { 134 | res.status(200).send(data); 135 | }); 136 | }); 137 | 138 | /** 139 | * Payload that `toJSON()` will resolve with depends on who the requesting user is. 140 | * 141 | * If requesting user is an admin of the requested group: 142 | * 143 | * { 144 | * id: 'b0a94a70-2db7-4063-ad0d-0ef39412bfd2', 145 | * name: 'Neo-Post-Tangential Economics Society', 146 | * created_at: '2016-04-12T08:12:11.380Z' 147 | * } 148 | * 149 | * If requesting user is a member of the requested group: 150 | * 151 | * { 152 | * id: 'b0a94a70-2db7-4063-ad0d-0ef39412bfd2', 153 | * name: 'Neo-Post-Tangential Economics Society' 154 | * } 155 | * 156 | * If the requesting user is not a member or admin of the group: 157 | * 158 | * undefined 159 | * 160 | */ 161 | app.get('/groups/:id', function(req, res) { 162 | bookshelf.model('Group') 163 | .forge({ 164 | id: req.params.id 165 | }, { 166 | accessor: { user: req.user } 167 | }) 168 | .fetch() 169 | .then(function(group) { 170 | return group.toJSON(); 171 | }) 172 | .then(function(data) { 173 | res.status(200).send(data); 174 | }); 175 | }); 176 | 177 | app.listen(8080, function () { 178 | console.log('Server listening on http://localhost:8080, Ctrl+C to stop'); 179 | }); 180 | -------------------------------------------------------------------------------- /jsdoc.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": true 4 | }, 5 | "source": { 6 | "include": ["README.md", "lib"], 7 | "includePattern": ".+\\.js(doc)?$" 8 | }, 9 | "plugins": [ 10 | "node_modules/jsdoc/plugins/markdown" 11 | ], 12 | "templates": { 13 | "cleverLinks": true, 14 | "monospaceLinks": false, 15 | "default": { 16 | "outputSourceFiles": true, 17 | "showInheritedFrom": false 18 | } 19 | }, 20 | "opts": { 21 | "template": "node_modules/minami", 22 | "destination": "./docs", 23 | "recurse": true, 24 | "private": true, 25 | "encoding": "utf-8" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createError = require('create-error'); 4 | 5 | module.exports = { 6 | /** 7 | * @ignore 8 | * @desc Thrown when invalid arguments are provided to the plugin's methods. 9 | */ 10 | BookshelfAdvancedSerializationPluginSanityError: 11 | createError('BookshelfAdvancedSerializationPluginSanityError') 12 | 13 | }; 14 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /*** 4 | * (c) 2016 Ian Hinsdale 5 | * Distributed under the MIT license. 6 | * 7 | * This module was inspired by https://github.com/tgriesser/bookshelf/pull/187. 8 | */ 9 | 10 | var BluebirdPromise = require('bluebird'); 11 | var _ = require('lodash'); 12 | 13 | var errors = require('./errors.js'); 14 | var SanityError = errors.BookshelfAdvancedSerializationPluginSanityError; 15 | 16 | var utils = require('./utils.js'); 17 | var relationPromise = utils.relationPromise; 18 | 19 | /** 20 | * @ignore 21 | * @desc A function for generating the default arguments that should be passed 22 | * to `contextDesignator`. 23 | * @returns {Array.<*>} 24 | */ 25 | var getDefaultEvaluatorArguments = function() { 26 | return [ this.tableName, this._accessedAsRelationChain, this.id ]; 27 | }; 28 | 29 | /** 30 | * @ignore 31 | * @param {function=} contextDesignator The contextDesignator function to use to determine the 32 | * contextDesignation 33 | * @param {function=} getEvaluatorArguments A function that returns the 34 | * arguments to pass to the contextDesignator function 35 | * @returns {Promise} A promise resolving to the name for how to 36 | * interpret or understand `this` that is currently being serialized. 37 | * We refer to this as the contextDesignation. This is especially useful for 38 | * distinguishing `this` from the top-level model on which `toJSON` 39 | * was called when both models are of the same type, because then we can given 40 | * them appropriate contextSpecificVisibleProperties to prevent infinite looping of 41 | * serialization. 42 | */ 43 | var getContextDesignation = function(contextDesignator, getEvaluatorArguments) { 44 | var contextDesignation; 45 | if (contextDesignator) { 46 | if (typeof contextDesignator !== 'function') { 47 | throw new SanityError('contextDesignator must be a function'); 48 | } 49 | contextDesignation = BluebirdPromise.resolve( 50 | contextDesignator.apply(this, getEvaluatorArguments.call(this)) 51 | ); 52 | } else { 53 | contextDesignation = BluebirdPromise.resolve(undefined); 54 | } 55 | return contextDesignation; 56 | }; 57 | 58 | /** 59 | * @ignore 60 | * @desc Performs default handling for ensuring that a relation is loaded on the 61 | * `this` model. 62 | * @returns A promise resolving to the relation, either directly if the relation 63 | * was already present on `this`, or after loading the relation if it 64 | * was not present. 65 | */ 66 | var defaultHandleEnsureRelation = function(relationName) { 67 | return relationPromise(this, relationName); 68 | }; 69 | 70 | /** 71 | * @module bookshelf-advanced-serialization 72 | */ 73 | 74 | /** 75 | * @desc Generates a configured plugin. 76 | * 77 | * @param {Object=} options An optional object optionally containing methods 78 | * for customizing plugin behavior. 79 | * @param {function=} options.getEvaluatorArguments A function which should return 80 | * the array of arguments that will be applied to the `contextDesignator` function. Its 81 | * `this` value is the model being serialized. See `getDefaultEvaluatorArguments` 82 | * in the source code for the example of the default behavior. 83 | * @param {function=} options.handleEnsureRelation A function which will be called 84 | * for each relation name in the `ensureRelationsLoaded` arrays. May return a 85 | * promise. See `defaultHandleEnsureRelation` in the source code for the example of the default 86 | * behavior, which, as you'd expect, simply loads the relation on the model if it 87 | * is not already present. This option was driven by the Sequiturs use case, 88 | * which special-cases relation names ending in `'CountPseudoRelation'` in order to 89 | * set on the model a `'Count'` attribute whose value is the count of rows of the 90 | * relation identified by the beginning of the relation name. 91 | * @param {boolean} [options.ensureRelationsVisibleAndInvisible=false] A boolean 92 | * which should be `true` only if you have also specified `options.handleEnsureRelation`. 93 | * It specifies whether the plugin should, before serializing, load all relations 94 | * listed in an `ensureRelationsLoaded` array--that is, whether it should call 95 | * `options.handleEnsureRelation` for each relation listed in an `ensureRelationsLoaded` 96 | * array--, regardless of whether the relations will be visible properties. Default 97 | * plugin behavior corresponds to a value of `false`, which avoids the work of 98 | * loading relations that will not be present in the serialization 99 | * result. Note that *even* if this option is `true`, relations that are not 100 | * visible properties will be removed from the model just before serializing--as 101 | * is documented for this plugin's `Model.toJSON`. 102 | * @param {boolean=} options.defaultOmitNew A boolean which specifies a 103 | * default value for the `omitNew` option to `{model,collection}.toJSON(options)`. Specifying 104 | * this option as `true` is a performant way to address this Bookshelf bug 105 | * (https://github.com/tgriesser/bookshelf/issues/753)--though note that the 106 | * plugin will address that bug even without this option or with a value of `false`, 107 | * albeit in a less performant manner. 108 | * 109 | * @returns A function that should be passed to `bookshelf.plugin()`, to register this plugin. 110 | */ 111 | module.exports = function(options) { 112 | if (options && !_.isPlainObject(options)) { 113 | throw new SanityError('Truthy options argument passed to plugin must be an object.'); 114 | } 115 | options = options || {}; 116 | 117 | if (options.getEvaluatorArguments && typeof options.getEvaluatorArguments !== 'function') { 118 | throw new SanityError('Truthy getEvaluatorArguments passed as plugin option must be a function.'); 119 | } 120 | var getEvaluatorArguments = options.getEvaluatorArguments || getDefaultEvaluatorArguments; 121 | 122 | if (options.handleEnsureRelation && typeof options.handleEnsureRelation !== 'function') { 123 | throw new SanityError('Truthy handleEnsureRelation passed as plugin option must be a function.'); 124 | } 125 | var handleEnsureRelation = options.handleEnsureRelation || defaultHandleEnsureRelation; 126 | 127 | if ( 128 | options.hasOwnProperty('ensureRelationsVisibleAndInvisible') && 129 | (typeof options.ensureRelationsVisibleAndInvisible !== 'boolean') 130 | ) { 131 | throw new SanityError('ensureRelationsVisibleAndInvisible option must be a boolean.'); 132 | } 133 | var ensureRelationsVisibleAndInvisible = !!options.ensureRelationsVisibleAndInvisible; 134 | 135 | if ( 136 | options.hasOwnProperty('defaultOmitNew') && 137 | (typeof options.defaultOmitNew !== 'boolean') 138 | ) { 139 | throw new SanityError('defaultOmitNew passed as plugin option must be a boolean.'); 140 | } 141 | var defaultOmitNew = options.defaultOmitNew; 142 | 143 | return function(Bookshelf) { 144 | var modelProto = Bookshelf.Model.prototype; 145 | var modelToJSON = modelProto.toJSON; 146 | var modelRelated = modelProto.related; 147 | var modelFetchAll = modelProto.fetchAll; 148 | 149 | /** 150 | * @ignore 151 | * @class Model 152 | * @memberof module:bookshelf-advanced-serialization 153 | * @classdesc The Bookshelf Model class, extended to support plugin behavior 154 | */ 155 | var Model = Bookshelf.Model.extend({ 156 | 157 | /** 158 | * @ignore 159 | * @member {*} 160 | * @memberof module:bookshelf-advanced-serialization.Model 161 | * @instance 162 | * @desc A value representing who the model is being accessed by / whom it 163 | * will be serialized for. Passed to `this.roleDeterminer()` to determine 164 | * the role of this person. You should not set this value directly; it is 165 | * set when creating a new model instance, or by `setAccessor()`. 166 | * @private 167 | */ 168 | _accessor: undefined, 169 | 170 | /** 171 | * @method 172 | * @memberof module:bookshelf-advanced-serialization.Model 173 | * @instance 174 | * @desc Sets `this._accessor`. An alternative to setting the 175 | * accessor via the model constructor. Useful if you already have a model, 176 | * on which you want to set an accessor. 177 | * @param {*} accessor The accessor value to set 178 | */ 179 | setAccessor: function(accessor) { 180 | this._accessor = accessor; 181 | }, 182 | 183 | /** 184 | * @ignore 185 | * @member {Array.} 186 | * @memberof module:bookshelf-advanced-serialization.Model 187 | * @instance 188 | * @desc Represents how `this` exists as a relation 189 | * related ultimately to some top-level model. This knowledge is 190 | * useful when serializing: the contextDesignator function can use it to determine 191 | * the contextDesignation. Specifically, it may be essential information 192 | * if a model can have multiple relations that are collections of the same 193 | * type of model. By default, this value is passed as the second argument 194 | * to the `contextDesignator` option passed to `toJSON`. 195 | * 196 | * @example Example from Sequiturs data model: for a Comment model that is the 197 | * `comment` relation of a Challenge model that is the `counterArgument` 198 | * relation of an Argument model, the value of `_accessedAsRelationChain` 199 | * would be `['counterArgument', 'comment']`. 200 | * 201 | * @private 202 | */ 203 | _accessedAsRelationChain: [], 204 | 205 | /** 206 | * @method 207 | * @memberof module:bookshelf-advanced-serialization.Model 208 | * @instance 209 | * @desc A function called by `toJSON` with `this._accessor` as its argument, 210 | * to determine the accessor's role. 211 | * This role is then looked up in `rolesToVisibleProperties` to identify 212 | * the visible properties of the model. May return a promise--this allows 213 | * asynchronously determining the role. 214 | * @param {*} accessor `this._accessor` 215 | * @returns {(string|Promise)} The role or a promise resolving 216 | * to the role. 217 | */ 218 | roleDeterminer: undefined, 219 | 220 | /** 221 | * @member {Object.>} 222 | * @memberof module:bookshelf-advanced-serialization.Model 223 | * @instance 224 | * @desc An object that maps a role name to the array of properties of the 225 | * model that should be visible to that role type. Reflects a whitelisting 226 | * approach to serialization, as only properties listed in the array may be 227 | * present in the serialization result. Not all properties listed in the 228 | * array will necessarily be in the serialization result, however, if 229 | * `contextSpecificVisibleProperties` is also being used. 230 | */ 231 | rolesToVisibleProperties: undefined, 232 | 233 | /** 234 | * @method 235 | * @memberof module:bookshelf-advanced-serialization.Model 236 | * @constructs module:bookshelf-advanced-serialization.Model 237 | * @desc The usual Bookshelf model constructor, but it accepts an 238 | * `accessor` option which will be set at `this._accessor` and passed to 239 | * `roleDeterminer()` when `toJSON` is called. 240 | * @param {Object} attributes 241 | * @param {Object=} options 242 | * @param {*} options.accessor The accessor value to set 243 | */ 244 | constructor: function() { 245 | modelProto.constructor.apply(this, arguments); 246 | var options = arguments[1] || {}; 247 | this._accessor = options.accessor; 248 | }, 249 | 250 | /** 251 | * @ignore 252 | * @method 253 | * @memberof module:bookshelf-advanced-serialization.Model 254 | * @instance 255 | * @desc Override the prototype's `fetchAll` so that this._accessor is set on all 256 | * models in a collection fetched by `fetchAll`. 257 | */ 258 | fetchAll: function() { 259 | var modelThis = this; 260 | var result = modelFetchAll.apply(this, arguments); 261 | return result.then(function(collection) { 262 | collection.each(function(model) { 263 | model._accessor = modelThis._accessor; 264 | }); 265 | return collection; 266 | }); 267 | // TODO There must be a way to accomplish this by having Bookshelf use 268 | // an accessor option (derived from modelThis._accessor) when it invokes 269 | // the model constructor for each model it puts in the collection. 270 | }, 271 | 272 | /** 273 | * @method 274 | * @memberof module:bookshelf-advanced-serialization.Model 275 | * @instance 276 | * @desc The method for serializing a model. 277 | * 278 | * Note that this method mutates models, first by loading relations per 279 | * `options.ensureRelationsLoaded` and then by removing relations that are not 280 | * among the model's visible properties (the latter improves serialization 281 | * performance and helps to avoid infinite-looping / cycling of serialization). 282 | * Such mutating behavior is, admittedly, not obvious from the method's 283 | * name of `toJSON` alone. In using this plugin, you should therefore think of 284 | * `toJSON` not as merely converting the existing model to a serialized 285 | * form, but as *transforming* it according to your specifications, 286 | * and then converting it to serialized form. 287 | * 288 | * A model with no visible properties -- that is, where the list of properties 289 | * that should be visible to the caller evaluates to empty -- will be 290 | * serialized as `undefined`, and all such models in collections will be 291 | * removed from the corresponding arrays in the serialization result. (N.B. A model 292 | * with no visible properties is not the same as an empty model--the latter is a 293 | * model with no attributes and no relations. An empty model that exists as 294 | * a relation of another model is assumed to be due to [this Bookshelf 295 | * bug](https://github.com/tgriesser/bookshelf/issues/753) and is serialized 296 | * as `null`. An empty model that exists otherwise (e.g. standalone, or in a 297 | * standalone collection, or in a collection that exists as the relation of another 298 | * model) is serialized as `{}`.) 299 | * 300 | * Note also that the behavior of this method diverges from the standard Bookshelf 301 | * behavior in that it does not remove `null` values for relations from the 302 | * final serialized result. 303 | * 304 | * @param {Object=} options An optional object specifying how to customize 305 | * the serialization result. 306 | * @param {Object=} options.contextSpecificVisibleProperties An optional object 307 | * specifying what properties of a model should be visible given the 308 | * application context in which `toJSON` is being invoked. The object should be 309 | * indexed first by table name, with values that are either (a) an array of 310 | * visible property names; or (b) an object indexed by the possible 311 | * context designations (i.e. the return values of `options.contextDesignator`), 312 | * with values that are an array of visible property names. This object, 313 | * potentially in combination with `options.contextDesignator`, is your 314 | * mechanism for preventing infinite-looping / cycling of serialization, 315 | * should your use case present that possibility. 316 | * @param {Object=} options.ensureRelationsLoaded An optional object 317 | * analogous in form to `options.contextSpecificVisibleProperties` but whose 318 | * values are arrays containing the relation names that it will be 319 | * ensured are loaded on a model before serializing. Such 320 | * relations will be loaded on the model if they are not 321 | * already present. 322 | * @param {function=} options.contextDesignator A function which returns the 323 | * context designation describing the context in which `toJSON` is being 324 | * called. Only required if `options.contextSpecificVisibleProperties` or 325 | * `options.ensureRelationsLoaded` index lists by context designation. 326 | * May return a promise--this supports asynchronously determining the 327 | * context designation. By default, the contextDesignator will be called with 328 | * `this.tableName`, `this._accessedAsRelationChain`, and `this.id` 329 | * as arguments. You may optionally customize these arguments by passing 330 | * `options.getEvaluatorArguments` to the plugin. 331 | * @param {Object=} options.accessor A value representing who the model is 332 | * being accessed by / whom it will be serialized for. Pass the accessor as 333 | * an option here as an alternative to setting the accessor when the 334 | * model is instantiated (see `constructor`) or using `.setAccessor()`. 335 | * A value provided for this option takes precedence over an accessor 336 | * value set on the model or any related models. 337 | * @param {boolean=} options.shallow Same as the standard Bookshelf option. 338 | * @param {boolean=} options.omitPivot Same as the standard Bookshelf option. 339 | * @param {boolean=} options.omitNew Same as the standard Bookshelf option. 340 | * 341 | * @returns {Promise<(Object|undefined)>} A promise resolving to the plain 342 | * javascript object representing the model. 343 | */ 344 | toJSON: function(options) { 345 | options = options || {}; 346 | 347 | // Determine value of `options.omitNew`. A value passed to `toJSON()` 348 | // takes priority, otherwise if a default was specified for the plugin 349 | // we use that, otherwise we do nothing special. 350 | if (options.hasOwnProperty('omitNew')) { 351 | // No op. 352 | } else if (typeof defaultOmitNew === 'boolean') { 353 | options.omitNew = defaultOmitNew; 354 | } else { 355 | // No op. 356 | } 357 | 358 | if (typeof this.roleDeterminer !== 'function') { 359 | throw new SanityError( 360 | 'roleDeterminer function was not defined for models of table: ' + this.tableName); 361 | } 362 | 363 | if (!_.isPlainObject(this.rolesToVisibleProperties)) { 364 | throw new SanityError('rolesToVisibleProperties was not defined for models of table: ' + this.tableName); 365 | } 366 | 367 | // If `options.omitNew=true` and the model is new, we can exit early. 368 | // This prevents unsaved models from being serialized, including empty 369 | // models created by this Bookshelf bug (https://github.com/tgriesser/bookshelf/issues/753). 370 | // We explicitly check these conditions here, as opposed to relying on 371 | // the standard Bookshelf handling of the `omitNew` option further downstream, 372 | // so that we can avoid doing unnecessary work related to determining visible 373 | // properties and loading relations for such models. 374 | if (options.omitNew && this.isNew()) { 375 | return BluebirdPromise.resolve(modelToJSON.call(this, options)); 376 | } 377 | 378 | // Determine visible properties based on role 379 | var accessor = options.accessor || this._accessor; 380 | return BluebirdPromise.resolve(this.roleDeterminer(accessor)) 381 | .bind(this) 382 | .then(function(role) { 383 | 384 | var visibleProperties = this.rolesToVisibleProperties[role]; 385 | 386 | if (!Array.isArray(visibleProperties)) { 387 | throw new SanityError('rolesToVisibleProperties for table ' + this.tableName + 388 | ' does not contain array of visible properties for role: ' + role); 389 | } 390 | 391 | if (!visibleProperties.length) { 392 | return BluebirdPromise.resolve(undefined); 393 | } 394 | 395 | // Determine contextDesignation. 396 | var contextDesignationPromise = getContextDesignation.call(this, 397 | options ? options.contextDesignator : undefined, getEvaluatorArguments); 398 | 399 | // Determine the properties that should be visible in final serialized result 400 | // based on application context in which `toJSON` is being called. 401 | // `contextSpecificVisibleProperties` should not be used to prune properties from 402 | // a model for permissions-logic-related reasons; the place for determining 403 | // what properties are visible for permissions reasons is in the roleDeterminer function. 404 | var contextSpecificVisiblePropertiesPromise; 405 | if (options && options.contextSpecificVisibleProperties) { 406 | 407 | if (!_.isPlainObject(options.contextSpecificVisibleProperties)) { 408 | throw new SanityError('contextSpecificVisibleProperties must be an object'); 409 | } 410 | 411 | var tableContextSpecific = options.contextSpecificVisibleProperties[this.tableName]; 412 | if (tableContextSpecific) { 413 | 414 | if (Array.isArray(tableContextSpecific)) { 415 | 416 | contextSpecificVisiblePropertiesPromise = BluebirdPromise.resolve(tableContextSpecific); 417 | 418 | } else if (_.isPlainObject(tableContextSpecific)) { 419 | 420 | if (!options.contextDesignator) { 421 | throw new SanityError('options must contain an contextDesignator function if ' + 422 | 'options.contextSpecificVisibleProperties[this.tableName] is an object'); 423 | } 424 | 425 | contextSpecificVisiblePropertiesPromise = contextDesignationPromise 426 | .then(function(contextDesignation) { 427 | var contextSpecificVisibleProperties = tableContextSpecific[contextDesignation]; 428 | 429 | if (!Array.isArray(contextSpecificVisibleProperties)) { 430 | throw new SanityError('contextDesignator function did not successfully ' + 431 | 'identify array within contextSpecificVisibleProperties'); 432 | } 433 | 434 | return contextSpecificVisibleProperties; 435 | }); 436 | 437 | } else { 438 | throw new SanityError('contextSpecificVisibleProperties.' + this.tableName + 439 | ' must be an array, or an object whose keys are strings returned ' + 440 | 'by the options.contextDesignator function and whose values are arrays.'); 441 | } 442 | 443 | } else { 444 | contextSpecificVisiblePropertiesPromise = BluebirdPromise.resolve(undefined); 445 | } 446 | } else { 447 | contextSpecificVisiblePropertiesPromise = BluebirdPromise.resolve(undefined); 448 | } 449 | 450 | return contextSpecificVisiblePropertiesPromise.bind(this).then(function(contextSpecificVisibleProperties) { 451 | 452 | // Determine the visible properties in the final serialization result. 453 | var ultimatelyVisibleProperties = 454 | contextSpecificVisibleProperties ? 455 | _.intersection(visibleProperties, contextSpecificVisibleProperties) : 456 | visibleProperties; 457 | 458 | // If ultimatelyVisibleProperties has zero length, we're done. 459 | if (!ultimatelyVisibleProperties.length) { 460 | return BluebirdPromise.resolve(undefined); 461 | } 462 | 463 | // Load relations that should be loaded before serializing the model. 464 | 465 | var ensureRelationsPromise; 466 | if (_.isPlainObject(this.attributes) && _.isEmpty(this.attributes)) { 467 | // If `this` model is empty, it must be due to this Bookshelf bug 468 | // (https://github.com/tgriesser/bookshelf/issues/753); we should never 469 | // have empty models. So in this case, don't worry about ensuring relations 470 | // are loaded for the model. 471 | 472 | ensureRelationsPromise = BluebirdPromise.resolve(undefined); 473 | 474 | } else if (options && options.ensureRelationsLoaded) { 475 | 476 | if (!_.isPlainObject(options.ensureRelationsLoaded)) { 477 | throw new SanityError('ensureRelationsLoaded must be an object'); 478 | } 479 | 480 | var tableContextEnsureRelations = options.ensureRelationsLoaded[this.tableName]; 481 | // Bookshelf provides no way to identify a model's type except by looking at its 482 | // `tableName`. We can't, for instance, access the name with which 483 | // the model class was registered with the registry. 484 | var relationNamesToEnsurePromise; 485 | if (tableContextEnsureRelations) { 486 | if (Array.isArray(tableContextEnsureRelations)) { 487 | 488 | relationNamesToEnsurePromise = BluebirdPromise.resolve(tableContextEnsureRelations); 489 | 490 | } else if (_.isPlainObject(tableContextEnsureRelations)) { 491 | 492 | if (!options.contextDesignator) { 493 | throw new SanityError('options must contain an contextDesignator function if ' + 494 | 'options.ensureRelationsLoaded[this.tableName] is an object'); 495 | } 496 | 497 | relationNamesToEnsurePromise = contextDesignationPromise 498 | .then(function(contextDesignation) { 499 | var relationNames = tableContextEnsureRelations[contextDesignation]; 500 | 501 | if (!Array.isArray(relationNames)) { 502 | throw new SanityError('contextDesignator function did not successfully ' + 503 | 'identify array within ensureRelationsLoaded'); 504 | } 505 | 506 | return relationNames; 507 | }); 508 | 509 | } else { 510 | throw new SanityError('ensureRelationsLoaded.' + this.tableName + 511 | ' must be an array, or an object whose keys are strings returned ' + 512 | 'by the options.contextDesignator function and whose values are arrays.'); 513 | } 514 | 515 | ensureRelationsPromise = relationNamesToEnsurePromise.bind(this) 516 | .then(function(relationNamesToEnsure) { 517 | 518 | // Load only those relationNamesToEnsure that are also in 519 | // ultimatelyVisibleProperties, to avoid unnecessary work -- 520 | // unless caller has opted to force loading all relationNamesToEnsure. 521 | 522 | var loadTheseRelations = ensureRelationsVisibleAndInvisible ? 523 | relationNamesToEnsure : 524 | _.intersection(relationNamesToEnsure, ultimatelyVisibleProperties); 525 | 526 | if (process.env.NODE_ENV !== 'production') { 527 | if (loadTheseRelations.length !== relationNamesToEnsure.length) { 528 | console.log( 529 | 'bookshelf-advanced-serialization plugin: You have ' + 530 | 'specified relation names in `ensureRelationsLoaded` ' + 531 | 'which are not visible properties: ' + 532 | _.difference(relationNamesToEnsure, loadTheseRelations) + 533 | '. These relations will ' + 534 | (ensureRelationsVisibleAndInvisible ? 'nevertheless' : 'not') + 535 | ' be loaded, because option ensureRelationsVisibleAndInvisible is `' + 536 | ensureRelationsVisibleAndInvisible + '`.' 537 | ); 538 | } 539 | } 540 | 541 | return BluebirdPromise.map(loadTheseRelations, handleEnsureRelation.bind(this)); 542 | }); 543 | 544 | } else { 545 | ensureRelationsPromise = BluebirdPromise.resolve(undefined); 546 | } 547 | } else { 548 | ensureRelationsPromise = BluebirdPromise.resolve(undefined); 549 | } 550 | 551 | return ensureRelationsPromise.bind(this).then(function(ensuredRelations) { 552 | 553 | // Remove from the model relations that are not in ultimatelyVisibleProperties, 554 | // even if they were just loaded per `ensureRelationsVisibleAndInvisible`. 555 | // These don't need to be serialized, and removing them can be essential 556 | // to preventing cycling / infinite looping of serialization. 557 | var ultimatelyVisiblePropertiesDict = {}; 558 | _.each(ultimatelyVisibleProperties, function(fieldName) { 559 | ultimatelyVisiblePropertiesDict[fieldName] = true; 560 | }); 561 | _.each(_.keys(this.relations), function(relationName) { 562 | // Iterate over `this.relation`'s keys rather than `this.relations` 563 | // itself, because I prefer not to mutate what I'm iterating over, 564 | // even though ECMAScript abides such practices. 565 | 566 | if (!ultimatelyVisiblePropertiesDict.hasOwnProperty(relationName)) { 567 | delete this.relations[relationName]; 568 | } 569 | }.bind(this)); 570 | 571 | // Finally, serialize the model 572 | 573 | var jsonPromises = modelToJSON.call(this, options); 574 | 575 | return BluebirdPromise.props(jsonPromises).bind(this).then(function(json) { 576 | 577 | var result = _.pick.apply(_, [ json ].concat(ultimatelyVisibleProperties)); 578 | 579 | // Any empty objects at this point must be due to a Bookshelf 580 | // bug (https://github.com/tgriesser/bookshelf/issues/753) 581 | // and should become `null`. (The empty objects couldn't be objects 582 | // with no visible properties, because we resolved those to `undefined`.) 583 | result = _.mapValues(result, function(val) { 584 | if (_.isPlainObject(val) && _.isEmpty(val)) { 585 | return null; 586 | } else { 587 | return val; 588 | } 589 | }); 590 | 591 | return result; 592 | }); 593 | }); 594 | }); 595 | }); 596 | }, 597 | 598 | /** 599 | * @ignore 600 | * @method 601 | * @memberof module:bookshelf-advanced-serialization.Model 602 | * @instance 603 | * @desc Override the prototype's `related` method in order to (1) transfer 604 | * `this._accessor` of a model to its relations' models, and (2) populate 605 | * `this._accessedAsRelationChain`. With (2) we keep track of how a relation 606 | * model is related to its ancestor models(s). To do this, we make a copy of 607 | * the parent model's `_accessedAsRelationChain`, add to this copy the 608 | * relation name being accessed, and set this value on the related model / 609 | * collection. 610 | */ 611 | related: function() { 612 | // TODO Ideally these values should be populated at the time of instantiation 613 | // of the relation model / collection, rather than relying on the user 614 | // to access the relation via .related() as we do currently. 615 | 616 | var result = modelRelated.apply(this, arguments); 617 | 618 | if (result) { 619 | var relationName = arguments[0]; 620 | 621 | // Collection 622 | if (result.hasOwnProperty('models')) { 623 | _.each(result.models, function(model) { 624 | model._accessor = this._accessor; 625 | model._accessedAsRelationChain = this._accessedAsRelationChain.concat([ relationName ]); 626 | }.bind(this)); 627 | 628 | // Model 629 | } else { 630 | result._accessor = this._accessor; 631 | result._accessedAsRelationChain = this._accessedAsRelationChain.concat([ relationName ]); 632 | } 633 | } 634 | 635 | return result; 636 | } 637 | }); 638 | 639 | Bookshelf.Model = Model; 640 | 641 | var collectionProto = Bookshelf.Collection.prototype; 642 | var collectionSerialize = collectionProto.serialize; 643 | var collectionToJSON = collectionProto.toJSON; 644 | 645 | /** 646 | * @ignore 647 | * @class Collection 648 | * @memberof module:bookshelf-advanced-serialization 649 | * @classdesc The Bookshelf Collection class, extended to support plugin behavior 650 | */ 651 | var Collection = Bookshelf.Collection.extend({ 652 | /** 653 | * @method 654 | * @memberof module:bookshelf-advanced-serialization.Collection 655 | * @instance 656 | * @desc The method for serializing a collection. Analogous to `Model.toJSON`. 657 | * All models in a collection which serialize to `undefined` will be removed 658 | * from the serialized collection. 659 | * 660 | * Note that this method diverges from the standard Bookshelf behavior 661 | * in that it does not remove `null` values from arrays. 662 | * 663 | * @param {Object=} options An optional object specifying how to customize 664 | * the serialization result. Accepts the same options as `Model.toJSON`. 665 | 666 | * @returns {Promise} A promise resolving to the plain 667 | * javascript array representing the collection. 668 | */ 669 | toJSON: function(options) { 670 | options = options || {}; 671 | 672 | // Determine value of `options.omitNew`. A value passed to `toJSON()` 673 | // takes priority, otherwise if a default was specified for the plugin 674 | // we use that, otherwise we do nothing special. 675 | if (options.hasOwnProperty('omitNew')) { 676 | // No op. 677 | } else if (typeof defaultOmitNew === 'boolean') { 678 | options.omitNew = defaultOmitNew; 679 | } else { 680 | // No op. 681 | } 682 | 683 | return collectionToJSON.call(this, options); 684 | }, 685 | /** 686 | * @ignore 687 | * @method 688 | * @memberof module:bookshelf-advanced-serialization.Collection 689 | * @desc Override `Collection.serialize` to support the fact that Model.toJSON 690 | * returns a promise. Otherwise we end up with an array of stringified 691 | * promises. (Note that overriding Collection.serialize here applies to 692 | * both top-level collections as well as models' relations that are 693 | * collections.) Remove `undefined` values from arrays, which represent 694 | * models with no visible properties, and which we'll assume the recipient 695 | * should therefore have no indication even exist. 696 | */ 697 | serialize: function() { 698 | var modelPromisesArray = collectionSerialize.apply(this, arguments); 699 | return BluebirdPromise.all(modelPromisesArray) 700 | .then(function(list) { 701 | return _.filter(list, _.negate(_.isUndefined)); 702 | }); 703 | } 704 | }); 705 | 706 | Bookshelf.Collection = Collection; 707 | }; 708 | }; 709 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var BluebirdPromise = require('bluebird'); 4 | var _ = require('lodash'); 5 | 6 | module.exports = { 7 | /** 8 | * @ignore 9 | * @desc Utility for ensuring a particular relation is loaded on a model 10 | * @param {object} model The model on which the relation should be loaded 11 | * @param {string} relationName The name of the relation that should be loaded 12 | * @returns {Promise} A promise resolving with the relation specified 13 | * by `relationName`. 14 | */ 15 | relationPromise: function(model, relationName) { 16 | return model.relations[relationName] ? 17 | BluebirdPromise.resolve(model.related(relationName)) : 18 | model.load([ relationName ]).then(function(modelLoadedWithRelation) { 19 | return modelLoadedWithRelation.related(relationName); 20 | }); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookshelf-advanced-serialization", 3 | "version": "1.4.1", 4 | "dependencies": { 5 | "bluebird": { 6 | "version": "3.4.1", 7 | "from": "bluebird@3.4.1", 8 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.1.tgz" 9 | }, 10 | "create-error": { 11 | "version": "0.3.1", 12 | "from": "create-error@0.3.1", 13 | "resolved": "https://registry.npmjs.org/create-error/-/create-error-0.3.1.tgz" 14 | }, 15 | "knex": { 16 | "version": "0.10.0", 17 | "from": "knex@>=0.6.10 <0.11.0", 18 | "resolved": "https://registry.npmjs.org/knex/-/knex-0.10.0.tgz", 19 | "dependencies": { 20 | "bluebird": { 21 | "version": "2.10.2", 22 | "from": "bluebird@>=2.9.24 <3.0.0", 23 | "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.10.2.tgz" 24 | }, 25 | "chalk": { 26 | "version": "1.1.3", 27 | "from": "chalk@>=1.0.0 <2.0.0", 28 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", 29 | "dependencies": { 30 | "ansi-styles": { 31 | "version": "2.2.1", 32 | "from": "ansi-styles@>=2.2.1 <3.0.0", 33 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz" 34 | }, 35 | "escape-string-regexp": { 36 | "version": "1.0.5", 37 | "from": "escape-string-regexp@>=1.0.2 <2.0.0", 38 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz" 39 | }, 40 | "has-ansi": { 41 | "version": "2.0.0", 42 | "from": "has-ansi@>=2.0.0 <3.0.0", 43 | "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", 44 | "dependencies": { 45 | "ansi-regex": { 46 | "version": "2.0.0", 47 | "from": "ansi-regex@>=2.0.0 <3.0.0", 48 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" 49 | } 50 | } 51 | }, 52 | "strip-ansi": { 53 | "version": "3.0.1", 54 | "from": "strip-ansi@>=3.0.0 <4.0.0", 55 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", 56 | "dependencies": { 57 | "ansi-regex": { 58 | "version": "2.0.0", 59 | "from": "ansi-regex@>=2.0.0 <3.0.0", 60 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.0.0.tgz" 61 | } 62 | } 63 | }, 64 | "supports-color": { 65 | "version": "2.0.0", 66 | "from": "supports-color@>=2.0.0 <3.0.0", 67 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz" 68 | } 69 | } 70 | }, 71 | "commander": { 72 | "version": "2.9.0", 73 | "from": "commander@>=2.2.0 <3.0.0", 74 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz", 75 | "dependencies": { 76 | "graceful-readlink": { 77 | "version": "1.0.1", 78 | "from": "graceful-readlink@>=1.0.0", 79 | "resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz" 80 | } 81 | } 82 | }, 83 | "debug": { 84 | "version": "2.2.0", 85 | "from": "debug@>=2.1.3 <3.0.0", 86 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz", 87 | "dependencies": { 88 | "ms": { 89 | "version": "0.7.1", 90 | "from": "ms@0.7.1", 91 | "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz" 92 | } 93 | } 94 | }, 95 | "inherits": { 96 | "version": "2.0.1", 97 | "from": "inherits@>=2.0.1 <2.1.0", 98 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz" 99 | }, 100 | "interpret": { 101 | "version": "0.6.6", 102 | "from": "interpret@>=0.6.5 <0.7.0", 103 | "resolved": "https://registry.npmjs.org/interpret/-/interpret-0.6.6.tgz" 104 | }, 105 | "liftoff": { 106 | "version": "2.0.3", 107 | "from": "liftoff@>=2.0.0 <2.1.0", 108 | "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.0.3.tgz", 109 | "dependencies": { 110 | "extend": { 111 | "version": "2.0.1", 112 | "from": "extend@>=2.0.0 <2.1.0", 113 | "resolved": "https://registry.npmjs.org/extend/-/extend-2.0.1.tgz" 114 | }, 115 | "findup-sync": { 116 | "version": "0.2.1", 117 | "from": "findup-sync@>=0.2.0 <0.3.0", 118 | "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-0.2.1.tgz", 119 | "dependencies": { 120 | "glob": { 121 | "version": "4.3.5", 122 | "from": "glob@>=4.3.0 <4.4.0", 123 | "resolved": "https://registry.npmjs.org/glob/-/glob-4.3.5.tgz", 124 | "dependencies": { 125 | "inflight": { 126 | "version": "1.0.5", 127 | "from": "inflight@>=1.0.4 <2.0.0", 128 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.5.tgz", 129 | "dependencies": { 130 | "wrappy": { 131 | "version": "1.0.2", 132 | "from": "wrappy@>=1.0.0 <2.0.0", 133 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" 134 | } 135 | } 136 | }, 137 | "minimatch": { 138 | "version": "2.0.10", 139 | "from": "minimatch@>=2.0.1 <3.0.0", 140 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", 141 | "dependencies": { 142 | "brace-expansion": { 143 | "version": "1.1.6", 144 | "from": "brace-expansion@>=1.0.0 <2.0.0", 145 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.6.tgz", 146 | "dependencies": { 147 | "balanced-match": { 148 | "version": "0.4.2", 149 | "from": "balanced-match@>=0.4.1 <0.5.0", 150 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz" 151 | }, 152 | "concat-map": { 153 | "version": "0.0.1", 154 | "from": "concat-map@0.0.1", 155 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" 156 | } 157 | } 158 | } 159 | } 160 | }, 161 | "once": { 162 | "version": "1.3.3", 163 | "from": "once@>=1.3.0 <2.0.0", 164 | "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", 165 | "dependencies": { 166 | "wrappy": { 167 | "version": "1.0.2", 168 | "from": "wrappy@>=1.0.0 <2.0.0", 169 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz" 170 | } 171 | } 172 | } 173 | } 174 | } 175 | } 176 | }, 177 | "flagged-respawn": { 178 | "version": "0.3.2", 179 | "from": "flagged-respawn@>=0.3.0 <0.4.0", 180 | "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-0.3.2.tgz" 181 | }, 182 | "resolve": { 183 | "version": "1.1.7", 184 | "from": "resolve@>=1.1.0 <1.2.0", 185 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz" 186 | } 187 | } 188 | }, 189 | "minimist": { 190 | "version": "1.1.3", 191 | "from": "minimist@>=1.1.0 <1.2.0", 192 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.1.3.tgz" 193 | }, 194 | "mkdirp": { 195 | "version": "0.5.1", 196 | "from": "mkdirp@>=0.5.0 <0.6.0", 197 | "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", 198 | "dependencies": { 199 | "minimist": { 200 | "version": "0.0.8", 201 | "from": "minimist@0.0.8", 202 | "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz" 203 | } 204 | } 205 | }, 206 | "pg-connection-string": { 207 | "version": "0.1.3", 208 | "from": "pg-connection-string@>=0.1.3 <0.2.0", 209 | "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz" 210 | }, 211 | "pool2": { 212 | "version": "1.3.4", 213 | "from": "pool2@>=1.1.0 <2.0.0", 214 | "resolved": "https://registry.npmjs.org/pool2/-/pool2-1.3.4.tgz", 215 | "dependencies": { 216 | "double-ended-queue": { 217 | "version": "2.1.0-0", 218 | "from": "double-ended-queue@>=2.1.0-0 <3.0.0", 219 | "resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz" 220 | }, 221 | "hashmap": { 222 | "version": "2.0.6", 223 | "from": "hashmap@>=2.0.1 <3.0.0", 224 | "resolved": "https://registry.npmjs.org/hashmap/-/hashmap-2.0.6.tgz" 225 | }, 226 | "simple-backoff": { 227 | "version": "1.0.0", 228 | "from": "simple-backoff@>=1.0.0 <2.0.0", 229 | "resolved": "https://registry.npmjs.org/simple-backoff/-/simple-backoff-1.0.0.tgz" 230 | } 231 | } 232 | }, 233 | "readable-stream": { 234 | "version": "1.1.14", 235 | "from": "readable-stream@>=1.1.12 <2.0.0", 236 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", 237 | "dependencies": { 238 | "core-util-is": { 239 | "version": "1.0.2", 240 | "from": "core-util-is@>=1.0.0 <1.1.0", 241 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" 242 | }, 243 | "isarray": { 244 | "version": "0.0.1", 245 | "from": "isarray@0.0.1", 246 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" 247 | }, 248 | "string_decoder": { 249 | "version": "0.10.31", 250 | "from": "string_decoder@>=0.10.0 <0.11.0", 251 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz" 252 | } 253 | } 254 | }, 255 | "tildify": { 256 | "version": "1.0.0", 257 | "from": "tildify@>=1.0.0 <1.1.0", 258 | "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.0.0.tgz", 259 | "dependencies": { 260 | "user-home": { 261 | "version": "1.1.1", 262 | "from": "user-home@>=1.1.1 <2.0.0", 263 | "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz" 264 | } 265 | } 266 | }, 267 | "v8flags": { 268 | "version": "2.0.11", 269 | "from": "v8flags@>=2.0.2 <3.0.0", 270 | "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.0.11.tgz", 271 | "dependencies": { 272 | "user-home": { 273 | "version": "1.1.1", 274 | "from": "user-home@>=1.1.1 <2.0.0", 275 | "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz" 276 | } 277 | } 278 | } 279 | } 280 | }, 281 | "lodash": { 282 | "version": "3.10.1", 283 | "from": "lodash@3.10.1", 284 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz" 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookshelf-advanced-serialization", 3 | "version": "1.4.1", 4 | "description": "Plugin for Bookshelf.js supporting advanced serialization behavior, including by (1) model access permissions, (2) the application context in which serialization occurs, and (3) the relations that should be loaded on the model before serializing.", 5 | "main": "lib/index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "jsdoc": "jsdoc --configure jsdoc.config.json", 11 | "test": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec --recursive && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage", 12 | "test-only": "mocha test --recursive" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/sequiturs/bookshelf-advanced-serialization.git" 17 | }, 18 | "files": [ 19 | "lib" 20 | ], 21 | "author": "Ian Hinsdale", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/sequiturs/bookshelf-advanced-serialization/issues" 25 | }, 26 | "homepage": "https://github.com/sequiturs/bookshelf-advanced-serialization#readme", 27 | "peerDependencies": { 28 | "bookshelf": ">= 0.9.4 <= 0.10.3" 29 | }, 30 | "dependencies": { 31 | "bluebird": "3.4.1", 32 | "create-error": "0.3.1", 33 | "lodash": "3.10.1" 34 | }, 35 | "devDependencies": { 36 | "bookshelf": "^0.10.3", 37 | "coveralls": "^2.11.12", 38 | "expect.js": "^0.3.1", 39 | "istanbul": "^0.4.4", 40 | "jsdoc": "^3.4.0", 41 | "minami": "^1.1.1", 42 | "mocha": "^2.5.3", 43 | "mocha-lcov-reporter": "^1.2.0", 44 | "mock-knex": "^0.3.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/_stubs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | users: { 5 | elephant1: { 6 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 7 | username: 'elephant1', 8 | email: 'elephant1@example.com', 9 | created_at: '2016-01-03T04:07:51.690Z' 10 | }, 11 | antelope99: { 12 | id: '3d33e941-e23e-41fa-8807-03e87ce7baa8', 13 | username: 'antelope99', 14 | email: 'antelope99@example.com', 15 | created_at: '2016-02-03T04:07:51.690Z' 16 | }, 17 | ostrich14: { 18 | id: '140fb3a9-f688-4852-b917-5287c228f45f', 19 | username: 'ostrich14', 20 | email: 'ostrich14@example.com', 21 | created_at: '2016-03-03T04:07:51.690Z' 22 | }, 23 | }, 24 | groups: { 25 | slouchyGauchos: { 26 | id: '0f91fab2-48b5-4396-9b75-632f99da02c2', 27 | name: 'Slouchy gauchos' 28 | }, 29 | 'NPTES': { 30 | id: 'b0a94a70-2db7-4063-ad0d-0ef39412bfd2', 31 | name: 'Neo-Post-Tangential Economics Society' 32 | } 33 | }, 34 | comments: [ 35 | { 36 | id: '8dc1464d-8c32-448d-a81d-ff161077d781', 37 | author_id: '3fe94198-7b32-44ee-abdd-04104b902c51', 38 | content: 'Hello, World!' 39 | } 40 | ] 41 | }; 42 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('expect.js'); 4 | var BluebirdPromise = require('bluebird'); 5 | var _ = require('lodash'); 6 | 7 | var mockKnex = require('mock-knex'); 8 | var knex = require('knex')({}); 9 | mockKnex.mock(knex); 10 | 11 | var plugin = require('../lib/index.js'); 12 | var SanityError = require('../lib/errors.js').BookshelfAdvancedSerializationPluginSanityError; 13 | 14 | var stubs = require('./_stubs.js'); 15 | 16 | describe('Plugin', function() { 17 | describe('options', function() { 18 | it('should accept not passing an options argument', function() { 19 | expect(function() { 20 | plugin(); 21 | }).to.not.throwException(); 22 | }); 23 | it('should accept an options argument that is an object', function() { 24 | expect(function() { 25 | plugin({}); 26 | }).to.not.throwException(); 27 | }); 28 | it('should reject a truthy options argument that is not an object', function() { 29 | expect(function() { 30 | plugin('foo'); 31 | }).to.throwException(function(e) { 32 | expect(e).to.be.a(SanityError); 33 | expect(e.message).to.equal('Truthy options argument passed to plugin must be an object.'); 34 | }); 35 | }); 36 | describe('options.getEvaluatorArguments', function() { 37 | it('should accept not passing the option', function() { 38 | expect(function() { 39 | plugin({}); 40 | }).to.not.throwException(); 41 | }); 42 | it('should accept passing a function', function() { 43 | expect(function() { 44 | plugin({ getEvaluatorArguments: function() {} }); 45 | }).to.not.throwException(); 46 | }); 47 | it('should reject passing something truthy that is not a function', function() { 48 | expect(function() { 49 | plugin({ getEvaluatorArguments: 'foo' }); 50 | }).to.throwException(function(e) { 51 | expect(e).to.be.a(SanityError); 52 | expect(e.message).to.equal('Truthy getEvaluatorArguments passed as plugin option must be a function.'); 53 | }); 54 | }); 55 | }); 56 | describe('options.handleEnsureRelation', function() { 57 | it('should accept not passing the option', function() { 58 | expect(function() { 59 | plugin({}); 60 | }).to.not.throwException(); 61 | }); 62 | it('should accept passing a function', function() { 63 | expect(function() { 64 | plugin({ 65 | handleEnsureRelation: function() {} 66 | }); 67 | }).to.not.throwException(); 68 | }); 69 | it('should reject passing something truthy that is not a function', function() { 70 | expect(function() { 71 | plugin({ handleEnsureRelation: 'foo' }); 72 | }).to.throwException(function(e) { 73 | expect(e).to.be.a(SanityError); 74 | expect(e.message).to.equal('Truthy handleEnsureRelation passed as plugin option must be a function.'); 75 | }); 76 | }); 77 | }); 78 | describe('options.ensureRelationsVisibleAndInvisible', function() { 79 | it('should accept not passing the option', function() { 80 | expect(function() { 81 | plugin({}); 82 | }).to.not.throwException(); 83 | 84 | // Default behavior of `false` is tested below in Model > toJSON > 85 | // options > ensureRelationsLoaded. 86 | }); 87 | it('should accept passing `true`', function() { 88 | expect(function() { 89 | plugin({ ensureRelationsVisibleAndInvisible: true }); 90 | }).to.not.throwException(); 91 | }); 92 | it('should accept passing `false`', function() { 93 | expect(function() { 94 | plugin({ ensureRelationsVisibleAndInvisible: false }); 95 | }).to.not.throwException(); 96 | }); 97 | it('should reject a value that is not a boolean', function() { 98 | expect(function() { 99 | plugin({ ensureRelationsVisibleAndInvisible: 'true' }); 100 | }).to.throwException(function(e) { 101 | expect(e).to.be.a(SanityError); 102 | expect(e.message).to.equal('ensureRelationsVisibleAndInvisible option must be a boolean.'); 103 | }); 104 | }); 105 | }); 106 | describe('options.defaultOmitNew', function() { 107 | it('should accept not passing the option', function() { 108 | expect(function() { 109 | plugin({}); 110 | }).to.not.throwException(); 111 | }); 112 | it('should accept passing `true`', function() { 113 | expect(function() { 114 | plugin({ defaultOmitNew: true }); 115 | }).to.not.throwException(); 116 | }); 117 | it('should accept passing `false`', function() { 118 | expect(function() { 119 | plugin({ defaultOmitNew: false }); 120 | }).to.not.throwException(); 121 | }); 122 | it('should reject a value that is not a boolean', function() { 123 | expect(function() { 124 | plugin({ defaultOmitNew: 'true' }); 125 | }).to.throwException(function(e) { 126 | expect(e).to.be.a(SanityError); 127 | expect(e.message).to.equal('defaultOmitNew passed as plugin option must be a boolean.'); 128 | }); 129 | }); 130 | it('should be respected in call to `model.toJSON()` when `omitNew` option is not specified', function(done) { 131 | var anotherBookshelf = require('bookshelf')(knex); 132 | anotherBookshelf.plugin('registry'); 133 | anotherBookshelf.plugin(plugin({ 134 | defaultOmitNew: true 135 | })); 136 | 137 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 138 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 139 | accessor: { user: { id: stubs.users.elephant1.id } } 140 | }) 141 | .toJSON() 142 | .then(function(json) { 143 | expect(json).to.equal(null); 144 | }) 145 | .asCallback(done); 146 | }); 147 | it('should not be respected in call to `model.toJSON()` when `omitNew` option is specified', function(done) { 148 | var anotherBookshelf = require('bookshelf')(knex); 149 | anotherBookshelf.plugin('registry'); 150 | anotherBookshelf.plugin(plugin({ 151 | defaultOmitNew: true 152 | })); 153 | 154 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 155 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 156 | accessor: { user: { id: stubs.users.elephant1.id } } 157 | }) 158 | .toJSON({ omitNew: false }) 159 | .then(function(json) { 160 | expect(json).to.eql({ username: 'elephant1' }); 161 | }) 162 | .asCallback(done); 163 | }); 164 | it('should be respected in call to `collection.toJSON()` when `omitNew` option is not specified', function(done) { 165 | var anotherBookshelf = require('bookshelf')(knex); 166 | anotherBookshelf.plugin('registry'); 167 | anotherBookshelf.plugin(plugin({ 168 | defaultOmitNew: true 169 | })); 170 | 171 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 172 | anotherBookshelf.Collection.extend({ model: AnotherBookshelfUser }).forge([ 173 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 174 | accessor: { user: { id: stubs.users.elephant1.id } } 175 | }) 176 | ]) 177 | .toJSON() 178 | .then(function(json) { 179 | expect(json).to.eql([ null ]); 180 | }) 181 | .asCallback(done); 182 | }); 183 | it('should not be respected in call to `collection.toJSON()` when `omitNew` option is specified', function(done) { 184 | var anotherBookshelf = require('bookshelf')(knex); 185 | anotherBookshelf.plugin('registry'); 186 | anotherBookshelf.plugin(plugin({ 187 | defaultOmitNew: true 188 | })); 189 | 190 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 191 | anotherBookshelf.Collection.extend({ model: AnotherBookshelfUser }).forge([ 192 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 193 | accessor: { user: { id: stubs.users.elephant1.id } } 194 | }) 195 | ]) 196 | .toJSON({ omitNew: false }) 197 | .then(function(json) { 198 | expect(json).to.eql([{ username: 'elephant1' }]); 199 | }) 200 | .asCallback(done); 201 | }); 202 | }); 203 | }); 204 | }); 205 | 206 | describe('Model', function() { 207 | var bookshelf = require('bookshelf')(knex); 208 | bookshelf.plugin('registry'); 209 | bookshelf.plugin(plugin()); 210 | 211 | var User = require('../examples/rest-api/User.js')(bookshelf); 212 | var Comment = require('../examples/rest-api/Comment.js')(bookshelf); 213 | var Group = require('../examples/rest-api/Group.js')(bookshelf); 214 | 215 | var fetchElephant1LoadGroupsMemberOf = function(query) { 216 | return [ 217 | function() { 218 | query.response([ stubs.users.elephant1 ]); 219 | }, 220 | function() { 221 | query.response([ 222 | _.extend({}, stubs.groups.slouchyGauchos, { 223 | _pivot_user_id: stubs.users.elephant1.id, 224 | _pivot_group_id: stubs.groups.slouchyGauchos.id 225 | }) 226 | ]); 227 | } 228 | ]; 229 | }; 230 | var determineRoleElephant1SlouchyGauchos = function(query) { 231 | return [ 232 | // Role determiner evaluation of admins 233 | function() { 234 | query.response([]); 235 | }, 236 | // Role determiner evaluation of members 237 | function() { 238 | query.response([ 239 | _.extend({}, stubs.users.elephant1, { 240 | _pivot_user_id: stubs.users.elephant1.id, 241 | _pivot_group_id: stubs.groups.slouchyGauchos.id 242 | }) 243 | ]); 244 | } 245 | ]; 246 | }; 247 | 248 | describe('accessor', function() { 249 | it('should default to setting an undefined _accessor', function() { 250 | var user = User.forge(); 251 | expect(user.hasOwnProperty('_accessor')).to.equal(true); 252 | expect(user._accessor).to.equal(undefined); 253 | }); 254 | }); 255 | describe('setAccessor', function() { 256 | it('should set the passed value as _accessor', function() { 257 | var user = User.forge({}); 258 | user.setAccessor({ user: 'foo' }); 259 | expect(user._accessor).to.eql({ user: 'foo' }); 260 | }); 261 | }); 262 | describe('_accessedAsRelationChain', function() { 263 | it('should default to setting an empty relation chain', function() { 264 | var user = User.forge(); 265 | expect(user._accessedAsRelationChain).to.eql([]); 266 | }); 267 | }); 268 | describe('roleDeterminer', function() { 269 | var model = bookshelf.Model.extend({ tableName: 'foo' }).forge({ 270 | test: 123 271 | }); 272 | it('should default to not setting a roleDeterminer method', function() { 273 | expect(model.roleDeterminer).to.equal(undefined); 274 | }); 275 | it('should fail to serialize a model lacking a roleDeterminer method', function() { 276 | expect(function() { 277 | model.toJSON({}); 278 | }).to.throwException(function(e) { 279 | expect(e).to.be.a(SanityError); 280 | expect(e.message).to.equal('roleDeterminer function was not defined for models of table: foo'); 281 | }); 282 | }); 283 | }); 284 | describe('rolesToVisibleProperties', function() { 285 | var model = bookshelf.Model.extend({ 286 | tableName: 'foo', 287 | roleDeterminer: function(accessor) { 288 | return 'bar'; 289 | } 290 | }).forge(); 291 | it('should default to not setting a rolesToVisibleProperties dictionary', function() { 292 | expect(model.rolesToVisibleProperties).to.equal(undefined); 293 | }); 294 | it('should fail to serialize a model lacking a rolesToVisibleProperties dictionary', function() { 295 | expect(function() { 296 | model.toJSON({}); 297 | }).to.throwException(function(e) { 298 | expect(e).to.be.a(SanityError); 299 | expect(e.message).to.equal('rolesToVisibleProperties was not defined for models of table: foo'); 300 | }); 301 | }); 302 | }); 303 | describe('constructor', function() { 304 | it('should allow setting _accessor via an option passed to .forge()', function() { 305 | var user = User.forge({}, { accessor: { user: 'foo' }}); 306 | expect(user._accessor).to.eql({ user: 'foo' }); 307 | }); 308 | }); 309 | describe('fetch', function() { 310 | var tracker = mockKnex.getTracker(); 311 | beforeEach(function() { 312 | tracker.install(); 313 | tracker.on('query', function sendResult(query, step) { 314 | fetchElephant1LoadGroupsMemberOf(query)[step - 1](); 315 | }); 316 | }); 317 | afterEach(function() { 318 | tracker.uninstall(); 319 | }); 320 | it('should transfer _accessor to a relation loaded via the `withRelated` option', function(done) { 321 | User.forge({ username: 'elephant1' }, { accessor: { user: 'foo' }}) 322 | .fetch({ withRelated: 'groupsMemberOf' }) 323 | .then(function(user) { 324 | var groups = user.related('groupsMemberOf'); 325 | expect(groups.length).to.equal(1); 326 | groups.each(function(group) { 327 | expect(group._accessor).to.eql({ user: 'foo' }); 328 | }); 329 | done(); 330 | }); 331 | }); 332 | it('should transfer _accessedAsRelationChain to a relation loaded via the `withRelated` option', function(done) { 333 | User.forge({ username: 'elephant1' }, { accessor: { user: 'foo' }}) 334 | .fetch({ withRelated: 'groupsMemberOf' }) 335 | .then(function(user) { 336 | var groups = user.related('groupsMemberOf'); 337 | expect(groups.length).to.equal(1); 338 | var group = groups.at(0); 339 | expect(user._accessedAsRelationChain).to.eql([]); 340 | expect(group._accessedAsRelationChain).to.eql([ 'groupsMemberOf' ]); 341 | done(); 342 | }); 343 | }); 344 | }); 345 | describe('fetchAll', function() { 346 | var tracker = mockKnex.getTracker(); 347 | before(function() { 348 | tracker.install(); 349 | tracker.on('query', function sendResult(query) { 350 | query.response([ 351 | stubs.users.elephant1, 352 | stubs.users.antelope99 353 | ]); 354 | }); 355 | }); 356 | after(function() { 357 | tracker.uninstall(); 358 | }); 359 | it('should set _accessor on all models in a collection fetched via fetchAll', function(done) { 360 | var user = User.forge({}, { accessor: { user: 'foo' }}); 361 | user.fetchAll().then(function(collection) { 362 | expect(collection.length).to.equal(2); 363 | collection.each(function(model) { 364 | expect(model._accessor).to.eql({ user: 'foo' }); 365 | }) 366 | done(); 367 | }); 368 | }); 369 | }); 370 | describe('toJSON', function() { 371 | it('should return a promise resolving to the serialization result', function(done) { 372 | var serializationResultPromise = User.forge({ username: 'foo' }, { 373 | accessor: { user: 'bar' } 374 | }).toJSON(); 375 | expect(serializationResultPromise).to.be.a(BluebirdPromise); 376 | serializationResultPromise.then(function(result) { 377 | expect(result).to.eql({ username: 'foo' }); 378 | done(); 379 | }); 380 | }); 381 | it('should reject a visibleProperties that is not an array', function(done) { 382 | var model = bookshelf.Model.extend({ 383 | tableName: 'foo', 384 | roleDeterminer: function() { return 'anyone'; }, 385 | rolesToVisibleProperties: { anyone: { username: true } } 386 | }).forge({ 387 | test: 123 388 | }); 389 | var serializationResultPromise = model.toJSON().catch(function(e) { 390 | expect(e).to.be.a(SanityError); 391 | expect(e.message).to.equal('rolesToVisibleProperties for table foo ' + 392 | 'does not contain array of visible properties for role: anyone'); 393 | done(); 394 | }); 395 | }); 396 | it('should resolve a model with no role visible properties as undefined', function(done) { 397 | var model = bookshelf.Model.extend({ 398 | tableName: 'foo', 399 | roleDeterminer: function() { return 'anyone'; }, 400 | rolesToVisibleProperties: { anyone: [] } 401 | }).forge({ 402 | test: 123 403 | }); 404 | var serializationResultPromise = model.toJSON().then(function(result) { 405 | expect(result).to.equal(undefined); 406 | done(); 407 | }); 408 | }); 409 | it('should successfully serialize a model that uses role visible properties only', function(done) { 410 | var model = User.forge(stubs.users.elephant1, { 411 | accessor: { user: { id: stubs.users.antelope99.id } } 412 | }); 413 | var serializationResultPromise = model.toJSON().then(function(result) { 414 | expect(result).to.eql({ 415 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 416 | username: 'elephant1', 417 | created_at: '2016-01-03T04:07:51.690Z' 418 | // email excluded because it's not a visible property for requesting 419 | // user who is not the user being requested 420 | }); 421 | done(); 422 | }); 423 | }); 424 | describe('options', function() { 425 | describe('contextDesignator', function() { 426 | it('should require a truthy contextDesignator to be a function', function(done) { 427 | User.forge({ username: 'foo' }, { 428 | accessor: { user: 'bar' } 429 | }).toJSON({ 430 | contextDesignator: 'fizz' 431 | }).catch(function(e) { 432 | expect(e).to.be.a(SanityError); 433 | expect(e.message).to.equal('contextDesignator must be a function'); 434 | done(); 435 | }); 436 | }); 437 | it('should invoke contextDesignator with tableName, _accessedAsRelationChain, and model id as default arguments', function(done) { 438 | User.forge({ id: 1, username: 'foo' }, { 439 | accessor: { user: 'bar' } 440 | }).toJSON({ 441 | contextDesignator: function(tableName, relationChain, id) { 442 | expect(tableName).to.equal('users'); 443 | expect(relationChain).to.eql([]); 444 | expect(id).to.equal(1); 445 | done(); 446 | } 447 | }); 448 | }); 449 | it('the return result of contextDesignator methods should be wrapped in a promise, so contextDesignator can do async work if it wants to', function(done) { 450 | User.forge({ id: 1, username: 'foo' }, { 451 | accessor: { user: 'bar' } 452 | }).toJSON({ 453 | contextDesignator: function(tableName, relationChain, id) { 454 | return 'fizz'; 455 | }, 456 | contextSpecificVisibleProperties: { 457 | users: { 458 | fizz: [ 'username' ] 459 | } 460 | } 461 | }).then(function(result) { 462 | expect(result).to.eql({ username: 'foo' }); 463 | 464 | User.forge({ id: 1, username: 'foo' }, { 465 | accessor: { user: 'bar' } 466 | }).toJSON({ 467 | contextDesignator: function(tableName, relationChain, id) { 468 | return BluebirdPromise.resolve('fizz'); 469 | }, 470 | contextSpecificVisibleProperties: { 471 | users: { 472 | fizz: [ 'username' ] 473 | } 474 | } 475 | }).then(function(result) { 476 | expect(result).to.eql({ username: 'foo' }); 477 | 478 | done(); 479 | }); 480 | }); 481 | }); 482 | it('should support providing a custom function for generating the arguments passed to the contextDesignator function', function(done) { 483 | var anotherBookshelf = require('bookshelf')(knex); 484 | anotherBookshelf.plugin('registry'); 485 | anotherBookshelf.plugin(plugin({ 486 | getEvaluatorArguments: function() { 487 | return [ this.tableName, this.get('albumName') ]; 488 | } 489 | })); 490 | 491 | anotherBookshelf.Model.extend({ 492 | tableName: 'tunes', 493 | roleDeterminer: function() { return 'anyone'; }, 494 | rolesToVisibleProperties: { 495 | anyone: [ 'id' ] 496 | } 497 | }).forge({ 498 | id: 1, 499 | name: 'As I went out one morning', 500 | albumName: 'John Wesley Harding' 501 | }, { 502 | accessor: { user: 'bar' } 503 | }).toJSON({ 504 | contextDesignator: function(tableName, albumName) { 505 | expect(tableName).to.equal('tunes'); 506 | expect(albumName).to.equal('John Wesley Harding'); 507 | done(); 508 | } 509 | }); 510 | }); 511 | }); 512 | 513 | // Share some sanity-checking test logic between ensureRelationsLoaded and 514 | // contextSpecificVisibleProperties. 515 | var sharedSanityChecking = function(property) { 516 | if (!(property === 'ensureRelationsLoaded' || property === 'contextSpecificVisibleProperties')) { 517 | throw new Error('Incorrect property passed to sharedSanityChecking'); 518 | } 519 | 520 | describe('Shared sanity-checking test logic', function() { 521 | var tracker = mockKnex.getTracker(); 522 | beforeEach(function(done) { 523 | tracker.install(); 524 | tracker.on('query', function sendResult(query, step) { 525 | fetchElephant1LoadGroupsMemberOf(query) 526 | .concat(determineRoleElephant1SlouchyGauchos(query))[step - 1]() 527 | }); 528 | done(); 529 | }); 530 | afterEach(function(done) { 531 | tracker.uninstall(); 532 | done(); 533 | }); 534 | 535 | it('should allow ' + property + ' to be falsy', function(done) { 536 | var options = {}; 537 | options[property] = null 538 | User.forge({ username: 'foo' }, { 539 | accessor: { user: 'bar' } 540 | }).toJSON(options).then(function(result) { 541 | done(); 542 | }); 543 | }); 544 | it('should require truthy ' + property + ' to be an object', function(done) { 545 | var options = {}; 546 | options[property] = 'fizz'; 547 | User.forge({ username: 'foo' }, { 548 | accessor: { user: 'bar' } 549 | }).toJSON(options).catch(function(e) { 550 | expect(e).to.be.a(SanityError); 551 | expect(e.message).to.equal(property + ' must be an object'); 552 | done(); 553 | }); 554 | }); 555 | it('should handle as normally a table name that is not present in ' + property, function(done) { 556 | var options = {}; 557 | options[property] = { comments: [ 'author' ] }; 558 | User.forge({ username: 'foo' }, { 559 | accessor: { user: 'bar' } 560 | }).toJSON(options).then(function(result) { 561 | expect(result).to.eql({ username: 'foo' }); 562 | done(); 563 | }); 564 | }); 565 | it('should support ' + property + '[tableName] being an array', function(done) { 566 | User.forge({ username: 'elephant1' }, { 567 | accessor: { user: { id: stubs.users.elephant1.id } } 568 | }) 569 | .fetch() 570 | .then(function(model) { 571 | model.toJSON({ 572 | ensureRelationsLoaded: { 573 | users: [ 'groupsMemberOf' ] 574 | }, 575 | contextSpecificVisibleProperties: { 576 | groups: [ 'name' ] 577 | } 578 | }).then(function(result) { 579 | expect(result).to.eql({ 580 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 581 | username: 'elephant1', 582 | email: 'elephant1@example.com', 583 | created_at: '2016-01-03T04:07:51.690Z', 584 | groupsMemberOf: [{ name: 'Slouchy gauchos' }] 585 | }); 586 | 587 | done(); 588 | }); 589 | }); 590 | }); 591 | it('should require an contextDesignator function if ' + property + '[tableName] is an object', function(done) { 592 | var options = {}; 593 | options[property] = { 594 | users: { foo: [ 'groupsMemberOf' ] } 595 | }; 596 | User.forge({ username: 'elephant1' }, { 597 | accessor: { user: { id: stubs.users.elephant1.id } } 598 | }) 599 | .fetch() 600 | .then(function(model) { 601 | model.toJSON(options).catch(function(e) { 602 | expect(e).to.be.a(SanityError); 603 | expect(e.message).to.equal('options must contain an contextDesignator function if ' + 604 | 'options.' + property + '[this.tableName] is an object'); 605 | 606 | done(); 607 | }); 608 | }); 609 | }); 610 | it('should support ' + property + '[tableName] being an object and use the designation returned by the contextDesignator function', function(done) { 611 | var options = { 612 | contextDesignator: function(tableName, relationChain, id) { 613 | return 'foo'; 614 | } 615 | }; 616 | if (property === 'ensureRelationsLoaded') { 617 | options[property] = { 618 | users: { foo: [ 'groupsMemberOf' ] } 619 | }; 620 | options.contextSpecificVisibleProperties = { 621 | groups: [ 'name' ] 622 | }; 623 | } else if (property === 'contextSpecificVisibleProperties') { 624 | options[property] = { 625 | users: { foo: [ 'email' ] } 626 | }; 627 | } 628 | User.forge({ username: 'elephant1' }, { 629 | accessor: { user: { id: stubs.users.elephant1.id } } 630 | }) 631 | .fetch() 632 | .then(function(model) { 633 | model.toJSON(options).then(function(result) { 634 | if (property === 'ensureRelationsLoaded') { 635 | expect(result).to.eql({ 636 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 637 | username: 'elephant1', 638 | email: 'elephant1@example.com', 639 | created_at: '2016-01-03T04:07:51.690Z', 640 | groupsMemberOf: [{ name: 'Slouchy gauchos' }] 641 | }); 642 | done(); 643 | } else if (property === 'contextSpecificVisibleProperties') { 644 | expect(result).to.eql({ 645 | email: 'elephant1@example.com' 646 | }); 647 | done(); 648 | } 649 | }); 650 | }); 651 | }); 652 | it('should reject ' + property + '[tableName] being neither array nor object', function(done) { 653 | var options = { 654 | contextDesignator: function(tableName, relationChain, id) { 655 | return 'foo'; 656 | } 657 | }; 658 | options[property] = { 659 | users: 'bar' 660 | }; 661 | User.forge({ username: 'elephant1' }, { 662 | accessor: { user: { id: stubs.users.elephant1.id } } 663 | }) 664 | .fetch() 665 | .then(function(model) { 666 | model.toJSON(options).catch(function(e) { 667 | expect(e).to.be.a(SanityError); 668 | expect(e.message).to.equal(property + '.users ' + 669 | 'must be an array, or an object whose keys are strings returned ' + 670 | 'by the options.contextDesignator function and whose values are arrays.'); 671 | 672 | done(); 673 | }); 674 | }); 675 | }); 676 | it('should require that ' + property + '[tableName][designation] be an array', function(done) { 677 | var options = { 678 | contextDesignator: function(tableName, relationChain, id) { 679 | return 'foo'; 680 | } 681 | }; 682 | options[property] = { 683 | users: { foo: { groupsMemberOf: true } } 684 | }; 685 | User.forge({ username: 'elephant1' }, { 686 | accessor: { user: { id: stubs.users.elephant1.id } } 687 | }) 688 | .fetch() 689 | .then(function(model) { 690 | model.toJSON(options).catch(function(e) { 691 | expect(e).to.be.a(SanityError); 692 | expect(e.message).to.equal('contextDesignator function did not successfully ' + 693 | 'identify array within ' + property); 694 | 695 | done(); 696 | }); 697 | }); 698 | }); 699 | }); 700 | }; 701 | 702 | describe('ensureRelationsLoaded', function() { 703 | 704 | sharedSanityChecking('ensureRelationsLoaded'); 705 | 706 | describe('Unique sanity-checking test logic', function() { 707 | var tracker = mockKnex.getTracker(); 708 | beforeEach(function(done) { 709 | tracker.install(); 710 | tracker.on('query', function sendResult(query, step) { 711 | fetchElephant1LoadGroupsMemberOf(query) 712 | .concat(determineRoleElephant1SlouchyGauchos(query))[step - 1]() 713 | }); 714 | done(); 715 | }); 716 | afterEach(function(done) { 717 | tracker.uninstall(); 718 | done(); 719 | }); 720 | 721 | it('should support custom handling of relation names to be loaded', function(done) { 722 | var anotherBookshelf = require('bookshelf')(knex); 723 | anotherBookshelf.plugin('registry'); 724 | anotherBookshelf.plugin(plugin({ 725 | handleEnsureRelation: function(relationName) { 726 | expect(relationName).to.equal('recordLabel'); 727 | this.set('artistName', 'Bob Dylan'); 728 | } 729 | })); 730 | 731 | anotherBookshelf.Model.extend({ 732 | tableName: 'tunes', 733 | roleDeterminer: function() { return 'anyone'; }, 734 | rolesToVisibleProperties: { 735 | anyone: [ 'id', 'name', 'albumName', 'artistName', 'recordLabel' ] 736 | } 737 | }).forge({ 738 | id: 1, 739 | name: 'As I went out one morning', 740 | albumName: 'John Wesley Harding' 741 | }, { 742 | accessor: { user: 'bar' } 743 | }).toJSON({ 744 | ensureRelationsLoaded: { 745 | tunes: [ 'recordLabel' ] 746 | } 747 | }).then(function(result) { 748 | expect(result).to.eql({ 749 | id: 1, 750 | name: 'As I went out one morning', 751 | albumName: 'John Wesley Harding', 752 | artistName: 'Bob Dylan', 753 | // We don't expect `recordLabel` to be in the result, because our 754 | // custom `handleEnsureRelation` doesn't actually load that relation; 755 | // instead it sets the `artistName` attribute. 756 | }); 757 | 758 | done(); 759 | }); 760 | }); 761 | it('should respect a `true` value of ensureRelationsVisibleAndInvisible when ensuring relations loaded', function(done) { 762 | var anotherBookshelf = require('bookshelf')(knex); 763 | anotherBookshelf.plugin('registry'); 764 | anotherBookshelf.plugin(plugin({ 765 | handleEnsureRelation: function(relationName) { 766 | expect(relationName).to.equal('groupsMemberOf'); 767 | 768 | // Replicate default handleEnsureRelation functionality 769 | return this.relations[relationName] ? 770 | BluebirdPromise.resolve(this.related(relationName)) : 771 | this.load([ relationName ]).then(function(thisLoadedWithRelationName) { 772 | return thisLoadedWithRelationName.related(relationName); 773 | }) 774 | }, 775 | ensureRelationsVisibleAndInvisible: true 776 | })); 777 | 778 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 779 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 780 | accessor: { user: { id: stubs.users.elephant1.id } } 781 | }) 782 | .fetch() 783 | .then(function(model) { 784 | model.toJSON({ 785 | ensureRelationsLoaded: { 786 | users: [ 'groupsMemberOf' ] 787 | }, 788 | contextSpecificVisibleProperties: { 789 | users: [ 'id', 'username' ] 790 | } 791 | }).then(function(result) { 792 | expect(result).to.eql({ 793 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 794 | username: 'elephant1' 795 | }); 796 | done(); 797 | }); 798 | }); 799 | }); 800 | it('should respect a `false` value of ensureRelationsVisibleAndInvisible when ensuring relations loaded', function(done) { 801 | var anotherBookshelf = require('bookshelf')(knex); 802 | anotherBookshelf.plugin('registry'); 803 | anotherBookshelf.plugin(plugin({ 804 | handleEnsureRelation: function(relationName) { 805 | done(new Error('handleEnsureRelation should not be called')); 806 | }, 807 | ensureRelationsVisibleAndInvisible: false 808 | })); 809 | 810 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 811 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 812 | accessor: { user: { id: stubs.users.elephant1.id } } 813 | }) 814 | .fetch() 815 | .then(function(model) { 816 | model.toJSON({ 817 | ensureRelationsLoaded: { 818 | users: [ 'groupsMemberOf' ] 819 | }, 820 | contextSpecificVisibleProperties: { 821 | users: [ 'id', 'username' ] 822 | } 823 | }).then(function(result) { 824 | expect(result).to.eql({ 825 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 826 | username: 'elephant1' 827 | }); 828 | done(); 829 | }); 830 | }); 831 | }); 832 | it('should default to behaving as if ensureRelationsVisibleAndInvisible is false when ensuring relations loaded', function(done) { 833 | var anotherBookshelf = require('bookshelf')(knex); 834 | anotherBookshelf.plugin('registry'); 835 | anotherBookshelf.plugin(plugin({ 836 | handleEnsureRelation: function(relationName) { 837 | done(new Error('handleEnsureRelation should not be called')); 838 | } 839 | })); 840 | 841 | var AnotherBookshelfUser = require('../examples/rest-api/User.js')(anotherBookshelf); 842 | AnotherBookshelfUser.forge({ username: 'elephant1' }, { 843 | accessor: { user: { id: stubs.users.elephant1.id } } 844 | }) 845 | .fetch() 846 | .then(function(model) { 847 | model.toJSON({ 848 | ensureRelationsLoaded: { 849 | users: [ 'groupsMemberOf' ] 850 | }, 851 | contextSpecificVisibleProperties: { 852 | users: [ 'id', 'username' ] 853 | } 854 | }).then(function(result) { 855 | expect(result).to.eql({ 856 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 857 | username: 'elephant1' 858 | }); 859 | done(); 860 | }); 861 | }); 862 | }); 863 | }); 864 | }); 865 | describe('contextSpecificVisibleProperties', function() { 866 | 867 | sharedSanityChecking('contextSpecificVisibleProperties'); 868 | 869 | describe('Unique sanity-checking test logic', function() { 870 | var tracker = mockKnex.getTracker(); 871 | beforeEach(function(done) { 872 | tracker.install(); 873 | tracker.on('query', function sendResult(query, step) { 874 | fetchElephant1LoadGroupsMemberOf(query)[step - 1]() 875 | }); 876 | done(); 877 | }); 878 | afterEach(function(done) { 879 | tracker.uninstall(); 880 | done(); 881 | }); 882 | 883 | it('should calculate visible properties as the intersection of the role visible properties and the context-specific visible properties', function(done) { 884 | User.forge({ username: 'elephant1' }, { 885 | accessor: { user: { id: stubs.users.elephant1.id } } 886 | }) 887 | .fetch() 888 | .then(function(model) { 889 | model.toJSON({ 890 | contextSpecificVisibleProperties: { 891 | users: [ 'created_at', 'email' ] 892 | } 893 | }).then(function(result) { 894 | expect(result).to.eql({ 895 | email: 'elephant1@example.com', 896 | created_at: '2016-01-03T04:07:51.690Z' 897 | }); 898 | done(); 899 | }); 900 | }); 901 | }); 902 | it('should resolve models with no ultimately visible properties as undefined', function(done) { 903 | User.forge({ username: 'elephant1' }, { 904 | accessor: { user: { id: stubs.users.elephant1.id } } 905 | }) 906 | .fetch() 907 | .then(function(model) { 908 | model.toJSON({ 909 | contextSpecificVisibleProperties: { 910 | users: [ 'shapes', 'sizes' ] 911 | } 912 | }).then(function(result) { 913 | expect(result === undefined).to.equal(true); 914 | done(); 915 | }); 916 | }); 917 | }); 918 | }) 919 | }); 920 | describe('accessor', function() { 921 | it('should use this option value as the accessor, when serializing', function(done) { 922 | var model = User.forge(stubs.users.elephant1); 923 | var serializationResultPromise = 924 | model.toJSON({ accessor: { user: { id: stubs.users.elephant1.id } } }) 925 | .then(function(result) { 926 | expect(result).to.eql({ 927 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 928 | username: 'elephant1', 929 | email: 'elephant1@example.com', 930 | created_at: '2016-01-03T04:07:51.690Z' 931 | }); 932 | done(); 933 | }); 934 | }); 935 | it('should give precedence to this option value over `model._accessor` set during instantiation, when serializing', function(done) { 936 | var model = User.forge(stubs.users.elephant1, { accessor: { user: { id: stubs.users.elephant1.id } } }); 937 | expect(model._accessor).to.eql({ user: { id: stubs.users.elephant1.id } }); 938 | var serializationResultPromise = 939 | model.toJSON({ accessor: { user: null } }) 940 | .then(function(result) { 941 | expect(result).to.eql({ 942 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 943 | username: 'elephant1', 944 | created_at: '2016-01-03T04:07:51.690Z' 945 | }); 946 | done(); 947 | }); 948 | }); 949 | it('should give precedence to this option value over `model._accessor` set via `model.setAccessor()`, when serializing', function(done) { 950 | var model = User.forge(stubs.users.elephant1); 951 | model.setAccessor({ user: { id: stubs.users.elephant1.id } }); 952 | expect(model._accessor).to.eql({ user: { id: stubs.users.elephant1.id } }); 953 | var serializationResultPromise = 954 | model.toJSON({ accessor: { user: null } }) 955 | .then(function(result) { 956 | expect(result).to.eql({ 957 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 958 | username: 'elephant1', 959 | created_at: '2016-01-03T04:07:51.690Z' 960 | }); 961 | done(); 962 | }); 963 | }); 964 | it('should give precedence to this option value over `model._accessor` on a related model, when serializing', function(done) { 965 | var tracker = mockKnex.getTracker(); 966 | tracker.install(); 967 | tracker.on('query', function sendResult(query, step) { 968 | ( 969 | [ 970 | function() { 971 | // Fetch NPTES group 972 | query.response([ stubs.groups.NPTES ]); 973 | }, 974 | function() { 975 | query.response([ 976 | // Load group admins 977 | _.extend({}, stubs.users.antelope99, { 978 | _pivot_user_id: stubs.users.antelope99.id, 979 | _pivot_group_id: stubs.groups.NPTES.id 980 | }) 981 | ]); 982 | }, 983 | function() { 984 | // Load group members (as part of roleDeterminer) 985 | query.response([ 986 | _.extend({}, stubs.users.ostrich14, { 987 | _pivot_user_id: stubs.users.ostrich14.id, 988 | _pivot_group_id: stubs.groups.NPTES.id 989 | }) 990 | ]); 991 | } 992 | ] 993 | )[step - 1](); 994 | }); 995 | 996 | Group.forge({ id: 'b0a94a70-2db7-4063-ad0d-0ef39412bfd2' }, { 997 | accessor: { user: { id: stubs.users.antelope99.id } } 998 | }) 999 | .fetch() 1000 | .then(function(model) { 1001 | model.load('admins').then(function() { 1002 | var admins = model.related('admins'); 1003 | admins.each(function(admin) { 1004 | expect(admin._accessor).to.eql({ user: { id: stubs.users.antelope99.id } }); 1005 | }); 1006 | model.toJSON({ 1007 | ensureRelationsLoaded: { 1008 | groups: ['admins'] 1009 | }, 1010 | accessor: { user: { id: stubs.users.ostrich14.id } } 1011 | }).then(function(result) { 1012 | expect(result).to.eql({ 1013 | id: 'b0a94a70-2db7-4063-ad0d-0ef39412bfd2', 1014 | name: 'Neo-Post-Tangential Economics Society', 1015 | admins: [{ 1016 | id: '3d33e941-e23e-41fa-8807-03e87ce7baa8', 1017 | username: 'antelope99', 1018 | created_at: '2016-02-03T04:07:51.690Z' 1019 | // We don't expect antelope99's email address, because 1020 | // only antelope99 herself may see her email address, but we 1021 | // expect the accessor taking precedence to be the ostrich14 user. 1022 | }], 1023 | members: [{ 1024 | id: '140fb3a9-f688-4852-b917-5287c228f45f', 1025 | username: 'ostrich14', 1026 | email: 'ostrich14@example.com', 1027 | created_at: '2016-03-03T04:07:51.690Z' 1028 | // Conversely, we expect ostrich14's email address, because 1029 | // we expect the accessor taking precedence to be ostrich14, whereas 1030 | // we wouldn't expect the email address if the accessor taking 1031 | // precedence were antelop99. 1032 | }] 1033 | }); 1034 | 1035 | tracker.uninstall(); 1036 | done(); 1037 | }); 1038 | }); 1039 | }); 1040 | }); 1041 | }); 1042 | describe('shallow', function() { 1043 | var tracker = mockKnex.getTracker(); 1044 | beforeEach(function(done) { 1045 | tracker.install(); 1046 | tracker.on('query', function sendResult(query, step) { 1047 | fetchElephant1LoadGroupsMemberOf(query) 1048 | .concat(determineRoleElephant1SlouchyGauchos(query))[step - 1]() 1049 | }); 1050 | done(); 1051 | }); 1052 | afterEach(function(done) { 1053 | tracker.uninstall(); 1054 | done(); 1055 | }); 1056 | 1057 | it('should respect `shallow: true` which Bookshelf by default supports', function(done) { 1058 | User.forge({ username: 'elephant1' }, { 1059 | accessor: { user: { id: stubs.users.elephant1.id } } 1060 | }) 1061 | .fetch() 1062 | .then(function(model) { 1063 | model.toJSON({ 1064 | ensureRelationsLoaded: { 1065 | users: [ 'groupsMemberOf' ] 1066 | }, 1067 | contextSpecificVisibleProperties: { 1068 | users: [ 'id', 'username', 'groupsMemberOf' ] 1069 | }, 1070 | shallow: true 1071 | }).then(function(result) { 1072 | // We expect groupsMemberOf relation to have been loaded on the model, 1073 | // but not be on the serialized result because `shallow: true` specifies 1074 | // relations not to be serialized. 1075 | expect(result).to.eql({ 1076 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 1077 | username: 'elephant1' 1078 | }); 1079 | expect(model.relations).to.have.key('groupsMemberOf'); 1080 | done(); 1081 | }); 1082 | }); 1083 | }); 1084 | }); 1085 | describe('omitPivot', function() { 1086 | var regularBookshelf = require('bookshelf')(knex); 1087 | regularBookshelf.plugin('registry'); 1088 | 1089 | it('should respect `omitPivot: true` which Bookshelf by default supports', function(done) { 1090 | var teachersModelDefinition = { 1091 | tableName: 'teachers', 1092 | roleDeterminer: function() { return 'anyone'; }, 1093 | rolesToVisibleProperties: { anyone: [ 'id', 'name', 'classesTeacherOf' ] }, 1094 | classesTeacherOf: function() { 1095 | return this.belongsToMany('Class', 'class_teachers', 'teacher_id', 'class_id') 1096 | } 1097 | }; 1098 | var classesModelDefinition = { 1099 | tableName: 'classes', 1100 | roleDeterminer: function() { return 'anyone'; }, 1101 | rolesToVisibleProperties: { anyone: [ 'id', 'name', '_pivot_teacher_id', '_pivot_class_id' ] } 1102 | }; 1103 | 1104 | var teacherFixture = { 1105 | id: 1, 1106 | name: 'bert' 1107 | }; 1108 | var classFixture = { 1109 | id: 2, 1110 | name: 'history', 1111 | _pivot_teacher_id: 1, 1112 | _pivot_group_id: 2 1113 | }; 1114 | 1115 | var mock = function(query, step) { 1116 | [ 1117 | function() { 1118 | query.response([teacherFixture]); 1119 | }, 1120 | function() { 1121 | query.response([classFixture]); 1122 | } 1123 | ][step - 1]() 1124 | }; 1125 | 1126 | var expectedJsonWithPivot = { 1127 | id: 1, 1128 | name: 'bert', 1129 | classesTeacherOf: [ classFixture ] 1130 | }; 1131 | var expectedJsonWithoutPivot = { 1132 | id: 1, 1133 | name: 'bert', 1134 | classesTeacherOf: [{ 1135 | id: 2, 1136 | name: 'history' 1137 | }] 1138 | }; 1139 | 1140 | // Establish regular, unplugged-in Bookshelf behavior 1141 | 1142 | regularBookshelf.model('Teacher', regularBookshelf.Model.extend(teachersModelDefinition, {})); 1143 | regularBookshelf.model('Class', regularBookshelf.Model.extend(classesModelDefinition, {})); 1144 | 1145 | var tracker = mockKnex.getTracker(); 1146 | tracker.install(); 1147 | tracker.on('query', mock); 1148 | 1149 | regularBookshelf.model('Teacher').forge({ name: 'bert' }) 1150 | .fetch() 1151 | .then(function(model) { 1152 | return model.load('classesTeacherOf').then(function() { 1153 | 1154 | var regularJson = model.toJSON(); 1155 | expect(regularJson).to.eql(expectedJsonWithPivot); 1156 | 1157 | var regularJsonOmitPivot = model.toJSON({ omitPivot: true }); 1158 | expect(regularJsonOmitPivot).to.eql(expectedJsonWithoutPivot); 1159 | 1160 | tracker.uninstall(); 1161 | }); 1162 | }) 1163 | .then(function() { 1164 | 1165 | // Establish same functionality for plugged-in bookshelf 1166 | 1167 | bookshelf.model('Teacher', bookshelf.Model.extend(teachersModelDefinition, {})); 1168 | bookshelf.model('Class', bookshelf.Model.extend(classesModelDefinition, {})); 1169 | 1170 | var tracker = mockKnex.getTracker(); 1171 | tracker.install(); 1172 | tracker.on('query', mock); 1173 | 1174 | regularBookshelf.model('Teacher').forge({ name: 'bert' }) 1175 | .fetch() 1176 | .then(function(model) { 1177 | model.load('classesTeacherOf').then(function() { 1178 | 1179 | var jsonPromise = model.toJSON(); 1180 | var jsonOmitPivotPromise = model.toJSON({ omitPivot: true }); 1181 | 1182 | BluebirdPromise.join(jsonPromise, jsonOmitPivotPromise, function(json, jsonOmitPivot) { 1183 | expect(json).to.eql(expectedJsonWithPivot); 1184 | expect(jsonOmitPivot).to.eql(expectedJsonWithoutPivot); 1185 | 1186 | tracker.uninstall(); 1187 | 1188 | done(); 1189 | }); 1190 | }); 1191 | }); 1192 | }); 1193 | }); 1194 | }); 1195 | describe('omitNew', function() { 1196 | it('should respect `omitNew: true`', function(done) { 1197 | User.forge({ username: 'elephant1' }, { accessor: { user: { id: stubs.users.elephant1.id } } }) 1198 | .toJSON({ omitNew: true }) 1199 | .then(function(json) { 1200 | expect(json).to.equal(null); 1201 | }) 1202 | .asCallback(done); 1203 | }); 1204 | it('should respect `omitNew: false`', function(done) { 1205 | User.forge({ username: 'elephant1' }, { accessor: { user: { id: stubs.users.elephant1.id } } }) 1206 | .toJSON({ omitNew: false }) 1207 | .then(function(json) { 1208 | expect(json).to.eql({ username: 'elephant1' }); 1209 | }) 1210 | .asCallback(done); 1211 | }); 1212 | it('should default to `omitNew: false`', function(done) { 1213 | User.forge({ username: 'elephant1' }, { accessor: { user: { id: stubs.users.elephant1.id } } }) 1214 | .toJSON() 1215 | .then(function(json) { 1216 | expect(json).to.eql({ username: 'elephant1' }); 1217 | }) 1218 | .asCallback(done); 1219 | }); 1220 | }); 1221 | }); 1222 | it('should remove relations from the model that do not need to be serialized, which can be important to prevent infinite looping', function(done) { 1223 | var tracker = mockKnex.getTracker(); 1224 | tracker.install(); 1225 | tracker.on('query', function sendResult(query, step) { 1226 | fetchElephant1LoadGroupsMemberOf(query)[step - 1]() 1227 | }); 1228 | 1229 | User.forge({ username: 'elephant1' }, { 1230 | accessor: { user: { id: stubs.users.elephant1.id } } 1231 | }) 1232 | .fetch() 1233 | .then(function(model) { 1234 | model.load('groupsMemberOf').then(function() { 1235 | expect(model.relations.hasOwnProperty('groupsMemberOf')).to.equal(true); 1236 | model.toJSON({ 1237 | contextSpecificVisibleProperties: { 1238 | users: [ 'id', 'username', 'email', 'created_at' ] 1239 | } 1240 | }).then(function(result) { 1241 | expect(model.relations.hasOwnProperty('groupsMemberOf')).to.equal(false); 1242 | 1243 | tracker.uninstall(); 1244 | done(); 1245 | }); 1246 | }); 1247 | }); 1248 | }); 1249 | it('in properties that are relations that are arrays (i.e. collections), should remove `undefined`s', function(done) { 1250 | var tracker = mockKnex.getTracker(); 1251 | tracker.install(); 1252 | tracker.on('query', function sendResult(query, step) { 1253 | ( 1254 | fetchElephant1LoadGroupsMemberOf(query) 1255 | .concat(determineRoleElephant1SlouchyGauchos(query)) 1256 | .concat(determineRoleElephant1SlouchyGauchos(query)) // Necessary for the second call of .toJSON(), 1257 | // because `admins` and `members` relations were deleted after first 1258 | // call of .toJSON() because they were not visible properties 1259 | )[step - 1](); 1260 | }); 1261 | 1262 | User.forge({ username: 'elephant1' }, { 1263 | accessor: { user: { id: stubs.users.elephant1.id } } 1264 | }) 1265 | .fetch() 1266 | .then(function(model) { 1267 | model.load('groupsMemberOf').then(function() { 1268 | var groups = model.related('groupsMemberOf'); // Need to call this because 1269 | // .related() is what transfers model._accessor to the relation 1270 | model.toJSON({ 1271 | contextSpecificVisibleProperties: { 1272 | groups: [ 'id', 'name' ] 1273 | } 1274 | }).then(function(result) { 1275 | expect(result).to.eql({ 1276 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 1277 | username: 'elephant1', 1278 | email: 'elephant1@example.com', 1279 | created_at: '2016-01-03T04:07:51.690Z', 1280 | groupsMemberOf: [{ 1281 | id: '0f91fab2-48b5-4396-9b75-632f99da02c2', 1282 | name: 'Slouchy gauchos' 1283 | }] 1284 | }); 1285 | 1286 | model.toJSON({ 1287 | contextSpecificVisibleProperties: { 1288 | groups: [] 1289 | } 1290 | }).then(function(result) { 1291 | expect(result).to.eql({ 1292 | id: '3fe94198-7b32-44ee-abdd-04104b902c51', 1293 | username: 'elephant1', 1294 | email: 'elephant1@example.com', 1295 | created_at: '2016-01-03T04:07:51.690Z', 1296 | groupsMemberOf: [] 1297 | }); 1298 | 1299 | tracker.uninstall(); 1300 | done(); 1301 | }); 1302 | }); 1303 | }); 1304 | }); 1305 | }); 1306 | it('in properties that are relations that are arrays (i.e. collections), should wait for all promises to be resolved', function(done) { 1307 | var tracker = mockKnex.getTracker(); 1308 | tracker.install(); 1309 | tracker.on('query', function sendResult(query, step) { 1310 | fetchElephant1LoadGroupsMemberOf(query) 1311 | .concat(determineRoleElephant1SlouchyGauchos(query))[step - 1](); 1312 | }); 1313 | 1314 | User.forge({ username: 'elephant1' }, { 1315 | accessor: { user: { id: stubs.users.elephant1.id } } 1316 | }) 1317 | .fetch() 1318 | .then(function(model) { 1319 | model.load('groupsMemberOf').then(function() { 1320 | var groups = model.related('groupsMemberOf'); // Need to call this because 1321 | // .related() is what transfers model._accessor to the relation 1322 | model.toJSON({ 1323 | contextSpecificVisibleProperties: { 1324 | groups: [ 'id', 'name' ] 1325 | } 1326 | }).then(function(result) { 1327 | expect(result.groupsMemberOf).to.eql([{ 1328 | id: '0f91fab2-48b5-4396-9b75-632f99da02c2', 1329 | name: 'Slouchy gauchos' 1330 | }]); // This array would be an array containing a serialized promise, 1331 | // if relation arrays were not resolved as a promise of all the 1332 | // promises in the array. QED. 1333 | 1334 | tracker.uninstall(); 1335 | done(); 1336 | }); 1337 | }); 1338 | }); 1339 | }); 1340 | it('should serialize a relation that is an empty model as null rather than as an empty object', function(done) { 1341 | // Cf. https://github.com/tgriesser/bookshelf/issues/753 1342 | 1343 | // Create an unplugged-in version of Bookshelf, for comparison 1344 | var regularBookshelf = require('bookshelf')(knex); 1345 | regularBookshelf.plugin('registry'); 1346 | var RegularComment = require('../examples/rest-api/Comment.js')(regularBookshelf); 1347 | 1348 | var configureTracker = function(tracker) { 1349 | tracker.on('query', function sendResult(query, step) { 1350 | [ 1351 | function() { 1352 | query.response([ 1353 | stubs.comments[0] 1354 | ]); 1355 | }, 1356 | function() { 1357 | query.response([]); 1358 | } 1359 | ][step - 1](); 1360 | }); 1361 | }; 1362 | 1363 | var tracker = mockKnex.getTracker(); 1364 | tracker.install(); 1365 | configureTracker(tracker); 1366 | 1367 | RegularComment.forge({ id: '8dc1464d-8c32-448d-a81d-ff161077d781' }, { 1368 | accessor: { user: { id: stubs.users.elephant1.id } } 1369 | }) 1370 | .fetch() 1371 | .then(function(regularComment) { 1372 | regularComment.load('author').then(function() { 1373 | var regularResult = regularComment.toJSON(); 1374 | expect(regularResult).to.eql({ 1375 | id: '8dc1464d-8c32-448d-a81d-ff161077d781', 1376 | author_id: '3fe94198-7b32-44ee-abdd-04104b902c51', 1377 | author: {}, 1378 | content: 'Hello, World!' 1379 | }); 1380 | 1381 | tracker.uninstall(); 1382 | 1383 | tracker = mockKnex.getTracker(); 1384 | tracker.install(); 1385 | configureTracker(tracker); 1386 | 1387 | Comment.forge({ id: '8dc1464d-8c32-448d-a81d-ff161077d781' }, { 1388 | accessor: { user: { id: stubs.users.elephant1.id } } 1389 | }) 1390 | .fetch() 1391 | .then(function(comment) { 1392 | comment.load('author').then(function() { 1393 | var author = comment.related('author'); // Need to call this because 1394 | // .related() is what transfers model._accessor to the relation 1395 | 1396 | comment.toJSON().then(function(result) { 1397 | expect(result).to.eql({ 1398 | id: '8dc1464d-8c32-448d-a81d-ff161077d781', 1399 | author: null, 1400 | content: 'Hello, World!' 1401 | }); 1402 | 1403 | tracker.uninstall(); 1404 | done(); 1405 | }); 1406 | }); 1407 | }); 1408 | }); 1409 | }); 1410 | }); 1411 | it('should serialize a standalone empty model as an empty object', function(done) { 1412 | // Not sure why anyone would ever care about the result of serializing an empty object, 1413 | // but it's good to document the behavior nonetheless. 1414 | bookshelf.Model.extend({ 1415 | tableName: 'foo', 1416 | roleDeterminer: function() { return 'anyone'; }, 1417 | rolesToVisibleProperties: { anyone: [ 'bar' ] } 1418 | }).forge({}, { accessor: 1419 | { user: { id: stubs.users.elephant1.id } } 1420 | }) 1421 | .toJSON() 1422 | .then(function(result) { 1423 | expect(result).to.eql({}); 1424 | done(); 1425 | }); 1426 | }); 1427 | it('should serialize an empty model that exists in a collection that is a relation as an empty object', function(done) { 1428 | var tracker = mockKnex.getTracker(); 1429 | tracker.install(); 1430 | tracker.on('query', function sendResult(query, step) { 1431 | fetchElephant1LoadGroupsMemberOf(query) 1432 | .concat(determineRoleElephant1SlouchyGauchos(query))[step - 1](); 1433 | }); 1434 | 1435 | User.forge({ username: 'elephant1' }, { 1436 | accessor: { user: { id: stubs.users.elephant1.id } } 1437 | }) 1438 | .fetch() 1439 | .then(function(user) { 1440 | user.load('groupsMemberOf').then(function() { 1441 | var groups = user.related('groupsMemberOf'); // Need to call this because 1442 | // .related() is what transfers model._accessor to the relation 1443 | groups.add(User.forge({}, { 1444 | accessor: { user: { id: stubs.users.elephant1.id } } 1445 | })); 1446 | 1447 | user.toJSON({ 1448 | contextSpecificVisibleProperties: { 1449 | groups: [ 'id', 'name' ] 1450 | } 1451 | }).then(function(result) { 1452 | expect(result.groupsMemberOf).to.eql([ 1453 | { 1454 | id: '0f91fab2-48b5-4396-9b75-632f99da02c2', 1455 | name: 'Slouchy gauchos' 1456 | }, 1457 | {} 1458 | ]); 1459 | 1460 | tracker.uninstall(); 1461 | done(); 1462 | }); 1463 | }); 1464 | }); 1465 | }); 1466 | }); 1467 | describe('related', function() { 1468 | var tracker = mockKnex.getTracker(); 1469 | beforeEach(function() { 1470 | tracker.install(); 1471 | tracker.on('query', function sendResult(query, step) { 1472 | fetchElephant1LoadGroupsMemberOf(query)[step - 1](); 1473 | }); 1474 | }); 1475 | afterEach(function() { 1476 | tracker.uninstall(); 1477 | }); 1478 | it('should transfer _accessor on the model to the relation loaded via .related()', function(done) { 1479 | User.forge({ username: 'elephant1' }, { accessor: { user: 'foo' }}).fetch() 1480 | .then(function(user) { 1481 | user.load('groupsMemberOf').then(function() { 1482 | var groups = user.related('groupsMemberOf'); 1483 | expect(groups.length).to.equal(1); 1484 | groups.each(function(group) { 1485 | expect(group._accessor).to.eql({ user: 'foo' }); 1486 | }); 1487 | done(); 1488 | }); 1489 | }); 1490 | }); 1491 | it('should transfer _accessedAsRelationChain to the related model and append to it the relation name', function(done) { 1492 | User.forge({ username: 'elephant1' }, { accessor: { user: 'foo' }}).fetch() 1493 | .then(function(user) { 1494 | user.load('groupsMemberOf').then(function() { 1495 | var groups = user.related('groupsMemberOf'); 1496 | expect(groups.length).to.equal(1); 1497 | var group = groups.at(0); 1498 | expect(user._accessedAsRelationChain).to.eql([]); 1499 | expect(group._accessedAsRelationChain).to.eql([ 'groupsMemberOf' ]); 1500 | done(); 1501 | }); 1502 | }); 1503 | }); 1504 | }); 1505 | }); 1506 | 1507 | describe('Collection', function() { 1508 | var bookshelf = require('bookshelf')(knex); 1509 | bookshelf.plugin('registry'); 1510 | bookshelf.plugin(plugin()); 1511 | 1512 | var Comment = require('../examples/rest-api/Comment.js')(bookshelf); 1513 | var User = require('../examples/rest-api/User.js')(bookshelf); 1514 | 1515 | describe('serialize', function() { 1516 | it('should wait for all promises in the collection to resolve', function(done) { 1517 | bookshelf.Collection.extend({ model: Comment }).forge([ 1518 | Comment.forge({ content: 'comment1' }, 1519 | { accessor: { user: { id: stubs.users.elephant1.id } } }), 1520 | Comment.forge({ content: 'comment2' }, 1521 | { accessor: { user: { id: stubs.users.elephant1.id } } }), 1522 | ]) 1523 | .toJSON() 1524 | .then(function(result) { 1525 | expect(result).to.eql([ 1526 | { content: 'comment1' }, 1527 | { content: 'comment2' } 1528 | ]); // This would be serialized as an array of promises if not all promises 1529 | // in the collection were waited for. QED. 1530 | done(); 1531 | }); 1532 | }); 1533 | it('should remove `undefined`s from a serialized collection', function(done) { 1534 | bookshelf.Collection.extend({ model: Comment }).forge([ 1535 | Comment.forge({ id: 1, content: 'comment1' }, 1536 | { accessor: { user: { id: stubs.users.elephant1.id } } }), 1537 | Comment.forge({ id: 2, content: 'comment2' }, 1538 | { accessor: { user: { id: stubs.users.elephant1.id } } }), 1539 | ]) 1540 | .toJSON({ 1541 | contextDesignator: function(tableName, relationChain, id) { 1542 | if (tableName === 'comments') { 1543 | if (id === 1) { 1544 | return 'foo'; 1545 | } else if (id === 2) { 1546 | return 'bar'; 1547 | } 1548 | } 1549 | }, 1550 | contextSpecificVisibleProperties: { 1551 | comments: { 1552 | foo: [ 'id', 'content' ], 1553 | bar: [] 1554 | } 1555 | } 1556 | }) 1557 | .then(function(result) { 1558 | expect(result).to.eql([ 1559 | { id: 1, content: 'comment1' } 1560 | ]); 1561 | 1562 | done(); 1563 | }); 1564 | }); 1565 | }); 1566 | describe('toJSON', function() { 1567 | it('should return a promise of a serialization result', function(done) { 1568 | var serializationResultPromise = bookshelf.Collection.extend({ model: Comment }).forge([ 1569 | User.forge({ username: 'foo' }, { accessor: { user: { id: stubs.users.elephant1.id } } }) 1570 | ]).toJSON(); 1571 | expect(serializationResultPromise).to.be.a(BluebirdPromise); 1572 | serializationResultPromise.then(function(serializationResult) { 1573 | serializationResultPromise.then(function(serializationResult) { 1574 | expect(serializationResult).to.eql([{ username: 'foo' }]); 1575 | done(); 1576 | }); 1577 | }); 1578 | }); 1579 | it('should apply options passed to `toJSON` to the models in the collection', function(done) { 1580 | bookshelf.Collection.extend({ model: Comment }).forge([ 1581 | Comment.forge({ id: 1, content: 'comment1' }, 1582 | { accessor: { user: { id: stubs.users.elephant1.id } } }) 1583 | ]) 1584 | .toJSON({ 1585 | // TODO Could strengthen this by including `contextDesignator` and `ensureRelationsLoaded`. 1586 | contextSpecificVisibleProperties: { 1587 | comments: [ 'content' ] 1588 | } 1589 | }) 1590 | .then(function(result) { 1591 | expect(result).to.eql([ 1592 | { content: 'comment1' } 1593 | ]); 1594 | 1595 | done(); 1596 | }); 1597 | }); 1598 | it('should serialize an empty model in a standalone collection as an empty object', function(done) { 1599 | // Not sure why anyone would care about this behavior, but good to document it. 1600 | bookshelf.Collection.extend({ model: Comment }).forge([ 1601 | Comment.forge({}, { accessor: { user: { id: stubs.users.elephant1.id } } }) 1602 | ]).toJSON().then(function(result) { 1603 | expect(result).to.eql([ {} ]); 1604 | done(); 1605 | }); 1606 | }); 1607 | }); 1608 | }); 1609 | --------------------------------------------------------------------------------